using System.Collections.Concurrent; using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using System.Windows.Media.Imaging; namespace AxCopilot.Services; /// /// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다. /// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png /// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다. /// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다. /// internal static class IconCacheService { private static readonly string _cacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AxCopilot", "IconCache"); /// key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패) private static readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); private static volatile bool _warmupDone; // ─── Win32 ────────────────────────────────────────────────────────────── [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct SHFILEINFO { public IntPtr hIcon; public int iIcon; public uint dwAttributes; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName; } [DllImport("shell32.dll", CharSet = CharSet.Auto)] private static extern IntPtr SHGetFileInfo( string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags); [DllImport("user32.dll", SetLastError = true)] private static extern bool DestroyIcon(IntPtr hIcon); private const uint SHGFI_ICON = 0x000000100; private const uint SHGFI_LARGEICON = 0x000000000; private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010; private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080; private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; // ─── 공개 API ─────────────────────────────────────────────────────────── /// /// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다. /// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다. /// public static string? GetIconPath(string filePath, bool isDirectory = false) { var key = isDirectory ? "folder" : GetExtKey(filePath); if (_cache.TryGetValue(key, out var cached)) return cached; _ = ExtractAsync(filePath, key, isDirectory); return null; } /// /// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다. /// 중복 실행은 자동으로 건너뜁니다. /// public static void WarmUp() { if (_warmupDone) return; _warmupDone = true; // 캐시 디렉터리에 이미 저장된 PNG 로드 (재시작 시 빠른 복원) _ = Task.Run(() => { try { Directory.CreateDirectory(_cacheDir); foreach (var file in Directory.GetFiles(_cacheDir, "*.png")) { var key = Path.GetFileNameWithoutExtension(file); _cache.TryAdd(key, file); } } catch { } }); // 자주 쓰는 확장자 사전 추출 (UI 스레드 부하를 분산하기 위해 순차 실행) _ = Task.Run(async () => { await Task.Delay(2000); // 앱 초기화 완료 후 추출 시작 var common = new (string Path, string Key, bool IsDir)[] { ("dummy.exe", "exe", false), ("dummy.lnk", "lnk", false), ("dummy.pdf", "pdf", false), ("dummy.docx", "docx", false), ("dummy.xlsx", "xlsx", false), ("dummy.pptx", "pptx", false), ("dummy.txt", "txt", false), ("dummy.png", "png", false), ("dummy.zip", "zip", false), ("dummy.mp4", "mp4", false), ("C:\\", "folder", true), }; foreach (var (path, key, isDir) in common) { if (_cache.ContainsKey(key)) continue; await ExtractAsync(path, key, isDir); await Task.Delay(80); // UI 스레드 부하 분산 } }); } // ─── 내부 ─────────────────────────────────────────────────────────────── private static string GetExtKey(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); return string.IsNullOrEmpty(ext) ? "noext" : ext.TrimStart('.'); } private static async Task ExtractAsync(string filePath, string key, bool isDirectory) { if (_cache.ContainsKey(key)) return; try { Directory.CreateDirectory(_cacheDir); var cachePath = Path.Combine(_cacheDir, key + ".png"); // 파일이 이미 캐시에 있으면 메모리 캐시에 등록만 if (File.Exists(cachePath)) { _cache.TryAdd(key, cachePath); return; } // SHGetFileInfo + HICON → PNG 변환은 반드시 STA(Dispatcher) 스레드에서 var dispatcher = Application.Current?.Dispatcher; if (dispatcher == null) return; var saved = await dispatcher.InvokeAsync(() => TryExtractAndSave(filePath, cachePath, isDirectory)); _cache.TryAdd(key, saved ? cachePath : null); } catch (Exception ex) { LogService.Warn($"[IconCache] '{key}' 추출 실패: {ex.Message}"); _cache.TryAdd(key, null); } } /// Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다. private static bool TryExtractAndSave(string filePath, string cachePath, bool isDirectory) { var info = new SHFILEINFO(); uint flags = SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES; uint attrs = isDirectory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; SHGetFileInfo(filePath, attrs, ref info, (uint)Marshal.SizeOf(info), flags); if (info.hIcon == IntPtr.Zero) return false; try { var bitmap = Imaging.CreateBitmapSourceFromHIcon( info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions()); bitmap.Freeze(); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using var stream = File.OpenWrite(cachePath); encoder.Save(stream); return true; } catch { return false; } finally { DestroyIcon(info.hIcon); } } }