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개를 확인했습니다.
This commit is contained in:
192
src/AxCopilot/Services/IconCacheService.cs
Normal file
192
src/AxCopilot/Services/IconCacheService.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user