{"owner":"d2phap","repo":"ImageGlass","hasSkills":true,"totalSkillsCount":2,"totalTokensCount":12030,"categories":["mcp-config","claude-rule"],"hasMcp":true,"mcpConfig":{"servers":{"avalonia-docs":{"url":"https://docs-mcp.avaloniaui.net/mcp","type":"http"}},"inputs":[]},"found":["source/.vscode/mcp.json","source/CLAUDE.md"],"skills":{"source/.vscode/mcp.json":"{\n\t\"servers\": {\n\t\t\"avalonia-docs\": {\n\t\t\t\"url\": \"https://docs-mcp.avaloniaui.net/mcp\",\n\t\t\t\"type\": \"http\"\n\t\t}\n\t},\n\t\"inputs\": []\n}","source/CLAUDE.md":"# ImageGlass v10\n\n## Project Overview\nImageGlass 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.\n\n## Repository Layout\n- **`/source`** — v10 source code (the active codebase; this `CLAUDE.md` lives here). All development happens here.\n- **`/v9`** — legacy v9 source code (WinForms/.NET, archived).\n\n> **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.\n\n**Key Projects:**\n- `ImageGlass.Lib`: Core library (net11.0) — UI controls, rendering, codecs, themes, localization, settings\n- `ImageGlass.Win32`: Windows desktop app (net11.0-windows10.0.19041.0) — entry point with Win32 APIs and platform-specific services\n- `ImageGlass.Linux`: Linux desktop app (net11.0) — entry point with Linux-specific services\n- `ImageGlass.Mac`: macOS desktop app (net11.0) — entry point with macOS-specific services\n\n---\n\n## Critical Focus Areas (Always Prioritize)\n\n### 1. AOT/Trim Safety\n- **All platform projects** (Win32, Linux, Mac) use `PublishAot=true` and `PublishTrimmed=true` with trim analyzer enabled.\n- **Win32 project** additionally uses `DisableRuntimeMarshalling=true` and CsWin32 code generation.\n- Avoid reflection-based serialization; use custom JSON converters in `Common/Types/JsonTypeConverters/`.\n- Test AOT builds: `dotnet publish ImageGlass.Win32 -c Release` produces a self-contained, trimmed executable.\n\n### 2. Thread-Safety (No Data Races)\n- Use `Lock` for protecting shared state (not `object` locks).\n- Use `InterlockedBool` instead of plain `bool` for concurrent flags.\n- Use `ConcurrentDictionary` for thread-safe collections (`PhotoManager` index).\n- Always check `IsDisposed` before accessing resources in multi-threaded contexts.\n- Example: `PhotoManager` uses `Lock _lock` to protect `_items`, `_dict`, and `_cachedIndexes`.\n\n### 3. Memory Leak Prevention\n- Use `try/finally` or `using` statements for all `SKObject` acquisitions.\n- Override `OnDisposing()` in `PhDisposable` subclasses to clean up unmanaged resources.\n- Example: `MipmapTileCache` evicts with `RequestDispose()` (never `Dispose()`) so an in-flight render lease stays valid.\n- Always dispose `SKImageRef` leases when done; use `KeepAlive()` pattern in `MipmapTileCache`.\n\n### 4. Memory Usage Control\n- Respect cache limits: `MipmapTileCache` caps 100 tiles; `PhotoManager_Caching` honors `Config.MaxMemoryCacheInMb`, `Config.MaxFileSizeCacheInMb`, `Config.MaxDimensionCache`.\n- LRU eviction is mandatory in tile and photo caches to prevent unbounded growth.\n- **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\").\n- Mark a finished bitmap `SetImmutable()` before `SKImage.FromBitmap` (`SkiaCodec.ToSKImageNoCopy`), otherwise Skia duplicates the entire pixel buffer and doubles peak memory.\n- Profile memory with large images and GIF animations to ensure caches don't leak.\n\n### 5. Cross-Platform Design\n- **Interfaces first** in `ImageGlass.Lib/Common/ServiceProviders/*`: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, etc.\n- **Implementations** per platform:\n  - `ImageGlass.Win32/Common/ServiceProviders/*`: `Win32FileSearchProvider`, `Win32ShellProvider`, etc.\n  - `ImageGlass.Linux/Common/ServiceProviders/*`: `LinuxPrintProvider`, `LinuxShellProvider`, etc.\n  - `ImageGlass.Mac/Common/ServiceProviders/*`: `MacPrintProvider`, `MacShellProvider`, etc.\n- Register service providers in each platform's `Program.cs` before app bootstrap.\n- Avoid platform-specific code in library; use dependency injection via `Core.API`, `Core.FileSearchProvider`, etc.\n\n---\n\n## Architecture Highlights\n\n### Core Application Layers\n1. **Core Static Hub** (`Core.cs`, `Core_Events.cs`): Central event dispatcher and singleton registry\n   - `Core.AppInstance`: App lifecycle & unique instance enforcement\n   - `Core.Config`: Global configuration state\n   - `Core.Photos`: Photo collection and file searching\n   - Events: `LanguageChanged`, `ThemeChanged`, `PhotoUnloaded`, `PhotoSaved`, `ColorProfileChanged`\n   - Service provider slots: `ShellProvider`, `PreviewProvider`, `FileSearchProvider`, `PrintProvider`, `API`\n\n2. **Service Providers** (Plug-in Pattern)\n   - Platform-agnostic interfaces in `ImageGlass.Lib/Common/ServiceProviders/*`\n   - Platform implementations:\n     - Win32: `ImageGlass.Win32/Common/ServiceProviders/*`\n     - Linux: `ImageGlass.Linux/Common/ServiceProviders/*`\n     - macOS: `ImageGlass.Mac/Common/ServiceProviders/*`\n   - Shared concrete providers in library: `PhotoPreviewProvider`, `SlideshowProvider`, `UpdateProvider`\n   - Examples: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, `IWindowColorProfileProvider`, `IPhotoPreviewProvider`\n   - Registered at startup in each platform's `Program.cs`; initialized in `App.xaml.cs`\n\n3. **Photo & Codec Abstraction**\n   - `PhotoManager`: Thread-safe collection with `AvaloniaList<Photo>` + `ConcurrentDictionary` index\n   - Codecs: `SkiaCodec` (fast SkiaSharp pipeline) and `MagickCodec` (Magick.NET fallback for obscure formats)\n   - Async loading via `PhotoLoadingOptions` + frame animation support via `SkiaAnimator`/`AnimatorImpl`\n   - Caching: `PhotoManager_Caching.cs` uses spiral pattern, LRU eviction, memory budgets\n\n4. **Viewer & Rendering** (`ViewerControl`)\n   - Partial classes split by concern: `ViewerControl_Render.cs`, `ViewerControl_ZoomAndPan.cs`, `ViewerControl_Events.cs`, `ViewerControl_Animation.cs`, `ViewerControl_NavButtons.cs`, etc.\n   - High-performance mipmap tile caching: `MipmapTileCache` with LRU eviction for large images (8192×8192+)\n   - Gesture recognizers: `PhPanGestureRecognizer`, `PhPinchGestureRecognizer` accumulate points for smooth interaction\n   - SkiaSharp rendering in `ViewerControl_Render.cs` + `PhotoRenderer.cs`\n   - **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\n   - **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\n\n---\n\n## Code Organization & Naming Conventions\n\n### Folder Structure\n```\nImageGlass.Lib/\n├── Common/\n│   ├── Actions/                   # Action definitions\n│   ├── Commands/                  # Command definitions\n│   ├── AppThemes/                 # Theme system: IgTheme, IgThemeColors, AppThemeColors, IgThemeMetadata\n│   ├── BHelper/                   # Static helper methods (see \"Reusing Shared Code\"): Color, Format, General, JsonEx, Path, ProcessHelper, Sound, Task\n│   ├── Extensions/                # Extension methods (see \"Reusing Shared Code\"): Color_Exts, DrawingContext_Exts, ISolidColorBrush_Exts, Point_Exts, Rect_Exts, Size_Exts, SKObject_Exts\n│   ├── Localization/              # Lang.cs, LangId enum\n│   ├── Photoing/                  # Photo management, codecs, animators\n│   │   ├── Animators/             # SkiaAnimator, AnimatorImpl\n│   │   ├── Codecs/                # Codec pipeline\n│   │   │   ├── MagickCodecs/      # Magick.NET fallback codec\n│   │   │   ├── Registry/          # CodecRegistry\n│   │   │   ├── SkiaCodecs/        # SkiaSharp fast-path codec\n│   │   │   └── SvgCodecs/         # SVG (vector) codec\n│   │   ├── Manager/               # PhotoManager & PhotoManager_*.cs (search, watcher, caching)\n│   │   └── Photos/                # Photo, PhotoMetadata, PhotoColorProfile, etc.\n│   ├── Printing/                  # Print layouts, layout engine, page renderer, PDF writer, PrintSession (see \"Printing\")\n│   ├── ServiceProviders/          # Interface contracts + shared providers\n│   │   ├── AppAPIs/               # App API provider interfaces\n│   │   ├── FileSearchService/     # File search service abstractions\n│   │   ├── Update/                # Update provider abstractions\n│   │   ├── I*.cs                  # Interfaces (IFileSearchProvider, IShellProvider, etc.)\n│   │   ├── PhotoPreviewProvider.cs\n│   │   ├── SlideshowProvider.cs\n│   │   └── UpdateProvider.cs\n│   └── Types/                     # Base classes + shared types: PhReactive, PhDisposable, InterlockedBool, Resx, Const, Hotkey, SKImageRef, Enums, Dir\n│       └── JsonTypeConverters/    # AOT-safe JSON converters (see \"Reusing Shared Code\")\n├── Plugins/                       # Native plugin host: PluginRegistry, codec proxies\n├── Settings/                      # Config.cs, Config_Static.cs, ConfigMetadata.cs\n├── Tools/                         # Built-in & external tool framework\n│   ├── Builtins/                  # ColorPicker, CropImage, FrameNav, ImageResizer, LosslessCompression\n│   └── External/                  # External tool process management\n├── UI/\n│   ├── BaseControls/              # Ph* controls (see \"Designing UI\"): PhControl, PhButton, PhTextBox, PhTextBlock, PhToolButton, PhMenuItem, PhHotkeyPicker, PhGridSplitter, PhVirtualizingUniformPanel, PhCommandPreview, PhSearchBoxControl, PhTableControl\n│   ├── Gallery/                   # Gallery browsing UI (GalleryControl)\n│   ├── Styles/                    # App-wide XAML styles + resource dictionaries (see \"Designing UI\"); merged in App.axaml\n│   ├── Toolbar/                   # Toolbar UI and model (ToolbarControl)\n│   ├── Viewer/                    # ViewerControl (partial classes by feature)\n│   │   ├── Checkerboard/          # Transparency checkerboard background\n│   │   ├── NavButtons/            # NavButtonsOverlay, NavButtonsInfo, NavButtonClickedEventArgs\n│   │   ├── Renderer/              # MipmapTileCache, PhotoRenderer\n│   │   ├── Selection/             # Selection tools\n│   │   └── ZoomAndPan/            # Gesture recognizers, zoom/pan math\n│   └── Windowing/                 # PhWindow, ModalWindow, DialogWindow, PhColorPickerDialog\n├── ViewModels/                    # MainWindowViewModel, MainWindowModel, SettingsViewModel\n└── Windows/                       # Top-level windows: MainWindow, AboutWindow, SettingsWindow, UpdateWindow, ExportFramesWindow\n    ├── Main/                      # MainWindow_View partial\n    ├── Print/                     # PrintWindow, its view, preview and layout tiles\n    └── Settings/                  # Settings UI: Controls/, Pages/, Windows/\n\nImageGlass.Win32/\n├── Program.cs                     # Entry point, service registration, AOT bootstrap\n├── Properties/                    # PublishProfiles\n├── Windows/                       # MainWindow32.cs (Win32-specific window)\n└── Common/\n    ├── ServiceProviders/          # Win32 implementations (Win32FileSearchProvider, etc.)\n    └── WinAPI/                    # P/Invoke wrappers (Win32*.cs files)\n\nImageGlass.Linux/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    └── ServiceProviders/          # Linux implementations (LinuxPrintProvider, etc.)\n\nImageGlass.Mac/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    ├── Sandbox/                   # macOS sandbox helpers\n    └── ServiceProviders/          # macOS implementations (MacPrintProvider, etc.)\n```\n\n### Naming Rules\n- **Classes inheriting from Control/UserControl**: Use `PhControl` base (e.g., `ToolbarControl : PhControl`)\n- **View Models**: Suffix with `ViewModel` (e.g., `MainWindowViewModel : PhReactive`)\n- **Async methods**: Always suffix with `Async`\n- **Codecs/Providers**: Prefix with platform/source (e.g., `SkiaCodec`, `Win32FileSearchProvider`, `MagickCodec`)\n- **Partial class files**: Name by concern (e.g., `ViewerControl_Render.cs`, `PhotoManager_Caching.cs`, `AppAPIProvider_Hotkeys.cs`)\n\n---\n\n## Reusing Shared Code (Always Check Before Writing New)\n\nBefore adding a helper, converter, or extension, check these folders; the utility you need likely already exists.\n\n### Type Extensions: `Common/Extensions/`\nExtension methods on framework types. Prefer these over re-implementing:\n- `Color_Exts`: `ToBrush()`, `WithAlpha()`, `NoAlpha()`, `Blend()`, `WithBrightness()`, `IsLight()`, `BlackOrWhite()` / `InvertBlackOrWhite()`, `ToHex()`, `ToRgbaString()`, `ToCmyk()` / `ToCmykString()`, `ToHslString()`, `ToHsvString()`, `ToCIELAB()` / `ToCIELABString()`\n- `ISolidColorBrush_Exts`, `DrawingContext_Exts`, `Point_Exts`, `Rect_Exts`, `Size_Exts`: Avalonia geometry & drawing helpers\n- `SKObject_Exts`: `IsDisposed()` and other SkiaSharp guards\n\n### Helper Methods: `Common/BHelper/` (static `BHelper` partial class)\n- `Color.cs`: `ColorFromHex()`\n- `Format.cs`: `FormatSize()`, `FormatDateTime()`, `SimplifyFractions()`, star-rating formatting\n- `General.cs`: `OS`, `GenerateWrappedIndexes()` (spiral cache order), `ComputeIndexInRange()`, `ResizeRatio()`, `GetInAppError()`\n- `Path.cs`: `ConfigDir()` / `BaseDir()`, `ResolvePath()`, `CheckPath()`, `OpenUrlAsync()`, `OpenFilePath()` / `OpenFolderPath()`, `DeleteFile()`\n  - `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.\n- `ProcessHelper.cs`: `RunExeAsync()` / `RunExeCmd()`, `RunSync()`, `ExitApp()`\n- `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)\n- `JsonEx.cs`: `CreateJsonOptions()`, `ReadJsonFromFile()` / `WriteJsonToFileAsync()` (AOT-safe via `JsonTypeInfo<T>`); both go through `FileIO.cs`, so every JSON file is multi-instance safe\n- `FileIO.cs`: `OpenReadShared()` / `WriteAllTextSharedAsync()`, the multi-instance-safe file primitives (see \"Concurrent file writes\")\n- `Task.cs`: `Debounce()`, `GcCollect()`\n\n### JSON Converters: `Common/Types/JsonTypeConverters/`\nAOT-safe converters for `Config` serialization. Reuse an existing one, or add a new converter here; never use reflection-based serialization.\n- `JsonStringEnumSafeConverter<T>` (always use for enums; never the built-in `JsonStringEnumConverter`)\n- `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`, `JsonArrayToRectConverter` / `JsonArrayToPixelRectConverter`, `JsonArrayToDoubleConverter` / `JsonArrayToIntConverter`, `JsonDateTimeConverter`, `JsonHashSetToStringConverter`, `JsonObservableCollectionToStringConverter`\n\n---\n\n## Designing UI\n\nWhen building or restyling UI, reuse the app's existing controls, design tokens, styles, icons, and fonts instead of raw Avalonia controls or hardcoded values.\n\n### 1. Controls: prefer `UI/` over raw Avalonia\n- **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).\n- **Windows & dialogs** (`UI/Windowing/`): inherit `PhWindow`; for modal/dialog flows use `ModalWindow` / `DialogWindow`; use `PhColorPickerDialog` for color picking.\n- **Feature controls**: `ViewerControl` (`UI/Viewer/`), `GalleryControl` (`UI/Gallery/`), `ToolbarControl` (`UI/Toolbar/`).\n\n### 2. Colors, brushes & styles: never hardcode\n- **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.\n- **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`).\n- **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.\n- **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`).\n\n### 3. Icons: `UI/Styles/IconResources.axaml`\nReuse 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()`.\n\n### 4. Fonts: `Common/Types/Const.cs:81-104`\n- **Font family**: `Const.FONT_CODE` (OS-specific monospace stack for code / credits / metadata text).\n- **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.\n\n---\n\n## Critical Base Classes & Patterns\n\n### 1. Reactive Programming — `PhReactive`\n- All ViewModels inherit from `PhReactive : INotifyPropertyChanged`\n- Thread-safe property change notifications using `Lock` (not `object`)\n- Use with Avalonia compiled bindings: `{CompiledBinding PropertyName}`\n- Example:\n  ```csharp\n  public class MainWindowViewModel : PhReactive\n  {\n      public string Title\n      {\n          get; set\n          {\n              if (field.Equals(value)) return;\n              field = value;\n              OnPropertyChanged();\n          }\n      } = BHelper.AppName;\n  }\n  ```\n\n### 2. Resource Management — `PhDisposable`\n- Inherit when managing unmanaged resources (SKImage, file handles, etc.)\n- Override `OnDisposing()` (called on Dispose, not destructor)\n- Uses `InterlockedBool _isDisposed` for thread-safe disposal checks\n- Use with `await using` in async contexts\n- Example:\n  ```csharp\n  public sealed class MipmapTileCache : PhDisposable\n  {\n      protected override void OnDisposing()\n      {\n          lock (_lock)\n          {\n              foreach (var bitmap in _tiles.Values)\n              {\n                  bitmap.Dispose();\n              }\n              _tiles.Clear();\n          }\n      }\n  }\n  ```\n\n### 3. Thread-Safe Operations — `InterlockedBool`\n- Use instead of `bool` for concurrent state flags\n- Atomic read/write using `Volatile` and `Interlocked` operations\n- Example: `_isPreviewing`, `_isFirstDraw`, `_isDisposed` in ViewerControl\n- Check with `.Value` property or implicit conversion\n\n### 4. SkiaSharp Safety — `IsDisposed()` Extension\n- Always check `SKObject.IsDisposed()` before using SkiaSharp objects\n- Defined in `ImageGlass.Common.Extensions.SKObject_Exts`\n- Returns `true` if object is `null` or `Handle == IntPtr.Zero`\n- Example:\n  ```csharp\n  if (_skImage.IsDisposed()) { _skImage = null; }\n  ```\n\n---\n\n## Critical Workflows & Commands\n\n### Build\n```powershell\n# Restore and build\ndotnet build\n\n# Publish AOT-enabled binary (ImageGlass.Win32)\ndotnet publish ImageGlass.Win32 -c Release -o ./bin/publish\n\n# Run from source\ndotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]\n```\n\n### Debug\n- Open solution in Visual Studio 2026\n- Set `ImageGlass.Win32` as startup project\n- **Breakpoints work normally in C# code**\n- **Avalonia Hot Reload**: Available with `--debug` flag (limited to XAML/styles)\n- **Profiling**: Use Visual Studio's memory profiler for cache leak detection\n\n### Testing\n- No dedicated test projects in v10 (focus on functional validation)\n- Manual testing via `dotnet run`\n- Test command-line parsing with sample images\n- Profile memory usage with large images and GIF animations\n\n### Key C# Version & Langversion\n- **LangVersion**: `Preview` (C# 15 features available)\n- **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.\n- **Nullable**: `enable` across projects\n- **AllowUnsafeBlocks**: `true` (for P/Invoke, SkiaSharp interop)\n- **PublishAot**: `true` in all platform projects (Win32, Linux, Mac)\n- **PublishTrimmed**: `true` in all projects (library + platform)\n- **PublishSingleFile** + **SelfContained** + **PublishReadyToRun**: `true` in all platform projects\n- **DisableRuntimeMarshalling**: `true` in Win32 only (AOT-safe marshalling)\n\n---\n\n## Key Integration Points\n\n### 1. Photo Loading Pipeline\n```\nApp.Initialize() \n  → Core.InitializeAsync() \n    → PhotoManager.Add/SearchAsync()\n    → ViewerControl.LoadPhotoAsync(photoPath)\n      → Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)\n      → shrink to fit the pixel ceiling if needed → Photo.DecodeScale\n      → PhotoRenderer.RenderAsync()\n      → AnimatorImpl for GIF/WEBP animation\n    → PhotoLoading/PhotoAnimatorFrameChanged events\n    → MipmapTileCache.Create() if image > 8192×8192\n```\n\n### Oversized image decode\n- Images past the `int.MaxValue`-byte ceiling are decoded smaller rather than refused.\n- **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`.\n- **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.\n- The reduction flows `SkiaDecoderOutput`/`MagickCodecAdapter` → `CodecDecodeResult.DecodeScale` → `Photo.DecodeScale`, and `AppStatusInfo.Dimension` shows it.\n- `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.\n\n### Multi-size icons (ICO/CUR)\n- 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.\n- **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.\n- `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`.\n- `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.\n\n### 2. Theme/Localization Changes\n- Change language: `Core.LanguageChanged?.Invoke()`\n- Change theme: `Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...))`\n- UI elements auto-refresh via bindings + event subscribers\n\n### 3. Win32 Service Registration\nOccurs in `Program.cs` before Avalonia app setup (Linux/Mac have equivalent registrations):\n- `FileSearchProvider` (shell enumeration in the view order of File Explorer or a supported third-party file manager; see \"Explorer sort order & the foreground shell\")\n- `ShellProvider` (context menu, file properties, Windows Share API)\n- `PrintProvider` (GDI printing through the spooler; Print Pictures as the system dialog; see \"Printing\")\n- `ColorProfileProvider` (Monitor color profile retrieval via Win32 APIs)\n- `PreviewProvider` (thumbnail cache via Windows shell)\n\n### 4. Configuration Persistence\n- `Config.cs` handles JSON serialization with custom converters\n- Converters in `Common/Types/JsonTypeConverters/` (e.g., `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`)\n- Cached in `AppData\\Local\\ImageGlass\\`, or the startup dir in portable mode (see below)\n- Async save via `Config.SaveAsync()`\n\n### Concurrent file writes (`BHelper.WriteAllTextSharedAsync`)\n- 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.\n- **`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.\n- **`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`.\n- `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.\n- Last writer wins is unchanged and intended: each instance persists its own whole `Config`.\n\n### Portable mode (`ConfigMode`)\n- 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.\n- `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.\n- 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`.\n- **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.\n- `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.\n- 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.\n\n### Linux self-update (`LinuxUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- **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.\n- 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.\n- 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.\n- The install-while-alive helpers (`RunInstallThenExitAsync`, `RelaunchAsync`, `ForgetInstalledUpdateAsync`, `RunCommandAsync`) live in the base `UpdateProvider`, shared with macOS.\n\n### macOS self-update (`MacUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- 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`).\n\n### Photo transitions (`UI/Viewer/Transitions/`)\n- **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.\n- **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 `\n`, 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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`).\n- **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.\n\n### Full screen & the windowed layout\n- 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.\n- 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.\n- 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`.\n- 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.\n- The close handler reads `RestorableWindowState`, not `WindowState`: closing a *minimized* window would otherwise record `EnableFullScreen`/`EnableMainWindowMaximized` as false.\n- 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`.\n\n### 5. Photo Caching Strategy\n- **Tier 1**: Current photo in viewer (always loaded)\n- **Tier 2**: Neighboring photos preloaded via `PhotoManager.RequestCacheAround()`\n- **Tier 3**: LRU eviction when memory budget exhausted\n- Spiral pattern: right-1, left-1, right-2, left-2, ... to balance browsing directions\n- **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.\n- **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`.\n- **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.\n- **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.\n- **`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.\n- **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.\n\n### File search & captured file metadata\n- `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.\n- `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.\n- 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).\n- `FileSearchingEventArgs.Results` is a materialized `IReadOnlyList`, and filtering/sorting now run on the background thread instead of lazily on the UI thread during `Add`.\n- 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.\n\n### Explorer sort order & the foreground shell (EggShell)\n- **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.\n- **`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.\n- **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.\n- **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.\n- **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`.\n- **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`).\n- 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.\n\n### Gallery thumbnail pipeline (`Photo.LoadThumbnailAsync`)\n- Tiers in order, all skipped when `useCache` is false: in-memory `GalleryThumbnail`, `ThumbnailDiskCache`, `IPhotoPreviewProvider.TryGetCachedThumbnailAsync` (platform cache), then `GetThumbnailAsync` (decodes).\n- 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.\n- `_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.\n- **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.\n- **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.\n- **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.\n- `TryGetCachedThumbnailAsync` must not decode: "},"files":{"source/.vscode/mcp.json":"{\n\t\"servers\": {\n\t\t\"avalonia-docs\": {\n\t\t\t\"url\": \"https://docs-mcp.avaloniaui.net/mcp\",\n\t\t\t\"type\": \"http\"\n\t\t}\n\t},\n\t\"inputs\": []\n}","source/CLAUDE.md":"# ImageGlass v10\n\n## Project Overview\nImageGlass 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.\n\n## Repository Layout\n- **`/source`** — v10 source code (the active codebase; this `CLAUDE.md` lives here). All development happens here.\n- **`/v9`** — legacy v9 source code (WinForms/.NET, archived).\n\n> **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.\n\n**Key Projects:**\n- `ImageGlass.Lib`: Core library (net11.0) — UI controls, rendering, codecs, themes, localization, settings\n- `ImageGlass.Win32`: Windows desktop app (net11.0-windows10.0.19041.0) — entry point with Win32 APIs and platform-specific services\n- `ImageGlass.Linux`: Linux desktop app (net11.0) — entry point with Linux-specific services\n- `ImageGlass.Mac`: macOS desktop app (net11.0) — entry point with macOS-specific services\n\n---\n\n## Critical Focus Areas (Always Prioritize)\n\n### 1. AOT/Trim Safety\n- **All platform projects** (Win32, Linux, Mac) use `PublishAot=true` and `PublishTrimmed=true` with trim analyzer enabled.\n- **Win32 project** additionally uses `DisableRuntimeMarshalling=true` and CsWin32 code generation.\n- Avoid reflection-based serialization; use custom JSON converters in `Common/Types/JsonTypeConverters/`.\n- Test AOT builds: `dotnet publish ImageGlass.Win32 -c Release` produces a self-contained, trimmed executable.\n\n### 2. Thread-Safety (No Data Races)\n- Use `Lock` for protecting shared state (not `object` locks).\n- Use `InterlockedBool` instead of plain `bool` for concurrent flags.\n- Use `ConcurrentDictionary` for thread-safe collections (`PhotoManager` index).\n- Always check `IsDisposed` before accessing resources in multi-threaded contexts.\n- Example: `PhotoManager` uses `Lock _lock` to protect `_items`, `_dict`, and `_cachedIndexes`.\n\n### 3. Memory Leak Prevention\n- Use `try/finally` or `using` statements for all `SKObject` acquisitions.\n- Override `OnDisposing()` in `PhDisposable` subclasses to clean up unmanaged resources.\n- Example: `MipmapTileCache` evicts with `RequestDispose()` (never `Dispose()`) so an in-flight render lease stays valid.\n- Always dispose `SKImageRef` leases when done; use `KeepAlive()` pattern in `MipmapTileCache`.\n\n### 4. Memory Usage Control\n- Respect cache limits: `MipmapTileCache` caps 100 tiles; `PhotoManager_Caching` honors `Config.MaxMemoryCacheInMb`, `Config.MaxFileSizeCacheInMb`, `Config.MaxDimensionCache`.\n- LRU eviction is mandatory in tile and photo caches to prevent unbounded growth.\n- **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\").\n- Mark a finished bitmap `SetImmutable()` before `SKImage.FromBitmap` (`SkiaCodec.ToSKImageNoCopy`), otherwise Skia duplicates the entire pixel buffer and doubles peak memory.\n- Profile memory with large images and GIF animations to ensure caches don't leak.\n\n### 5. Cross-Platform Design\n- **Interfaces first** in `ImageGlass.Lib/Common/ServiceProviders/*`: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, etc.\n- **Implementations** per platform:\n  - `ImageGlass.Win32/Common/ServiceProviders/*`: `Win32FileSearchProvider`, `Win32ShellProvider`, etc.\n  - `ImageGlass.Linux/Common/ServiceProviders/*`: `LinuxPrintProvider`, `LinuxShellProvider`, etc.\n  - `ImageGlass.Mac/Common/ServiceProviders/*`: `MacPrintProvider`, `MacShellProvider`, etc.\n- Register service providers in each platform's `Program.cs` before app bootstrap.\n- Avoid platform-specific code in library; use dependency injection via `Core.API`, `Core.FileSearchProvider`, etc.\n\n---\n\n## Architecture Highlights\n\n### Core Application Layers\n1. **Core Static Hub** (`Core.cs`, `Core_Events.cs`): Central event dispatcher and singleton registry\n   - `Core.AppInstance`: App lifecycle & unique instance enforcement\n   - `Core.Config`: Global configuration state\n   - `Core.Photos`: Photo collection and file searching\n   - Events: `LanguageChanged`, `ThemeChanged`, `PhotoUnloaded`, `PhotoSaved`, `ColorProfileChanged`\n   - Service provider slots: `ShellProvider`, `PreviewProvider`, `FileSearchProvider`, `PrintProvider`, `API`\n\n2. **Service Providers** (Plug-in Pattern)\n   - Platform-agnostic interfaces in `ImageGlass.Lib/Common/ServiceProviders/*`\n   - Platform implementations:\n     - Win32: `ImageGlass.Win32/Common/ServiceProviders/*`\n     - Linux: `ImageGlass.Linux/Common/ServiceProviders/*`\n     - macOS: `ImageGlass.Mac/Common/ServiceProviders/*`\n   - Shared concrete providers in library: `PhotoPreviewProvider`, `SlideshowProvider`, `UpdateProvider`\n   - Examples: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, `IWindowColorProfileProvider`, `IPhotoPreviewProvider`\n   - Registered at startup in each platform's `Program.cs`; initialized in `App.xaml.cs`\n\n3. **Photo & Codec Abstraction**\n   - `PhotoManager`: Thread-safe collection with `AvaloniaList<Photo>` + `ConcurrentDictionary` index\n   - Codecs: `SkiaCodec` (fast SkiaSharp pipeline) and `MagickCodec` (Magick.NET fallback for obscure formats)\n   - Async loading via `PhotoLoadingOptions` + frame animation support via `SkiaAnimator`/`AnimatorImpl`\n   - Caching: `PhotoManager_Caching.cs` uses spiral pattern, LRU eviction, memory budgets\n\n4. **Viewer & Rendering** (`ViewerControl`)\n   - Partial classes split by concern: `ViewerControl_Render.cs`, `ViewerControl_ZoomAndPan.cs`, `ViewerControl_Events.cs`, `ViewerControl_Animation.cs`, `ViewerControl_NavButtons.cs`, etc.\n   - High-performance mipmap tile caching: `MipmapTileCache` with LRU eviction for large images (8192×8192+)\n   - Gesture recognizers: `PhPanGestureRecognizer`, `PhPinchGestureRecognizer` accumulate points for smooth interaction\n   - SkiaSharp rendering in `ViewerControl_Render.cs` + `PhotoRenderer.cs`\n   - **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\n   - **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\n\n---\n\n## Code Organization & Naming Conventions\n\n### Folder Structure\n```\nImageGlass.Lib/\n├── Common/\n│   ├── Actions/                   # Action definitions\n│   ├── Commands/                  # Command definitions\n│   ├── AppThemes/                 # Theme system: IgTheme, IgThemeColors, AppThemeColors, IgThemeMetadata\n│   ├── BHelper/                   # Static helper methods (see \"Reusing Shared Code\"): Color, Format, General, JsonEx, Path, ProcessHelper, Sound, Task\n│   ├── Extensions/                # Extension methods (see \"Reusing Shared Code\"): Color_Exts, DrawingContext_Exts, ISolidColorBrush_Exts, Point_Exts, Rect_Exts, Size_Exts, SKObject_Exts\n│   ├── Localization/              # Lang.cs, LangId enum\n│   ├── Photoing/                  # Photo management, codecs, animators\n│   │   ├── Animators/             # SkiaAnimator, AnimatorImpl\n│   │   ├── Codecs/                # Codec pipeline\n│   │   │   ├── MagickCodecs/      # Magick.NET fallback codec\n│   │   │   ├── Registry/          # CodecRegistry\n│   │   │   ├── SkiaCodecs/        # SkiaSharp fast-path codec\n│   │   │   └── SvgCodecs/         # SVG (vector) codec\n│   │   ├── Manager/               # PhotoManager & PhotoManager_*.cs (search, watcher, caching)\n│   │   └── Photos/                # Photo, PhotoMetadata, PhotoColorProfile, etc.\n│   ├── Printing/                  # Print layouts, layout engine, page renderer, PDF writer, PrintSession (see \"Printing\")\n│   ├── ServiceProviders/          # Interface contracts + shared providers\n│   │   ├── AppAPIs/               # App API provider interfaces\n│   │   ├── FileSearchService/     # File search service abstractions\n│   │   ├── Update/                # Update provider abstractions\n│   │   ├── I*.cs                  # Interfaces (IFileSearchProvider, IShellProvider, etc.)\n│   │   ├── PhotoPreviewProvider.cs\n│   │   ├── SlideshowProvider.cs\n│   │   └── UpdateProvider.cs\n│   └── Types/                     # Base classes + shared types: PhReactive, PhDisposable, InterlockedBool, Resx, Const, Hotkey, SKImageRef, Enums, Dir\n│       └── JsonTypeConverters/    # AOT-safe JSON converters (see \"Reusing Shared Code\")\n├── Plugins/                       # Native plugin host: PluginRegistry, codec proxies\n├── Settings/                      # Config.cs, Config_Static.cs, ConfigMetadata.cs\n├── Tools/                         # Built-in & external tool framework\n│   ├── Builtins/                  # ColorPicker, CropImage, FrameNav, ImageResizer, LosslessCompression\n│   └── External/                  # External tool process management\n├── UI/\n│   ├── BaseControls/              # Ph* controls (see \"Designing UI\"): PhControl, PhButton, PhTextBox, PhTextBlock, PhToolButton, PhMenuItem, PhHotkeyPicker, PhGridSplitter, PhVirtualizingUniformPanel, PhCommandPreview, PhSearchBoxControl, PhTableControl\n│   ├── Gallery/                   # Gallery browsing UI (GalleryControl)\n│   ├── Styles/                    # App-wide XAML styles + resource dictionaries (see \"Designing UI\"); merged in App.axaml\n│   ├── Toolbar/                   # Toolbar UI and model (ToolbarControl)\n│   ├── Viewer/                    # ViewerControl (partial classes by feature)\n│   │   ├── Checkerboard/          # Transparency checkerboard background\n│   │   ├── NavButtons/            # NavButtonsOverlay, NavButtonsInfo, NavButtonClickedEventArgs\n│   │   ├── Renderer/              # MipmapTileCache, PhotoRenderer\n│   │   ├── Selection/             # Selection tools\n│   │   └── ZoomAndPan/            # Gesture recognizers, zoom/pan math\n│   └── Windowing/                 # PhWindow, ModalWindow, DialogWindow, PhColorPickerDialog\n├── ViewModels/                    # MainWindowViewModel, MainWindowModel, SettingsViewModel\n└── Windows/                       # Top-level windows: MainWindow, AboutWindow, SettingsWindow, UpdateWindow, ExportFramesWindow\n    ├── Main/                      # MainWindow_View partial\n    ├── Print/                     # PrintWindow, its view, preview and layout tiles\n    └── Settings/                  # Settings UI: Controls/, Pages/, Windows/\n\nImageGlass.Win32/\n├── Program.cs                     # Entry point, service registration, AOT bootstrap\n├── Properties/                    # PublishProfiles\n├── Windows/                       # MainWindow32.cs (Win32-specific window)\n└── Common/\n    ├── ServiceProviders/          # Win32 implementations (Win32FileSearchProvider, etc.)\n    └── WinAPI/                    # P/Invoke wrappers (Win32*.cs files)\n\nImageGlass.Linux/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    └── ServiceProviders/          # Linux implementations (LinuxPrintProvider, etc.)\n\nImageGlass.Mac/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    ├── Sandbox/                   # macOS sandbox helpers\n    └── ServiceProviders/          # macOS implementations (MacPrintProvider, etc.)\n```\n\n### Naming Rules\n- **Classes inheriting from Control/UserControl**: Use `PhControl` base (e.g., `ToolbarControl : PhControl`)\n- **View Models**: Suffix with `ViewModel` (e.g., `MainWindowViewModel : PhReactive`)\n- **Async methods**: Always suffix with `Async`\n- **Codecs/Providers**: Prefix with platform/source (e.g., `SkiaCodec`, `Win32FileSearchProvider`, `MagickCodec`)\n- **Partial class files**: Name by concern (e.g., `ViewerControl_Render.cs`, `PhotoManager_Caching.cs`, `AppAPIProvider_Hotkeys.cs`)\n\n---\n\n## Reusing Shared Code (Always Check Before Writing New)\n\nBefore adding a helper, converter, or extension, check these folders; the utility you need likely already exists.\n\n### Type Extensions: `Common/Extensions/`\nExtension methods on framework types. Prefer these over re-implementing:\n- `Color_Exts`: `ToBrush()`, `WithAlpha()`, `NoAlpha()`, `Blend()`, `WithBrightness()`, `IsLight()`, `BlackOrWhite()` / `InvertBlackOrWhite()`, `ToHex()`, `ToRgbaString()`, `ToCmyk()` / `ToCmykString()`, `ToHslString()`, `ToHsvString()`, `ToCIELAB()` / `ToCIELABString()`\n- `ISolidColorBrush_Exts`, `DrawingContext_Exts`, `Point_Exts`, `Rect_Exts`, `Size_Exts`: Avalonia geometry & drawing helpers\n- `SKObject_Exts`: `IsDisposed()` and other SkiaSharp guards\n\n### Helper Methods: `Common/BHelper/` (static `BHelper` partial class)\n- `Color.cs`: `ColorFromHex()`\n- `Format.cs`: `FormatSize()`, `FormatDateTime()`, `SimplifyFractions()`, star-rating formatting\n- `General.cs`: `OS`, `GenerateWrappedIndexes()` (spiral cache order), `ComputeIndexInRange()`, `ResizeRatio()`, `GetInAppError()`\n- `Path.cs`: `ConfigDir()` / `BaseDir()`, `ResolvePath()`, `CheckPath()`, `OpenUrlAsync()`, `OpenFilePath()` / `OpenFolderPath()`, `DeleteFile()`\n  - `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.\n- `ProcessHelper.cs`: `RunExeAsync()` / `RunExeCmd()`, `RunSync()`, `ExitApp()`\n- `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)\n- `JsonEx.cs`: `CreateJsonOptions()`, `ReadJsonFromFile()` / `WriteJsonToFileAsync()` (AOT-safe via `JsonTypeInfo<T>`); both go through `FileIO.cs`, so every JSON file is multi-instance safe\n- `FileIO.cs`: `OpenReadShared()` / `WriteAllTextSharedAsync()`, the multi-instance-safe file primitives (see \"Concurrent file writes\")\n- `Task.cs`: `Debounce()`, `GcCollect()`\n\n### JSON Converters: `Common/Types/JsonTypeConverters/`\nAOT-safe converters for `Config` serialization. Reuse an existing one, or add a new converter here; never use reflection-based serialization.\n- `JsonStringEnumSafeConverter<T>` (always use for enums; never the built-in `JsonStringEnumConverter`)\n- `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`, `JsonArrayToRectConverter` / `JsonArrayToPixelRectConverter`, `JsonArrayToDoubleConverter` / `JsonArrayToIntConverter`, `JsonDateTimeConverter`, `JsonHashSetToStringConverter`, `JsonObservableCollectionToStringConverter`\n\n---\n\n## Designing UI\n\nWhen building or restyling UI, reuse the app's existing controls, design tokens, styles, icons, and fonts instead of raw Avalonia controls or hardcoded values.\n\n### 1. Controls: prefer `UI/` over raw Avalonia\n- **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).\n- **Windows & dialogs** (`UI/Windowing/`): inherit `PhWindow`; for modal/dialog flows use `ModalWindow` / `DialogWindow`; use `PhColorPickerDialog` for color picking.\n- **Feature controls**: `ViewerControl` (`UI/Viewer/`), `GalleryControl` (`UI/Gallery/`), `ToolbarControl` (`UI/Toolbar/`).\n\n### 2. Colors, brushes & styles: never hardcode\n- **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.\n- **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`).\n- **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.\n- **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`).\n\n### 3. Icons: `UI/Styles/IconResources.axaml`\nReuse 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()`.\n\n### 4. Fonts: `Common/Types/Const.cs:81-104`\n- **Font family**: `Const.FONT_CODE` (OS-specific monospace stack for code / credits / metadata text).\n- **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.\n\n---\n\n## Critical Base Classes & Patterns\n\n### 1. Reactive Programming — `PhReactive`\n- All ViewModels inherit from `PhReactive : INotifyPropertyChanged`\n- Thread-safe property change notifications using `Lock` (not `object`)\n- Use with Avalonia compiled bindings: `{CompiledBinding PropertyName}`\n- Example:\n  ```csharp\n  public class MainWindowViewModel : PhReactive\n  {\n      public string Title\n      {\n          get; set\n          {\n              if (field.Equals(value)) return;\n              field = value;\n              OnPropertyChanged();\n          }\n      } = BHelper.AppName;\n  }\n  ```\n\n### 2. Resource Management — `PhDisposable`\n- Inherit when managing unmanaged resources (SKImage, file handles, etc.)\n- Override `OnDisposing()` (called on Dispose, not destructor)\n- Uses `InterlockedBool _isDisposed` for thread-safe disposal checks\n- Use with `await using` in async contexts\n- Example:\n  ```csharp\n  public sealed class MipmapTileCache : PhDisposable\n  {\n      protected override void OnDisposing()\n      {\n          lock (_lock)\n          {\n              foreach (var bitmap in _tiles.Values)\n              {\n                  bitmap.Dispose();\n              }\n              _tiles.Clear();\n          }\n      }\n  }\n  ```\n\n### 3. Thread-Safe Operations — `InterlockedBool`\n- Use instead of `bool` for concurrent state flags\n- Atomic read/write using `Volatile` and `Interlocked` operations\n- Example: `_isPreviewing`, `_isFirstDraw`, `_isDisposed` in ViewerControl\n- Check with `.Value` property or implicit conversion\n\n### 4. SkiaSharp Safety — `IsDisposed()` Extension\n- Always check `SKObject.IsDisposed()` before using SkiaSharp objects\n- Defined in `ImageGlass.Common.Extensions.SKObject_Exts`\n- Returns `true` if object is `null` or `Handle == IntPtr.Zero`\n- Example:\n  ```csharp\n  if (_skImage.IsDisposed()) { _skImage = null; }\n  ```\n\n---\n\n## Critical Workflows & Commands\n\n### Build\n```powershell\n# Restore and build\ndotnet build\n\n# Publish AOT-enabled binary (ImageGlass.Win32)\ndotnet publish ImageGlass.Win32 -c Release -o ./bin/publish\n\n# Run from source\ndotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]\n```\n\n### Debug\n- Open solution in Visual Studio 2026\n- Set `ImageGlass.Win32` as startup project\n- **Breakpoints work normally in C# code**\n- **Avalonia Hot Reload**: Available with `--debug` flag (limited to XAML/styles)\n- **Profiling**: Use Visual Studio's memory profiler for cache leak detection\n\n### Testing\n- No dedicated test projects in v10 (focus on functional validation)\n- Manual testing via `dotnet run`\n- Test command-line parsing with sample images\n- Profile memory usage with large images and GIF animations\n\n### Key C# Version & Langversion\n- **LangVersion**: `Preview` (C# 15 features available)\n- **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.\n- **Nullable**: `enable` across projects\n- **AllowUnsafeBlocks**: `true` (for P/Invoke, SkiaSharp interop)\n- **PublishAot**: `true` in all platform projects (Win32, Linux, Mac)\n- **PublishTrimmed**: `true` in all projects (library + platform)\n- **PublishSingleFile** + **SelfContained** + **PublishReadyToRun**: `true` in all platform projects\n- **DisableRuntimeMarshalling**: `true` in Win32 only (AOT-safe marshalling)\n\n---\n\n## Key Integration Points\n\n### 1. Photo Loading Pipeline\n```\nApp.Initialize() \n  → Core.InitializeAsync() \n    → PhotoManager.Add/SearchAsync()\n    → ViewerControl.LoadPhotoAsync(photoPath)\n      → Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)\n      → shrink to fit the pixel ceiling if needed → Photo.DecodeScale\n      → PhotoRenderer.RenderAsync()\n      → AnimatorImpl for GIF/WEBP animation\n    → PhotoLoading/PhotoAnimatorFrameChanged events\n    → MipmapTileCache.Create() if image > 8192×8192\n```\n\n### Oversized image decode\n- Images past the `int.MaxValue`-byte ceiling are decoded smaller rather than refused.\n- **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`.\n- **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.\n- The reduction flows `SkiaDecoderOutput`/`MagickCodecAdapter` → `CodecDecodeResult.DecodeScale` → `Photo.DecodeScale`, and `AppStatusInfo.Dimension` shows it.\n- `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.\n\n### Multi-size icons (ICO/CUR)\n- 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.\n- **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.\n- `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`.\n- `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.\n\n### 2. Theme/Localization Changes\n- Change language: `Core.LanguageChanged?.Invoke()`\n- Change theme: `Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...))`\n- UI elements auto-refresh via bindings + event subscribers\n\n### 3. Win32 Service Registration\nOccurs in `Program.cs` before Avalonia app setup (Linux/Mac have equivalent registrations):\n- `FileSearchProvider` (shell enumeration in the view order of File Explorer or a supported third-party file manager; see \"Explorer sort order & the foreground shell\")\n- `ShellProvider` (context menu, file properties, Windows Share API)\n- `PrintProvider` (GDI printing through the spooler; Print Pictures as the system dialog; see \"Printing\")\n- `ColorProfileProvider` (Monitor color profile retrieval via Win32 APIs)\n- `PreviewProvider` (thumbnail cache via Windows shell)\n\n### 4. Configuration Persistence\n- `Config.cs` handles JSON serialization with custom converters\n- Converters in `Common/Types/JsonTypeConverters/` (e.g., `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`)\n- Cached in `AppData\\Local\\ImageGlass\\`, or the startup dir in portable mode (see below)\n- Async save via `Config.SaveAsync()`\n\n### Concurrent file writes (`BHelper.WriteAllTextSharedAsync`)\n- 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.\n- **`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.\n- **`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`.\n- `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.\n- Last writer wins is unchanged and intended: each instance persists its own whole `Config`.\n\n### Portable mode (`ConfigMode`)\n- 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.\n- `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.\n- 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`.\n- **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.\n- `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.\n- 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.\n\n### Linux self-update (`LinuxUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- **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.\n- 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.\n- 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.\n- The install-while-alive helpers (`RunInstallThenExitAsync`, `RelaunchAsync`, `ForgetInstalledUpdateAsync`, `RunCommandAsync`) live in the base `UpdateProvider`, shared with macOS.\n\n### macOS self-update (`MacUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- 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`).\n\n### Photo transitions (`UI/Viewer/Transitions/`)\n- **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.\n- **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 `\n`, 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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`).\n- **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.\n\n### Full screen & the windowed layout\n- 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.\n- 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.\n- 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`.\n- 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.\n- The close handler reads `RestorableWindowState`, not `WindowState`: closing a *minimized* window would otherwise record `EnableFullScreen`/`EnableMainWindowMaximized` as false.\n- 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`.\n\n### 5. Photo Caching Strategy\n- **Tier 1**: Current photo in viewer (always loaded)\n- **Tier 2**: Neighboring photos preloaded via `PhotoManager.RequestCacheAround()`\n- **Tier 3**: LRU eviction when memory budget exhausted\n- Spiral pattern: right-1, left-1, right-2, left-2, ... to balance browsing directions\n- **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.\n- **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`.\n- **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.\n- **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.\n- **`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.\n- **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.\n\n### File search & captured file metadata\n- `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.\n- `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.\n- 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).\n- `FileSearchingEventArgs.Results` is a materialized `IReadOnlyList`, and filtering/sorting now run on the background thread instead of lazily on the UI thread during `Add`.\n- 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.\n\n### Explorer sort order & the foreground shell (EggShell)\n- **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.\n- **`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.\n- **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.\n- **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.\n- **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`.\n- **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`).\n- 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.\n\n### Gallery thumbnail pipeline (`Photo.LoadThumbnailAsync`)\n- Tiers in order, all skipped when `useCache` is false: in-memory `GalleryThumbnail`, `ThumbnailDiskCache`, `IPhotoPreviewProvider.TryGetCachedThumbnailAsync` (platform cache), then `GetThumbnailAsync` (decodes).\n- 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.\n- `_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.\n- **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.\n- **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.\n- **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.\n- `TryGetCachedThumbnailAsync` must not decode: "},"items":[{"name":"CLAUDE.md","path":"source/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/d2phap/ImageGlass/HEAD/source/CLAUDE.md","title":"source - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# ImageGlass v10\n\n## Project Overview\nImageGlass 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.\n\n## Repository Layout\n- **`/source`** — v10 source code (the active codebase; this `CLAUDE.md` lives here). All development happens here.\n- **`/v9`** — legacy v9 source code (WinForms/.NET, archived).\n\n> **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.\n\n**Key Projects:**\n- `ImageGlass.Lib`: Core library (net11.0) — UI controls, rendering, codecs, themes, localization, settings\n- `ImageGlass.Win32`: Windows desktop app (net11.0-windows10.0.19041.0) — entry point with Win32 APIs and platform-specific services\n- `ImageGlass.Linux`: Linux desktop app (net11.0) — entry point with Linux-specific services\n- `ImageGlass.Mac`: macOS desktop app (net11.0) — entry point with macOS-specific services\n\n---\n\n## Critical Focus Areas (Always Prioritize)\n\n### 1. AOT/Trim Safety\n- **All platform projects** (Win32, Linux, Mac) use `PublishAot=true` and `PublishTrimmed=true` with trim analyzer enabled.\n- **Win32 project** additionally uses `DisableRuntimeMarshalling=true` and CsWin32 code generation.\n- Avoid reflection-based serialization; use custom JSON converters in `Common/Types/JsonTypeConverters/`.\n- Test AOT builds: `dotnet publish ImageGlass.Win32 -c Release` produces a self-contained, trimmed executable.\n\n### 2. Thread-Safety (No Data Races)\n- Use `Lock` for protecting shared state (not `object` locks).\n- Use `InterlockedBool` instead of plain `bool` for concurrent flags.\n- Use `ConcurrentDictionary` for thread-safe collections (`PhotoManager` index).\n- Always check `IsDisposed` before accessing resources in multi-threaded contexts.\n- Example: `PhotoManager` uses `Lock _lock` to protect `_items`, `_dict`, and `_cachedIndexes`.\n\n### 3. Memory Leak Prevention\n- Use `try/finally` or `using` statements for all `SKObject` acquisitions.\n- Override `OnDisposing()` in `PhDisposable` subclasses to clean up unmanaged resources.\n- Example: `MipmapTileCache` evicts with `RequestDispose()` (never `Dispose()`) so an in-flight render lease stays valid.\n- Always dispose `SKImageRef` leases when done; use `KeepAlive()` pattern in `MipmapTileCache`.\n\n### 4. Memory Usage Control\n- Respect cache limits: `MipmapTileCache` caps 100 tiles; `PhotoManager_Caching` honors `Config.MaxMemoryCacheInMb`, `Config.MaxFileSizeCacheInMb`, `Config.MaxDimensionCache`.\n- LRU eviction is mandatory in tile and photo caches to prevent unbounded growth.\n- **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\").\n- Mark a finished bitmap `SetImmutable()` before `SKImage.FromBitmap` (`SkiaCodec.ToSKImageNoCopy`), otherwise Skia duplicates the entire pixel buffer and doubles peak memory.\n- Profile memory with large images and GIF animations to ensure caches don't leak.\n\n### 5. Cross-Platform Design\n- **Interfaces first** in `ImageGlass.Lib/Common/ServiceProviders/*`: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, etc.\n- **Implementations** per platform:\n  - `ImageGlass.Win32/Common/ServiceProviders/*`: `Win32FileSearchProvider`, `Win32ShellProvider`, etc.\n  - `ImageGlass.Linux/Common/ServiceProviders/*`: `LinuxPrintProvider`, `LinuxShellProvider`, etc.\n  - `ImageGlass.Mac/Common/ServiceProviders/*`: `MacPrintProvider`, `MacShellProvider`, etc.\n- Register service providers in each platform's `Program.cs` before app bootstrap.\n- Avoid platform-specific code in library; use dependency injection via `Core.API`, `Core.FileSearchProvider`, etc.\n\n---\n\n## Architecture Highlights\n\n### Core Application Layers\n1. **Core Static Hub** (`Core.cs`, `Core_Events.cs`): Central event dispatcher and singleton registry\n   - `Core.AppInstance`: App lifecycle & unique instance enforcement\n   - `Core.Config`: Global configuration state\n   - `Core.Photos`: Photo collection and file searching\n   - Events: `LanguageChanged`, `ThemeChanged`, `PhotoUnloaded`, `PhotoSaved`, `ColorProfileChanged`\n   - Service provider slots: `ShellProvider`, `PreviewProvider`, `FileSearchProvider`, `PrintProvider`, `API`\n\n2. **Service Providers** (Plug-in Pattern)\n   - Platform-agnostic interfaces in `ImageGlass.Lib/Common/ServiceProviders/*`\n   - Platform implementations:\n     - Win32: `ImageGlass.Win32/Common/ServiceProviders/*`\n     - Linux: `ImageGlass.Linux/Common/ServiceProviders/*`\n     - macOS: `ImageGlass.Mac/Common/ServiceProviders/*`\n   - Shared concrete providers in library: `PhotoPreviewProvider`, `SlideshowProvider`, `UpdateProvider`\n   - Examples: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, `IWindowColorProfileProvider`, `IPhotoPreviewProvider`\n   - Registered at startup in each platform's `Program.cs`; initialized in `App.xaml.cs`\n\n3. **Photo & Codec Abstraction**\n   - `PhotoManager`: Thread-safe collection with `AvaloniaList<Photo>` + `ConcurrentDictionary` index\n   - Codecs: `SkiaCodec` (fast SkiaSharp pipeline) and `MagickCodec` (Magick.NET fallback for obscure formats)\n   - Async loading via `PhotoLoadingOptions` + frame animation support via `SkiaAnimator`/`AnimatorImpl`\n   - Caching: `PhotoManager_Caching.cs` uses spiral pattern, LRU eviction, memory budgets\n\n4. **Viewer & Rendering** (`ViewerControl`)\n   - Partial classes split by concern: `ViewerControl_Render.cs`, `ViewerControl_ZoomAndPan.cs`, `ViewerControl_Events.cs`, `ViewerControl_Animation.cs`, `ViewerControl_NavButtons.cs`, etc.\n   - High-performance mipmap tile caching: `MipmapTileCache` with LRU eviction for large images (8192×8192+)\n   - Gesture recognizers: `PhPanGestureRecognizer`, `PhPinchGestureRecognizer` accumulate points for smooth interaction\n   - SkiaSharp rendering in `ViewerControl_Render.cs` + `PhotoRenderer.cs`\n   - **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\n   - **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\n\n---\n\n## Code Organization & Naming Conventions\n\n### Folder Structure\n```\nImageGlass.Lib/\n├── Common/\n│   ├── Actions/                   # Action definitions\n│   ├── Commands/                  # Command definitions\n│   ├── AppThemes/                 # Theme system: IgTheme, IgThemeColors, AppThemeColors, IgThemeMetadata\n│   ├── BHelper/                   # Static helper methods (see \"Reusing Shared Code\"): Color, Format, General, JsonEx, Path, ProcessHelper, Sound, Task\n│   ├── Extensions/                # Extension methods (see \"Reusing Shared Code\"): Color_Exts, DrawingContext_Exts, ISolidColorBrush_Exts, Point_Exts, Rect_Exts, Size_Exts, SKObject_Exts\n│   ├── Localization/              # Lang.cs, LangId enum\n│   ├── Photoing/                  # Photo management, codecs, animators\n│   │   ├── Animators/             # SkiaAnimator, AnimatorImpl\n│   │   ├── Codecs/                # Codec pipeline\n│   │   │   ├── MagickCodecs/      # Magick.NET fallback codec\n│   │   │   ├── Registry/          # CodecRegistry\n│   │   │   ├── SkiaCodecs/        # SkiaSharp fast-path codec\n│   │   │   └── SvgCodecs/         # SVG (vector) codec\n│   │   ├── Manager/               # PhotoManager & PhotoManager_*.cs (search, watcher, caching)\n│   │   └── Photos/                # Photo, PhotoMetadata, PhotoColorProfile, etc.\n│   ├── Printing/                  # Print layouts, layout engine, page renderer, PDF writer, PrintSession (see \"Printing\")\n│   ├── ServiceProviders/          # Interface contracts + shared providers\n│   │   ├── AppAPIs/               # App API provider interfaces\n│   │   ├── FileSearchService/     # File search service abstractions\n│   │   ├── Update/                # Update provider abstractions\n│   │   ├── I*.cs                  # Interfaces (IFileSearchProvider, IShellProvider, etc.)\n│   │   ├── PhotoPreviewProvider.cs\n│   │   ├── SlideshowProvider.cs\n│   │   └── UpdateProvider.cs\n│   └── Types/                     # Base classes + shared types: PhReactive, PhDisposable, InterlockedBool, Resx, Const, Hotkey, SKImageRef, Enums, Dir\n│       └── JsonTypeConverters/    # AOT-safe JSON converters (see \"Reusing Shared Code\")\n├── Plugins/                       # Native plugin host: PluginRegistry, codec proxies\n├── Settings/                      # Config.cs, Config_Static.cs, ConfigMetadata.cs\n├── Tools/                         # Built-in & external tool framework\n│   ├── Builtins/                  # ColorPicker, CropImage, FrameNav, ImageResizer, LosslessCompression\n│   └── External/                  # External tool process management\n├── UI/\n│   ├── BaseControls/              # Ph* controls (see \"Designing UI\"): PhControl, PhButton, PhTextBox, PhTextBlock, PhToolButton, PhMenuItem, PhHotkeyPicker, PhGridSplitter, PhVirtualizingUniformPanel, PhCommandPreview, PhSearchBoxControl, PhTableControl\n│   ├── Gallery/                   # Gallery browsing UI (GalleryControl)\n│   ├── Styles/                    # App-wide XAML styles + resource dictionaries (see \"Designing UI\"); merged in App.axaml\n│   ├── Toolbar/                   # Toolbar UI and model (ToolbarControl)\n│   ├── Viewer/                    # ViewerControl (partial classes by feature)\n│   │   ├── Checkerboard/          # Transparency checkerboard background\n│   │   ├── NavButtons/            # NavButtonsOverlay, NavButtonsInfo, NavButtonClickedEventArgs\n│   │   ├── Renderer/              # MipmapTileCache, PhotoRenderer\n│   │   ├── Selection/             # Selection tools\n│   │   └── ZoomAndPan/            # Gesture recognizers, zoom/pan math\n│   └── Windowing/                 # PhWindow, ModalWindow, DialogWindow, PhColorPickerDialog\n├── ViewModels/                    # MainWindowViewModel, MainWindowModel, SettingsViewModel\n└── Windows/                       # Top-level windows: MainWindow, AboutWindow, SettingsWindow, UpdateWindow, ExportFramesWindow\n    ├── Main/                      # MainWindow_View partial\n    ├── Print/                     # PrintWindow, its view, preview and layout tiles\n    └── Settings/                  # Settings UI: Controls/, Pages/, Windows/\n\nImageGlass.Win32/\n├── Program.cs                     # Entry point, service registration, AOT bootstrap\n├── Properties/                    # PublishProfiles\n├── Windows/                       # MainWindow32.cs (Win32-specific window)\n└── Common/\n    ├── ServiceProviders/          # Win32 implementations (Win32FileSearchProvider, etc.)\n    └── WinAPI/                    # P/Invoke wrappers (Win32*.cs files)\n\nImageGlass.Linux/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    └── ServiceProviders/          # Linux implementations (LinuxPrintProvider, etc.)\n\nImageGlass.Mac/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    ├── Sandbox/                   # macOS sandbox helpers\n    └── ServiceProviders/          # macOS implementations (MacPrintProvider, etc.)\n```\n\n### Naming Rules\n- **Classes inheriting from Control/UserControl**: Use `PhControl` base (e.g., `ToolbarControl : PhControl`)\n- **View Models**: Suffix with `ViewModel` (e.g., `MainWindowViewModel : PhReactive`)\n- **Async methods**: Always suffix with `Async`\n- **Codecs/Providers**: Prefix with platform/source (e.g., `SkiaCodec`, `Win32FileSearchProvider`, `MagickCodec`)\n- **Partial class files**: Name by concern (e.g., `ViewerControl_Render.cs`, `PhotoManager_Caching.cs`, `AppAPIProvider_Hotkeys.cs`)\n\n---\n\n## Reusing Shared Code (Always Check Before Writing New)\n\nBefore adding a helper, converter, or extension, check these folders; the utility you need likely already exists.\n\n### Type Extensions: `Common/Extensions/`\nExtension methods on framework types. Prefer these over re-implementing:\n- `Color_Exts`: `ToBrush()`, `WithAlpha()`, `NoAlpha()`, `Blend()`, `WithBrightness()`, `IsLight()`, `BlackOrWhite()` / `InvertBlackOrWhite()`, `ToHex()`, `ToRgbaString()`, `ToCmyk()` / `ToCmykString()`, `ToHslString()`, `ToHsvString()`, `ToCIELAB()` / `ToCIELABString()`\n- `ISolidColorBrush_Exts`, `DrawingContext_Exts`, `Point_Exts`, `Rect_Exts`, `Size_Exts`: Avalonia geometry & drawing helpers\n- `SKObject_Exts`: `IsDisposed()` and other SkiaSharp guards\n\n### Helper Methods: `Common/BHelper/` (static `BHelper` partial class)\n- `Color.cs`: `ColorFromHex()`\n- `Format.cs`: `FormatSize()`, `FormatDateTime()`, `SimplifyFractions()`, star-rating formatting\n- `General.cs`: `OS`, `GenerateWrappedIndexes()` (spiral cache order), `ComputeIndexInRange()`, `ResizeRatio()`, `GetInAppError()`\n- `Path.cs`: `ConfigDir()` / `BaseDir()`, `ResolvePath()`, `CheckPath()`, `OpenUrlAsync()`, `OpenFilePath()` / `OpenFolderPath()`, `DeleteFile()`\n  - `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.\n- `ProcessHelper.cs`: `RunExeAsync()` / `RunExeCmd()`, `RunSync()`, `ExitApp()`\n- `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)\n- `JsonEx.cs`: `CreateJsonOptions()`, `ReadJsonFromFile()` / `WriteJsonToFileAsync()` (AOT-safe via `JsonTypeInfo<T>`); both go through `FileIO.cs`, so every JSON file is multi-instance safe\n- `FileIO.cs`: `OpenReadShared()` / `WriteAllTextSharedAsync()`, the multi-instance-safe file primitives (see \"Concurrent file writes\")\n- `Task.cs`: `Debounce()`, `GcCollect()`\n\n### JSON Converters: `Common/Types/JsonTypeConverters/`\nAOT-safe converters for `Config` serialization. Reuse an existing one, or add a new converter here; never use reflection-based serialization.\n- `JsonStringEnumSafeConverter<T>` (always use for enums; never the built-in `JsonStringEnumConverter`)\n- `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`, `JsonArrayToRectConverter` / `JsonArrayToPixelRectConverter`, `JsonArrayToDoubleConverter` / `JsonArrayToIntConverter`, `JsonDateTimeConverter`, `JsonHashSetToStringConverter`, `JsonObservableCollectionToStringConverter`\n\n---\n\n## Designing UI\n\nWhen building or restyling UI, reuse the app's existing controls, design tokens, styles, icons, and fonts instead of raw Avalonia controls or hardcoded values.\n\n### 1. Controls: prefer `UI/` over raw Avalonia\n- **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).\n- **Windows & dialogs** (`UI/Windowing/`): inherit `PhWindow`; for modal/dialog flows use `ModalWindow` / `DialogWindow`; use `PhColorPickerDialog` for color picking.\n- **Feature controls**: `ViewerControl` (`UI/Viewer/`), `GalleryControl` (`UI/Gallery/`), `ToolbarControl` (`UI/Toolbar/`).\n\n### 2. Colors, brushes & styles: never hardcode\n- **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.\n- **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`).\n- **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.\n- **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`).\n\n### 3. Icons: `UI/Styles/IconResources.axaml`\nReuse 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()`.\n\n### 4. Fonts: `Common/Types/Const.cs:81-104`\n- **Font family**: `Const.FONT_CODE` (OS-specific monospace stack for code / credits / metadata text).\n- **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.\n\n---\n\n## Critical Base Classes & Patterns\n\n### 1. Reactive Programming — `PhReactive`\n- All ViewModels inherit from `PhReactive : INotifyPropertyChanged`\n- Thread-safe property change notifications using `Lock` (not `object`)\n- Use with Avalonia compiled bindings: `{CompiledBinding PropertyName}`\n- Example:\n  ```csharp\n  public class MainWindowViewModel : PhReactive\n  {\n      public string Title\n      {\n          get; set\n          {\n              if (field.Equals(value)) return;\n              field = value;\n              OnPropertyChanged();\n          }\n      } = BHelper.AppName;\n  }\n  ```\n\n### 2. Resource Management — `PhDisposable`\n- Inherit when managing unmanaged resources (SKImage, file handles, etc.)\n- Override `OnDisposing()` (called on Dispose, not destructor)\n- Uses `InterlockedBool _isDisposed` for thread-safe disposal checks\n- Use with `await using` in async contexts\n- Example:\n  ```csharp\n  public sealed class MipmapTileCache : PhDisposable\n  {\n      protected override void OnDisposing()\n      {\n          lock (_lock)\n          {\n              foreach (var bitmap in _tiles.Values)\n              {\n                  bitmap.Dispose();\n              }\n              _tiles.Clear();\n          }\n      }\n  }\n  ```\n\n### 3. Thread-Safe Operations — `InterlockedBool`\n- Use instead of `bool` for concurrent state flags\n- Atomic read/write using `Volatile` and `Interlocked` operations\n- Example: `_isPreviewing`, `_isFirstDraw`, `_isDisposed` in ViewerControl\n- Check with `.Value` property or implicit conversion\n\n### 4. SkiaSharp Safety — `IsDisposed()` Extension\n- Always check `SKObject.IsDisposed()` before using SkiaSharp objects\n- Defined in `ImageGlass.Common.Extensions.SKObject_Exts`\n- Returns `true` if object is `null` or `Handle == IntPtr.Zero`\n- Example:\n  ```csharp\n  if (_skImage.IsDisposed()) { _skImage = null; }\n  ```\n\n---\n\n## Critical Workflows & Commands\n\n### Build\n```powershell\n# Restore and build\ndotnet build\n\n# Publish AOT-enabled binary (ImageGlass.Win32)\ndotnet publish ImageGlass.Win32 -c Release -o ./bin/publish\n\n# Run from source\ndotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]\n```\n\n### Debug\n- Open solution in Visual Studio 2026\n- Set `ImageGlass.Win32` as startup project\n- **Breakpoints work normally in C# code**\n- **Avalonia Hot Reload**: Available with `--debug` flag (limited to XAML/styles)\n- **Profiling**: Use Visual Studio's memory profiler for cache leak detection\n\n### Testing\n- No dedicated test projects in v10 (focus on functional validation)\n- Manual testing via `dotnet run`\n- Test command-line parsing with sample images\n- Profile memory usage with large images and GIF animations\n\n### Key C# Version & Langversion\n- **LangVersion**: `Preview` (C# 15 features available)\n- **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.\n- **Nullable**: `enable` across projects\n- **AllowUnsafeBlocks**: `true` (for P/Invoke, SkiaSharp interop)\n- **PublishAot**: `true` in all platform projects (Win32, Linux, Mac)\n- **PublishTrimmed**: `true` in all projects (library + platform)\n- **PublishSingleFile** + **SelfContained** + **PublishReadyToRun**: `true` in all platform projects\n- **DisableRuntimeMarshalling**: `true` in Win32 only (AOT-safe marshalling)\n\n---\n\n## Key Integration Points\n\n### 1. Photo Loading Pipeline\n```\nApp.Initialize() \n  → Core.InitializeAsync() \n    → PhotoManager.Add/SearchAsync()\n    → ViewerControl.LoadPhotoAsync(photoPath)\n      → Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)\n      → shrink to fit the pixel ceiling if needed → Photo.DecodeScale\n      → PhotoRenderer.RenderAsync()\n      → AnimatorImpl for GIF/WEBP animation\n    → PhotoLoading/PhotoAnimatorFrameChanged events\n    → MipmapTileCache.Create() if image > 8192×8192\n```\n\n### Oversized image decode\n- Images past the `int.MaxValue`-byte ceiling are decoded smaller rather than refused.\n- **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`.\n- **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.\n- The reduction flows `SkiaDecoderOutput`/`MagickCodecAdapter` → `CodecDecodeResult.DecodeScale` → `Photo.DecodeScale`, and `AppStatusInfo.Dimension` shows it.\n- `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.\n\n### Multi-size icons (ICO/CUR)\n- 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.\n- **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.\n- `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`.\n- `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.\n\n### 2. Theme/Localization Changes\n- Change language: `Core.LanguageChanged?.Invoke()`\n- Change theme: `Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...))`\n- UI elements auto-refresh via bindings + event subscribers\n\n### 3. Win32 Service Registration\nOccurs in `Program.cs` before Avalonia app setup (Linux/Mac have equivalent registrations):\n- `FileSearchProvider` (shell enumeration in the view order of File Explorer or a supported third-party file manager; see \"Explorer sort order & the foreground shell\")\n- `ShellProvider` (context menu, file properties, Windows Share API)\n- `PrintProvider` (GDI printing through the spooler; Print Pictures as the system dialog; see \"Printing\")\n- `ColorProfileProvider` (Monitor color profile retrieval via Win32 APIs)\n- `PreviewProvider` (thumbnail cache via Windows shell)\n\n### 4. Configuration Persistence\n- `Config.cs` handles JSON serialization with custom converters\n- Converters in `Common/Types/JsonTypeConverters/` (e.g., `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`)\n- Cached in `AppData\\Local\\ImageGlass\\`, or the startup dir in portable mode (see below)\n- Async save via `Config.SaveAsync()`\n\n### Concurrent file writes (`BHelper.WriteAllTextSharedAsync`)\n- 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.\n- **`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.\n- **`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`.\n- `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.\n- Last writer wins is unchanged and intended: each instance persists its own whole `Config`.\n\n### Portable mode (`ConfigMode`)\n- 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.\n- `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.\n- 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`.\n- **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.\n- `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.\n- 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.\n\n### Linux self-update (`LinuxUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- **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.\n- 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.\n- 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.\n- The install-while-alive helpers (`RunInstallThenExitAsync`, `RelaunchAsync`, `ForgetInstalledUpdateAsync`, `RunCommandAsync`) live in the base `UpdateProvider`, shared with macOS.\n\n### macOS self-update (`MacUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- 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`).\n\n### Photo transitions (`UI/Viewer/Transitions/`)\n- **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.\n- **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 `\n`, 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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`).\n- **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.\n\n### Full screen & the windowed layout\n- 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.\n- 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.\n- 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`.\n- 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.\n- The close handler reads `RestorableWindowState`, not `WindowState`: closing a *minimized* window would otherwise record `EnableFullScreen`/`EnableMainWindowMaximized` as false.\n- 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`.\n\n### 5. Photo Caching Strategy\n- **Tier 1**: Current photo in viewer (always loaded)\n- **Tier 2**: Neighboring photos preloaded via `PhotoManager.RequestCacheAround()`\n- **Tier 3**: LRU eviction when memory budget exhausted\n- Spiral pattern: right-1, left-1, right-2, left-2, ... to balance browsing directions\n- **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.\n- **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`.\n- **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.\n- **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.\n- **`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.\n- **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.\n\n### File search & captured file metadata\n- `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.\n- `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.\n- 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).\n- `FileSearchingEventArgs.Results` is a materialized `IReadOnlyList`, and filtering/sorting now run on the background thread instead of lazily on the UI thread during `Add`.\n- 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.\n\n### Explorer sort order & the foreground shell (EggShell)\n- **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.\n- **`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.\n- **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.\n- **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.\n- **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`.\n- **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`).\n- 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.\n\n### Gallery thumbnail pipeline (`Photo.LoadThumbnailAsync`)\n- Tiers in order, all skipped when `useCache` is false: in-memory `GalleryThumbnail`, `ThumbnailDiskCache`, `IPhotoPreviewProvider.TryGetCachedThumbnailAsync` (platform cache), then `GetThumbnailAsync` (decodes).\n- 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.\n- `_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.\n- **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.\n- **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.\n- **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.\n- `TryGetCachedThumbnailAsync` must not decode: ","isInternal":false,"tokens":11979,"sizeBytes":50548},{"name":"mcp.json","path":"source/.vscode/mcp.json","rawUrl":"https://raw.githubusercontent.com/d2phap/ImageGlass/HEAD/source/.vscode/mcp.json","title":"Model Context Protocol (MCP) Configuration","category":"mcp-config","format":"json","content":"{\n\t\"servers\": {\n\t\t\"avalonia-docs\": {\n\t\t\t\"url\": \"https://docs-mcp.avaloniaui.net/mcp\",\n\t\t\t\"type\": \"http\"\n\t\t}\n\t},\n\t\"inputs\": []\n}","frontmatter":{"servers":{"avalonia-docs":{"url":"https://docs-mcp.avaloniaui.net/mcp","type":"http"}},"inputs":[]},"isInternal":false,"tokens":51,"sizeBytes":127}],"systemPromptSnippet":"<yakaai_skills repo=\"d2phap/ImageGlass\">\n<!-- File: source/CLAUDE.md (Tokens: ~11979 | Category: claude-rule) -->\n# ImageGlass v10\n\n## Project Overview\nImageGlass 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.\n\n## Repository Layout\n- **`/source`** — v10 source code (the active codebase; this `CLAUDE.md` lives here). All development happens here.\n- **`/v9`** — legacy v9 source code (WinForms/.NET, archived).\n\n> **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.\n\n**Key Projects:**\n- `ImageGlass.Lib`: Core library (net11.0) — UI controls, rendering, codecs, themes, localization, settings\n- `ImageGlass.Win32`: Windows desktop app (net11.0-windows10.0.19041.0) — entry point with Win32 APIs and platform-specific services\n- `ImageGlass.Linux`: Linux desktop app (net11.0) — entry point with Linux-specific services\n- `ImageGlass.Mac`: macOS desktop app (net11.0) — entry point with macOS-specific services\n\n---\n\n## Critical Focus Areas (Always Prioritize)\n\n### 1. AOT/Trim Safety\n- **All platform projects** (Win32, Linux, Mac) use `PublishAot=true` and `PublishTrimmed=true` with trim analyzer enabled.\n- **Win32 project** additionally uses `DisableRuntimeMarshalling=true` and CsWin32 code generation.\n- Avoid reflection-based serialization; use custom JSON converters in `Common/Types/JsonTypeConverters/`.\n- Test AOT builds: `dotnet publish ImageGlass.Win32 -c Release` produces a self-contained, trimmed executable.\n\n### 2. Thread-Safety (No Data Races)\n- Use `Lock` for protecting shared state (not `object` locks).\n- Use `InterlockedBool` instead of plain `bool` for concurrent flags.\n- Use `ConcurrentDictionary` for thread-safe collections (`PhotoManager` index).\n- Always check `IsDisposed` before accessing resources in multi-threaded contexts.\n- Example: `PhotoManager` uses `Lock _lock` to protect `_items`, `_dict`, and `_cachedIndexes`.\n\n### 3. Memory Leak Prevention\n- Use `try/finally` or `using` statements for all `SKObject` acquisitions.\n- Override `OnDisposing()` in `PhDisposable` subclasses to clean up unmanaged resources.\n- Example: `MipmapTileCache` evicts with `RequestDispose()` (never `Dispose()`) so an in-flight render lease stays valid.\n- Always dispose `SKImageRef` leases when done; use `KeepAlive()` pattern in `MipmapTileCache`.\n\n### 4. Memory Usage Control\n- Respect cache limits: `MipmapTileCache` caps 100 tiles; `PhotoManager_Caching` honors `Config.MaxMemoryCacheInMb`, `Config.MaxFileSizeCacheInMb`, `Config.MaxDimensionCache`.\n- LRU eviction is mandatory in tile and photo caches to prevent unbounded growth.\n- **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\").\n- Mark a finished bitmap `SetImmutable()` before `SKImage.FromBitmap` (`SkiaCodec.ToSKImageNoCopy`), otherwise Skia duplicates the entire pixel buffer and doubles peak memory.\n- Profile memory with large images and GIF animations to ensure caches don't leak.\n\n### 5. Cross-Platform Design\n- **Interfaces first** in `ImageGlass.Lib/Common/ServiceProviders/*`: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, etc.\n- **Implementations** per platform:\n  - `ImageGlass.Win32/Common/ServiceProviders/*`: `Win32FileSearchProvider`, `Win32ShellProvider`, etc.\n  - `ImageGlass.Linux/Common/ServiceProviders/*`: `LinuxPrintProvider`, `LinuxShellProvider`, etc.\n  - `ImageGlass.Mac/Common/ServiceProviders/*`: `MacPrintProvider`, `MacShellProvider`, etc.\n- Register service providers in each platform's `Program.cs` before app bootstrap.\n- Avoid platform-specific code in library; use dependency injection via `Core.API`, `Core.FileSearchProvider`, etc.\n\n---\n\n## Architecture Highlights\n\n### Core Application Layers\n1. **Core Static Hub** (`Core.cs`, `Core_Events.cs`): Central event dispatcher and singleton registry\n   - `Core.AppInstance`: App lifecycle & unique instance enforcement\n   - `Core.Config`: Global configuration state\n   - `Core.Photos`: Photo collection and file searching\n   - Events: `LanguageChanged`, `ThemeChanged`, `PhotoUnloaded`, `PhotoSaved`, `ColorProfileChanged`\n   - Service provider slots: `ShellProvider`, `PreviewProvider`, `FileSearchProvider`, `PrintProvider`, `API`\n\n2. **Service Providers** (Plug-in Pattern)\n   - Platform-agnostic interfaces in `ImageGlass.Lib/Common/ServiceProviders/*`\n   - Platform implementations:\n     - Win32: `ImageGlass.Win32/Common/ServiceProviders/*`\n     - Linux: `ImageGlass.Linux/Common/ServiceProviders/*`\n     - macOS: `ImageGlass.Mac/Common/ServiceProviders/*`\n   - Shared concrete providers in library: `PhotoPreviewProvider`, `SlideshowProvider`, `UpdateProvider`\n   - Examples: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, `IWindowColorProfileProvider`, `IPhotoPreviewProvider`\n   - Registered at startup in each platform's `Program.cs`; initialized in `App.xaml.cs`\n\n3. **Photo & Codec Abstraction**\n   - `PhotoManager`: Thread-safe collection with `AvaloniaList<Photo>` + `ConcurrentDictionary` index\n   - Codecs: `SkiaCodec` (fast SkiaSharp pipeline) and `MagickCodec` (Magick.NET fallback for obscure formats)\n   - Async loading via `PhotoLoadingOptions` + frame animation support via `SkiaAnimator`/`AnimatorImpl`\n   - Caching: `PhotoManager_Caching.cs` uses spiral pattern, LRU eviction, memory budgets\n\n4. **Viewer & Rendering** (`ViewerControl`)\n   - Partial classes split by concern: `ViewerControl_Render.cs`, `ViewerControl_ZoomAndPan.cs`, `ViewerControl_Events.cs`, `ViewerControl_Animation.cs`, `ViewerControl_NavButtons.cs`, etc.\n   - High-performance mipmap tile caching: `MipmapTileCache` with LRU eviction for large images (8192×8192+)\n   - Gesture recognizers: `PhPanGestureRecognizer`, `PhPinchGestureRecognizer` accumulate points for smooth interaction\n   - SkiaSharp rendering in `ViewerControl_Render.cs` + `PhotoRenderer.cs`\n   - **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\n   - **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\n\n---\n\n## Code Organization & Naming Conventions\n\n### Folder Structure\n```\nImageGlass.Lib/\n├── Common/\n│   ├── Actions/                   # Action definitions\n│   ├── Commands/                  # Command definitions\n│   ├── AppThemes/                 # Theme system: IgTheme, IgThemeColors, AppThemeColors, IgThemeMetadata\n│   ├── BHelper/                   # Static helper methods (see \"Reusing Shared Code\"): Color, Format, General, JsonEx, Path, ProcessHelper, Sound, Task\n│   ├── Extensions/                # Extension methods (see \"Reusing Shared Code\"): Color_Exts, DrawingContext_Exts, ISolidColorBrush_Exts, Point_Exts, Rect_Exts, Size_Exts, SKObject_Exts\n│   ├── Localization/              # Lang.cs, LangId enum\n│   ├── Photoing/                  # Photo management, codecs, animators\n│   │   ├── Animators/             # SkiaAnimator, AnimatorImpl\n│   │   ├── Codecs/                # Codec pipeline\n│   │   │   ├── MagickCodecs/      # Magick.NET fallback codec\n│   │   │   ├── Registry/          # CodecRegistry\n│   │   │   ├── SkiaCodecs/        # SkiaSharp fast-path codec\n│   │   │   └── SvgCodecs/         # SVG (vector) codec\n│   │   ├── Manager/               # PhotoManager & PhotoManager_*.cs (search, watcher, caching)\n│   │   └── Photos/                # Photo, PhotoMetadata, PhotoColorProfile, etc.\n│   ├── Printing/                  # Print layouts, layout engine, page renderer, PDF writer, PrintSession (see \"Printing\")\n│   ├── ServiceProviders/          # Interface contracts + shared providers\n│   │   ├── AppAPIs/               # App API provider interfaces\n│   │   ├── FileSearchService/     # File search service abstractions\n│   │   ├── Update/                # Update provider abstractions\n│   │   ├── I*.cs                  # Interfaces (IFileSearchProvider, IShellProvider, etc.)\n│   │   ├── PhotoPreviewProvider.cs\n│   │   ├── SlideshowProvider.cs\n│   │   └── UpdateProvider.cs\n│   └── Types/                     # Base classes + shared types: PhReactive, PhDisposable, InterlockedBool, Resx, Const, Hotkey, SKImageRef, Enums, Dir\n│       └── JsonTypeConverters/    # AOT-safe JSON converters (see \"Reusing Shared Code\")\n├── Plugins/                       # Native plugin host: PluginRegistry, codec proxies\n├── Settings/                      # Config.cs, Config_Static.cs, ConfigMetadata.cs\n├── Tools/                         # Built-in & external tool framework\n│   ├── Builtins/                  # ColorPicker, CropImage, FrameNav, ImageResizer, LosslessCompression\n│   └── External/                  # External tool process management\n├── UI/\n│   ├── BaseControls/              # Ph* controls (see \"Designing UI\"): PhControl, PhButton, PhTextBox, PhTextBlock, PhToolButton, PhMenuItem, PhHotkeyPicker, PhGridSplitter, PhVirtualizingUniformPanel, PhCommandPreview, PhSearchBoxControl, PhTableControl\n│   ├── Gallery/                   # Gallery browsing UI (GalleryControl)\n│   ├── Styles/                    # App-wide XAML styles + resource dictionaries (see \"Designing UI\"); merged in App.axaml\n│   ├── Toolbar/                   # Toolbar UI and model (ToolbarControl)\n│   ├── Viewer/                    # ViewerControl (partial classes by feature)\n│   │   ├── Checkerboard/          # Transparency checkerboard background\n│   │   ├── NavButtons/            # NavButtonsOverlay, NavButtonsInfo, NavButtonClickedEventArgs\n│   │   ├── Renderer/              # MipmapTileCache, PhotoRenderer\n│   │   ├── Selection/             # Selection tools\n│   │   └── ZoomAndPan/            # Gesture recognizers, zoom/pan math\n│   └── Windowing/                 # PhWindow, ModalWindow, DialogWindow, PhColorPickerDialog\n├── ViewModels/                    # MainWindowViewModel, MainWindowModel, SettingsViewModel\n└── Windows/                       # Top-level windows: MainWindow, AboutWindow, SettingsWindow, UpdateWindow, ExportFramesWindow\n    ├── Main/                      # MainWindow_View partial\n    ├── Print/                     # PrintWindow, its view, preview and layout tiles\n    └── Settings/                  # Settings UI: Controls/, Pages/, Windows/\n\nImageGlass.Win32/\n├── Program.cs                     # Entry point, service registration, AOT bootstrap\n├── Properties/                    # PublishProfiles\n├── Windows/                       # MainWindow32.cs (Win32-specific window)\n└── Common/\n    ├── ServiceProviders/          # Win32 implementations (Win32FileSearchProvider, etc.)\n    └── WinAPI/                    # P/Invoke wrappers (Win32*.cs files)\n\nImageGlass.Linux/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    └── ServiceProviders/          # Linux implementations (LinuxPrintProvider, etc.)\n\nImageGlass.Mac/\n├── Program.cs                     # Entry point, service registration\n└── Common/\n    ├── Sandbox/                   # macOS sandbox helpers\n    └── ServiceProviders/          # macOS implementations (MacPrintProvider, etc.)\n```\n\n### Naming Rules\n- **Classes inheriting from Control/UserControl**: Use `PhControl` base (e.g., `ToolbarControl : PhControl`)\n- **View Models**: Suffix with `ViewModel` (e.g., `MainWindowViewModel : PhReactive`)\n- **Async methods**: Always suffix with `Async`\n- **Codecs/Providers**: Prefix with platform/source (e.g., `SkiaCodec`, `Win32FileSearchProvider`, `MagickCodec`)\n- **Partial class files**: Name by concern (e.g., `ViewerControl_Render.cs`, `PhotoManager_Caching.cs`, `AppAPIProvider_Hotkeys.cs`)\n\n---\n\n## Reusing Shared Code (Always Check Before Writing New)\n\nBefore adding a helper, converter, or extension, check these folders; the utility you need likely already exists.\n\n### Type Extensions: `Common/Extensions/`\nExtension methods on framework types. Prefer these over re-implementing:\n- `Color_Exts`: `ToBrush()`, `WithAlpha()`, `NoAlpha()`, `Blend()`, `WithBrightness()`, `IsLight()`, `BlackOrWhite()` / `InvertBlackOrWhite()`, `ToHex()`, `ToRgbaString()`, `ToCmyk()` / `ToCmykString()`, `ToHslString()`, `ToHsvString()`, `ToCIELAB()` / `ToCIELABString()`\n- `ISolidColorBrush_Exts`, `DrawingContext_Exts`, `Point_Exts`, `Rect_Exts`, `Size_Exts`: Avalonia geometry & drawing helpers\n- `SKObject_Exts`: `IsDisposed()` and other SkiaSharp guards\n\n### Helper Methods: `Common/BHelper/` (static `BHelper` partial class)\n- `Color.cs`: `ColorFromHex()`\n- `Format.cs`: `FormatSize()`, `FormatDateTime()`, `SimplifyFractions()`, star-rating formatting\n- `General.cs`: `OS`, `GenerateWrappedIndexes()` (spiral cache order), `ComputeIndexInRange()`, `ResizeRatio()`, `GetInAppError()`\n- `Path.cs`: `ConfigDir()` / `BaseDir()`, `ResolvePath()`, `CheckPath()`, `OpenUrlAsync()`, `OpenFilePath()` / `OpenFolderPath()`, `DeleteFile()`\n  - `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.\n- `ProcessHelper.cs`: `RunExeAsync()` / `RunExeCmd()`, `RunSync()`, `ExitApp()`\n- `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)\n- `JsonEx.cs`: `CreateJsonOptions()`, `ReadJsonFromFile()` / `WriteJsonToFileAsync()` (AOT-safe via `JsonTypeInfo<T>`); both go through `FileIO.cs`, so every JSON file is multi-instance safe\n- `FileIO.cs`: `OpenReadShared()` / `WriteAllTextSharedAsync()`, the multi-instance-safe file primitives (see \"Concurrent file writes\")\n- `Task.cs`: `Debounce()`, `GcCollect()`\n\n### JSON Converters: `Common/Types/JsonTypeConverters/`\nAOT-safe converters for `Config` serialization. Reuse an existing one, or add a new converter here; never use reflection-based serialization.\n- `JsonStringEnumSafeConverter<T>` (always use for enums; never the built-in `JsonStringEnumConverter`)\n- `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`, `JsonArrayToRectConverter` / `JsonArrayToPixelRectConverter`, `JsonArrayToDoubleConverter` / `JsonArrayToIntConverter`, `JsonDateTimeConverter`, `JsonHashSetToStringConverter`, `JsonObservableCollectionToStringConverter`\n\n---\n\n## Designing UI\n\nWhen building or restyling UI, reuse the app's existing controls, design tokens, styles, icons, and fonts instead of raw Avalonia controls or hardcoded values.\n\n### 1. Controls: prefer `UI/` over raw Avalonia\n- **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).\n- **Windows & dialogs** (`UI/Windowing/`): inherit `PhWindow`; for modal/dialog flows use `ModalWindow` / `DialogWindow`; use `PhColorPickerDialog` for color picking.\n- **Feature controls**: `ViewerControl` (`UI/Viewer/`), `GalleryControl` (`UI/Gallery/`), `ToolbarControl` (`UI/Toolbar/`).\n\n### 2. Colors, brushes & styles: never hardcode\n- **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.\n- **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`).\n- **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.\n- **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`).\n\n### 3. Icons: `UI/Styles/IconResources.axaml`\nReuse 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()`.\n\n### 4. Fonts: `Common/Types/Const.cs:81-104`\n- **Font family**: `Const.FONT_CODE` (OS-specific monospace stack for code / credits / metadata text).\n- **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.\n\n---\n\n## Critical Base Classes & Patterns\n\n### 1. Reactive Programming — `PhReactive`\n- All ViewModels inherit from `PhReactive : INotifyPropertyChanged`\n- Thread-safe property change notifications using `Lock` (not `object`)\n- Use with Avalonia compiled bindings: `{CompiledBinding PropertyName}`\n- Example:\n  ```csharp\n  public class MainWindowViewModel : PhReactive\n  {\n      public string Title\n      {\n          get; set\n          {\n              if (field.Equals(value)) return;\n              field = value;\n              OnPropertyChanged();\n          }\n      } = BHelper.AppName;\n  }\n  ```\n\n### 2. Resource Management — `PhDisposable`\n- Inherit when managing unmanaged resources (SKImage, file handles, etc.)\n- Override `OnDisposing()` (called on Dispose, not destructor)\n- Uses `InterlockedBool _isDisposed` for thread-safe disposal checks\n- Use with `await using` in async contexts\n- Example:\n  ```csharp\n  public sealed class MipmapTileCache : PhDisposable\n  {\n      protected override void OnDisposing()\n      {\n          lock (_lock)\n          {\n              foreach (var bitmap in _tiles.Values)\n              {\n                  bitmap.Dispose();\n              }\n              _tiles.Clear();\n          }\n      }\n  }\n  ```\n\n### 3. Thread-Safe Operations — `InterlockedBool`\n- Use instead of `bool` for concurrent state flags\n- Atomic read/write using `Volatile` and `Interlocked` operations\n- Example: `_isPreviewing`, `_isFirstDraw`, `_isDisposed` in ViewerControl\n- Check with `.Value` property or implicit conversion\n\n### 4. SkiaSharp Safety — `IsDisposed()` Extension\n- Always check `SKObject.IsDisposed()` before using SkiaSharp objects\n- Defined in `ImageGlass.Common.Extensions.SKObject_Exts`\n- Returns `true` if object is `null` or `Handle == IntPtr.Zero`\n- Example:\n  ```csharp\n  if (_skImage.IsDisposed()) { _skImage = null; }\n  ```\n\n---\n\n## Critical Workflows & Commands\n\n### Build\n```powershell\n# Restore and build\ndotnet build\n\n# Publish AOT-enabled binary (ImageGlass.Win32)\ndotnet publish ImageGlass.Win32 -c Release -o ./bin/publish\n\n# Run from source\ndotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]\n```\n\n### Debug\n- Open solution in Visual Studio 2026\n- Set `ImageGlass.Win32` as startup project\n- **Breakpoints work normally in C# code**\n- **Avalonia Hot Reload**: Available with `--debug` flag (limited to XAML/styles)\n- **Profiling**: Use Visual Studio's memory profiler for cache leak detection\n\n### Testing\n- No dedicated test projects in v10 (focus on functional validation)\n- Manual testing via `dotnet run`\n- Test command-line parsing with sample images\n- Profile memory usage with large images and GIF animations\n\n### Key C# Version & Langversion\n- **LangVersion**: `Preview` (C# 15 features available)\n- **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.\n- **Nullable**: `enable` across projects\n- **AllowUnsafeBlocks**: `true` (for P/Invoke, SkiaSharp interop)\n- **PublishAot**: `true` in all platform projects (Win32, Linux, Mac)\n- **PublishTrimmed**: `true` in all projects (library + platform)\n- **PublishSingleFile** + **SelfContained** + **PublishReadyToRun**: `true` in all platform projects\n- **DisableRuntimeMarshalling**: `true` in Win32 only (AOT-safe marshalling)\n\n---\n\n## Key Integration Points\n\n### 1. Photo Loading Pipeline\n```\nApp.Initialize() \n  → Core.InitializeAsync() \n    → PhotoManager.Add/SearchAsync()\n    → ViewerControl.LoadPhotoAsync(photoPath)\n      → Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)\n      → shrink to fit the pixel ceiling if needed → Photo.DecodeScale\n      → PhotoRenderer.RenderAsync()\n      → AnimatorImpl for GIF/WEBP animation\n    → PhotoLoading/PhotoAnimatorFrameChanged events\n    → MipmapTileCache.Create() if image > 8192×8192\n```\n\n### Oversized image decode\n- Images past the `int.MaxValue`-byte ceiling are decoded smaller rather than refused.\n- **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`.\n- **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.\n- The reduction flows `SkiaDecoderOutput`/`MagickCodecAdapter` → `CodecDecodeResult.DecodeScale` → `Photo.DecodeScale`, and `AppStatusInfo.Dimension` shows it.\n- `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.\n\n### Multi-size icons (ICO/CUR)\n- 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.\n- **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.\n- `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`.\n- `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.\n\n### 2. Theme/Localization Changes\n- Change language: `Core.LanguageChanged?.Invoke()`\n- Change theme: `Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...))`\n- UI elements auto-refresh via bindings + event subscribers\n\n### 3. Win32 Service Registration\nOccurs in `Program.cs` before Avalonia app setup (Linux/Mac have equivalent registrations):\n- `FileSearchProvider` (shell enumeration in the view order of File Explorer or a supported third-party file manager; see \"Explorer sort order & the foreground shell\")\n- `ShellProvider` (context menu, file properties, Windows Share API)\n- `PrintProvider` (GDI printing through the spooler; Print Pictures as the system dialog; see \"Printing\")\n- `ColorProfileProvider` (Monitor color profile retrieval via Win32 APIs)\n- `PreviewProvider` (thumbnail cache via Windows shell)\n\n### 4. Configuration Persistence\n- `Config.cs` handles JSON serialization with custom converters\n- Converters in `Common/Types/JsonTypeConverters/` (e.g., `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`)\n- Cached in `AppData\\Local\\ImageGlass\\`, or the startup dir in portable mode (see below)\n- Async save via `Config.SaveAsync()`\n\n### Concurrent file writes (`BHelper.WriteAllTextSharedAsync`)\n- 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.\n- **`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.\n- **`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`.\n- `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.\n- Last writer wins is unchanged and intended: each instance persists its own whole `Config`.\n\n### Portable mode (`ConfigMode`)\n- 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.\n- `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.\n- 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`.\n- **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.\n- `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.\n- 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.\n\n### Linux self-update (`LinuxUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- **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.\n- 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.\n- 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.\n- The install-while-alive helpers (`RunInstallThenExitAsync`, `RelaunchAsync`, `ForgetInstalledUpdateAsync`, `RunCommandAsync`) live in the base `UpdateProvider`, shared with macOS.\n\n### macOS self-update (`MacUpdateProvider`)\n- 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.\n- **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.\n- **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.\n- 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`).\n\n### Photo transitions (`UI/Viewer/Transitions/`)\n- **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.\n- **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 `\n`, 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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`).\n- **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.\n\n### Full screen & the windowed layout\n- 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.\n- 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.\n- 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`.\n- 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.\n- The close handler reads `RestorableWindowState`, not `WindowState`: closing a *minimized* window would otherwise record `EnableFullScreen`/`EnableMainWindowMaximized` as false.\n- 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`.\n\n### 5. Photo Caching Strategy\n- **Tier 1**: Current photo in viewer (always loaded)\n- **Tier 2**: Neighboring photos preloaded via `PhotoManager.RequestCacheAround()`\n- **Tier 3**: LRU eviction when memory budget exhausted\n- Spiral pattern: right-1, left-1, right-2, left-2, ... to balance browsing directions\n- **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.\n- **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`.\n- **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.\n- **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.\n- **`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.\n- **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.\n\n### File search & captured file metadata\n- `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.\n- `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.\n- 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).\n- `FileSearchingEventArgs.Results` is a materialized `IReadOnlyList`, and filtering/sorting now run on the background thread instead of lazily on the UI thread during `Add`.\n- 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.\n\n### Explorer sort order & the foreground shell (EggShell)\n- **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.\n- **`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.\n- **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.\n- **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.\n- **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`.\n- **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`).\n- 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.\n\n### Gallery thumbnail pipeline (`Photo.LoadThumbnailAsync`)\n- Tiers in order, all skipped when `useCache` is false: in-memory `GalleryThumbnail`, `ThumbnailDiskCache`, `IPhotoPreviewProvider.TryGetCachedThumbnailAsync` (platform cache), then `GetThumbnailAsync` (decodes).\n- 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.\n- `_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.\n- **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.\n- **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.\n- **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.\n- `TryGetCachedThumbnailAsync` must not decode:\n\n<!-- File: source/.vscode/mcp.json (Tokens: ~51 | Category: mcp-config) -->\n{\n\t\"servers\": {\n\t\t\"avalonia-docs\": {\n\t\t\t\"url\": \"https://docs-mcp.avaloniaui.net/mcp\",\n\t\t\t\"type\": \"http\"\n\t\t}\n\t},\n\t\"inputs\": []\n}\n</yakaai_skills>"}