using System.Collections.Concurrent; using System.IO; using System.Net.Http; using System.Windows.Media.Imaging; namespace AxCopilot.Services; /// /// 외부 URL의 favicon을 다운로드하여 캐시합니다. /// Google Favicon API (https://www.google.com/s2/favicons?domain=xxx&sz=32)를 사용합니다. /// 캐시는 메모리(ConcurrentDictionary) + 디스크(%APPDATA%\AxCopilot\favicons\)에 저장됩니다. /// public static class FaviconService { private static readonly string CacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "favicons"); private static readonly ConcurrentDictionary _memCache = new(); private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) }; /// /// 도메인의 favicon BitmapImage를 반환합니다. /// 캐시에 있으면 즉시 반환, 없으면 null 반환 후 백그라운드에서 다운로드합니다. /// 다운로드 완료 시 콜백을 호출합니다. /// public static BitmapImage? GetFavicon(string url, Action? onLoaded = null) { var domain = ExtractDomain(url); if (string.IsNullOrEmpty(domain)) return null; // 메모리 캐시 확인 if (_memCache.TryGetValue(domain, out var cached)) return cached; // 디스크 캐시 확인 var diskPath = Path.Combine(CacheDir, $"{domain}.png"); if (File.Exists(diskPath)) { try { var bmp = LoadFromDisk(diskPath); _memCache[domain] = bmp; return bmp; } catch { } } // 백그라운드에서 다운로드 _memCache[domain] = null; // 중복 요청 방지 _ = DownloadAsync(domain, diskPath, onLoaded); return null; } private static async Task DownloadAsync(string domain, string diskPath, Action? onLoaded) { try { var faviconUrl = $"https://www.google.com/s2/favicons?domain={Uri.EscapeDataString(domain)}&sz=32"; var bytes = await _http.GetByteArrayAsync(faviconUrl).ConfigureAwait(false); if (bytes.Length < 100) return; // 너무 작으면 유효한 이미지 아님 // 디스크 저장 Directory.CreateDirectory(CacheDir); await File.WriteAllBytesAsync(diskPath, bytes).ConfigureAwait(false); // 메모리 캐시 갱신 System.Windows.Application.Current?.Dispatcher.Invoke(() => { try { var bmp = LoadFromDisk(diskPath); _memCache[domain] = bmp; onLoaded?.Invoke(); } catch { } }); } catch (Exception ex) { LogService.Warn($"Favicon 다운로드 실패: {domain} — {ex.Message}"); } } private static BitmapImage LoadFromDisk(string path) { var bmp = new BitmapImage(); bmp.BeginInit(); bmp.UriSource = new Uri(path, UriKind.Absolute); bmp.CacheOption = BitmapCacheOption.OnLoad; bmp.DecodePixelWidth = 32; bmp.EndInit(); bmp.Freeze(); return bmp; } private static string? ExtractDomain(string url) { try { if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) url = "https://" + url; return new Uri(url).Host.ToLowerInvariant(); } catch { return null; } } }