using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; namespace AxCopilot.Services; public class ClipboardHistoryService : IDisposable { private class SavedClipEntry { public string Text { get; set; } = ""; public DateTime CopiedAt { get; set; } public string? ImageBase64 { get; set; } public string? OriginalImagePath { get; set; } public bool IsPinned { get; set; } public string Category { get; set; } = "일반"; } private const int WM_CLIPBOARDUPDATE = 797; private static readonly string HistoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "clipboard_history.dat"); private static readonly string LegacyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "clipboard_history.json"); private static readonly string ImageCachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "clipboard_images"); private const long MaxCacheSizeBytes = 524288000L; private const int MaxCacheAgeDays = 30; private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions { WriteIndented = false, PropertyNameCaseInsensitive = true }; private readonly SettingsService _settings; private HwndSource? _msgSource; private readonly object _lock = new object(); private volatile bool _ignoreNext; private bool _disposed; private readonly List _history = new List(); public IReadOnlyList History { get { lock (_lock) { return _history.ToList(); } } } public event EventHandler? HistoryChanged; public ClipboardHistoryService(SettingsService settings) { _settings = settings; } public void Initialize() { if (!_disposed && _msgSource == null && _settings.Settings.ClipboardHistory.Enabled) { LoadHistory(); ((DispatcherObject)Application.Current).Dispatcher.Invoke((Action)delegate { HwndSourceParameters parameters = new HwndSourceParameters("axClipboardMonitor") { Width = 0, Height = 0, ParentWindow = new IntPtr(-3), WindowStyle = 0, ExtendedWindowStyle = 0 }; _msgSource = new HwndSource(parameters); _msgSource.AddHook(WndProc); AddClipboardFormatListener(_msgSource.Handle); LogService.Info("클립보드 히스토리 서비스 시작"); }); } } public void SuppressNextCapture() { _ignoreNext = true; } public void PromoteEntry(ClipboardEntry entry) { lock (_lock) { _history.Remove(entry); ClipboardEntry item = new ClipboardEntry(entry.Text, DateTime.Now) { Image = entry.Image, OriginalImagePath = entry.OriginalImagePath, IsPinned = entry.IsPinned, Category = entry.Category }; _history.Insert(0, item); } this.HistoryChanged?.Invoke(this, EventArgs.Empty); SaveHistoryAsync(); } public void ClearHistory() { lock (_lock) { _history.Clear(); } this.HistoryChanged?.Invoke(this, EventArgs.Empty); SaveHistoryAsync(); } public void TogglePin(ClipboardEntry entry) { lock (_lock) { if (!entry.IsPinned) { int maxPinnedClipboardItems = _settings.Settings.Launcher.MaxPinnedClipboardItems; int num = _history.Count((ClipboardEntry e) => e.IsPinned); if (num >= maxPinnedClipboardItems) { return; } } entry.IsPinned = !entry.IsPinned; } this.HistoryChanged?.Invoke(this, EventArgs.Empty); SaveHistoryAsync(); } private static string DetectCategory(string text) { string text2 = text.Trim(); if (Uri.TryCreate(text2, UriKind.Absolute, out Uri result) && (result.Scheme == "http" || result.Scheme == "https")) { return "URL"; } if (text2.StartsWith("\\\\") || (text2.Length >= 3 && text2[1] == ':' && (text2[2] == '\\' || text2[2] == '/'))) { return "경로"; } if ((text2.Contains('{') && text2.Contains('}')) || text2.Contains("function ") || text2.Contains("class ") || text2.Contains("public ") || text2.Contains("private ") || text2.Contains("def ") || text2.Contains("import ") || text2.Contains("using ")) { return "코드"; } return "일반"; } public void Reinitialize() { _disposed = false; _msgSource = null; Initialize(); LogService.Info("클립보드 히스토리 서비스 재초기화 완료"); } public void Dispose() { if (!_disposed) { _disposed = true; if (_msgSource != null) { RemoveClipboardFormatListener(_msgSource.Handle); _msgSource.Dispose(); _msgSource = null; } SaveHistorySync(); } } private void LoadHistory() { try { if (!File.Exists(HistoryPath) && File.Exists(LegacyPath)) { MigrateLegacyFile(); } if (!File.Exists(HistoryPath)) { return; } byte[] encryptedData = File.ReadAllBytes(HistoryPath); byte[] bytes = ProtectedData.Unprotect(encryptedData, null, DataProtectionScope.CurrentUser); string json = Encoding.UTF8.GetString(bytes); List list = JsonSerializer.Deserialize>(json, JsonOpts); if (list == null) { return; } int maxItems = _settings.Settings.ClipboardHistory.MaxItems; lock (_lock) { foreach (SavedClipEntry item in list.Take(maxItems)) { if (!string.IsNullOrEmpty(item.ImageBase64)) { BitmapSource bitmapSource = Base64ToImage(item.ImageBase64); if (bitmapSource != null) { _history.Add(new ClipboardEntry("", item.CopiedAt) { Image = bitmapSource, OriginalImagePath = item.OriginalImagePath, IsPinned = item.IsPinned, Category = item.Category }); continue; } } _history.Add(new ClipboardEntry(item.Text, item.CopiedAt) { IsPinned = item.IsPinned, Category = item.Category }); } } Task.Run((Action)CleanupImageCache); LogService.Info($"클립보드 히스토리 {_history.Count}개 복원 (암호화)"); } catch (Exception ex) { LogService.Warn("클립보드 히스토리 로드 실패: " + ex.Message); } } private void MigrateLegacyFile() { try { string s = File.ReadAllText(LegacyPath); byte[] bytes = Encoding.UTF8.GetBytes(s); byte[] bytes2 = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)); File.WriteAllBytes(HistoryPath, bytes2); File.Delete(LegacyPath); LogService.Info("클립보드 히스토리 평문→암호화 마이그레이션 완료"); } catch (Exception ex) { LogService.Warn("클립보드 히스토리 마이그레이션 실패: " + ex.Message); } } private async Task SaveHistoryAsync() { try { List snapshot = BuildSnapshot(); string json = JsonSerializer.Serialize(snapshot, JsonOpts); byte[] plain = Encoding.UTF8.GetBytes(json); byte[] encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)); await File.WriteAllBytesAsync(HistoryPath, encrypted).ConfigureAwait(continueOnCapturedContext: false); } catch (Exception ex) { Exception ex2 = ex; LogService.Warn("클립보드 히스토리 저장 실패: " + ex2.Message); } } private void SaveHistorySync() { try { List value = BuildSnapshot(); string s = JsonSerializer.Serialize(value, JsonOpts); byte[] bytes = Encoding.UTF8.GetBytes(s); byte[] bytes2 = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)); File.WriteAllBytes(HistoryPath, bytes2); } catch { } } private List BuildSnapshot() { lock (_lock) { return _history.Select(delegate(ClipboardEntry e) { if (e.IsText) { return new SavedClipEntry { Text = e.Text, CopiedAt = e.CopiedAt, IsPinned = e.IsPinned, Category = e.Category }; } string imageBase = ImageToBase64(e.Image); return new SavedClipEntry { CopiedAt = e.CopiedAt, ImageBase64 = imageBase, OriginalImagePath = e.OriginalImagePath, IsPinned = e.IsPinned, Category = e.Category }; }).ToList(); } } private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) { if (msg == 797) { OnClipboardUpdate(); handled = false; } return IntPtr.Zero; } private void OnClipboardUpdate() { if (_ignoreNext) { _ignoreNext = false; } else { if (!_settings.Settings.ClipboardHistory.Enabled) { return; } ((DispatcherObject)Application.Current).Dispatcher.Invoke((Action)delegate { try { ClipboardEntry entry = null; if (Clipboard.ContainsText()) { string text = Clipboard.GetText(); if (string.IsNullOrWhiteSpace(text) || text.Length > 10000) { return; } foreach (string excludePattern in _settings.Settings.ClipboardHistory.ExcludePatterns) { try { if (Regex.IsMatch(text.Trim(), excludePattern, RegexOptions.None, TimeSpan.FromMilliseconds(200.0))) { return; } } catch { } } string category = (_settings.Settings.Launcher.EnableClipboardAutoCategory ? DetectCategory(text) : "일반"); entry = new ClipboardEntry(text, DateTime.Now) { Category = category }; } else if (Clipboard.ContainsImage()) { BitmapSource image = Clipboard.GetImage(); if (image == null) { return; } string originalImagePath = SaveOriginalImage(image); BitmapSource image2 = CreateThumbnail(image, 80); entry = new ClipboardEntry("", DateTime.Now) { Image = image2, OriginalImagePath = originalImagePath }; } if (!(entry == null)) { lock (_lock) { if (entry.IsText) { if (_history.Count > 0 && _history[0].Text == entry.Text) { return; } _history.RemoveAll((ClipboardEntry e) => e.IsText && e.Text == entry.Text); } _history.Insert(0, entry); int maxItems = _settings.Settings.ClipboardHistory.MaxItems; while (_history.Count > maxItems) { int num = _history.FindLastIndex((ClipboardEntry e) => !e.IsPinned); if (num < 0) { break; } _history.RemoveAt(num); } } this.HistoryChanged?.Invoke(this, EventArgs.Empty); SaveHistoryAsync(); } } catch (Exception ex) { LogService.Warn("클립보드 캡처 실패: " + ex.Message); } }); } } private static string? SaveOriginalImage(BitmapSource src) { try { Directory.CreateDirectory(ImageCachePath); string path = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; string text = Path.Combine(ImageCachePath, path); PngBitmapEncoder pngBitmapEncoder = new PngBitmapEncoder(); pngBitmapEncoder.Frames.Add(BitmapFrame.Create(src)); using FileStream stream = new FileStream(text, FileMode.Create); pngBitmapEncoder.Save(stream); return text; } catch (Exception ex) { LogService.Warn("원본 이미지 저장 실패: " + ex.Message); return null; } } public static BitmapSource? LoadOriginalImage(string? path) { if (string.IsNullOrEmpty(path) || !File.Exists(path)) { return null; } try { BitmapImage bitmapImage = new BitmapImage(); bitmapImage.BeginInit(); bitmapImage.CacheOption = BitmapCacheOption.OnLoad; bitmapImage.UriSource = new Uri(path, UriKind.Absolute); bitmapImage.EndInit(); ((Freezable)bitmapImage).Freeze(); return bitmapImage; } catch { return null; } } public static void CleanupImageCache() { try { if (!Directory.Exists(ImageCachePath)) { return; } List list = (from f in new DirectoryInfo(ImageCachePath).GetFiles("clip_*.png") orderby f.LastWriteTime select f).ToList(); DateTime cutoff = DateTime.Now.AddDays(-30.0); foreach (FileInfo item in list.Where((FileInfo f) => f.LastWriteTime < cutoff).ToList()) { try { item.Delete(); list.Remove(item); } catch { } } long num = list.Sum((FileInfo f) => f.Length); while (num > 524288000 && list.Count > 0) { FileInfo fileInfo = list[0]; num -= fileInfo.Length; try { fileInfo.Delete(); } catch { } list.RemoveAt(0); } } catch (Exception ex) { LogService.Warn("이미지 캐시 정리 실패: " + ex.Message); } } private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth) { if (src.PixelWidth <= maxWidth) { return src; } double num = (double)maxWidth / (double)src.PixelWidth; return new TransformedBitmap(src, new ScaleTransform(num, num)); } private static string? ImageToBase64(BitmapSource? img) { if (img == null) { return null; } try { PngBitmapEncoder pngBitmapEncoder = new PngBitmapEncoder(); pngBitmapEncoder.Frames.Add(BitmapFrame.Create(img)); using MemoryStream memoryStream = new MemoryStream(); pngBitmapEncoder.Save(memoryStream); return Convert.ToBase64String(memoryStream.ToArray()); } catch { return null; } } private static BitmapSource? Base64ToImage(string? base64) { if (string.IsNullOrEmpty(base64)) { return null; } try { byte[] buffer = Convert.FromBase64String(base64); using MemoryStream bitmapStream = new MemoryStream(buffer); BitmapDecoder bitmapDecoder = BitmapDecoder.Create(bitmapStream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); return bitmapDecoder.Frames[0]; } catch { return null; } } [DllImport("user32.dll", SetLastError = true)] private static extern bool AddClipboardFormatListener(nint hwnd); [DllImport("user32.dll", SetLastError = true)] private static extern bool RemoveClipboardFormatListener(nint hwnd); }