Files
AX-Copilot-Codex/src/AxCopilot/Services/IconCacheService.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00

193 lines
7.4 KiB
C#

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;
/// <summary>
/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다.
/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png
/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다.
/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다.
/// </summary>
internal static class IconCacheService
{
private static readonly string _cacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AxCopilot", "IconCache");
/// <summary>key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패)</summary>
private static readonly ConcurrentDictionary<string, string?> _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 ───────────────────────────────────────────────────────────
/// <summary>
/// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다.
/// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다.
/// </summary>
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;
}
/// <summary>
/// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다.
/// 중복 실행은 자동으로 건너뜁니다.
/// </summary>
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);
}
}
/// <summary>Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다.</summary>
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);
}
}
}