using System.IO; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Interop; using System.Windows.Media.Imaging; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 클립보드 변경을 감지하여 히스토리를 관리합니다. /// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다. /// public class ClipboardHistoryService : IDisposable { private const int WM_CLIPBOARDUPDATE = 0x031D; 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"); /// 원본 이미지 캐시 폴더 (%APPDATA%\AxCopilot\clipboard_images\) private static readonly string ImageCachePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "clipboard_images"); private const long MaxCacheSizeBytes = 500 * 1024 * 1024; // 500MB private const int MaxCacheAgeDays = 30; private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false, PropertyNameCaseInsensitive = true }; private readonly SettingsService _settings; private HwndSource? _msgSource; private readonly object _lock = new(); private volatile bool _ignoreNext; // 자체 클립보드 조작 시 히스토리 추가 방지 private uint _lastClipboardSequenceNumber; private bool _disposed; private readonly List _history = new(); public IReadOnlyList History { get { lock (_lock) return _history.ToList(); } } public event EventHandler? HistoryChanged; public ClipboardHistoryService(SettingsService settings) { _settings = settings; } /// 메시지 창을 생성하고 클립보드 알림을 등록합니다. public void Initialize() { if (_disposed) return; if (_msgSource != null) return; // 이미 초기화됨 if (!_settings.Settings.ClipboardHistory.Enabled) return; // 저장된 히스토리 복원 (텍스트 항목만) LoadHistory(); Application.Current.Dispatcher.Invoke(() => { var sourceParams = new HwndSourceParameters("axClipboardMonitor") { Width = 0, Height = 0, ParentWindow = new IntPtr(-3), // HWND_MESSAGE — 화면에 표시 안 됨 WindowStyle = 0, ExtendedWindowStyle = 0 }; _msgSource = new HwndSource(sourceParams); _msgSource.AddHook(WndProc); AddClipboardFormatListener(_msgSource.Handle); LogService.Info("클립보드 히스토리 서비스 시작"); }); } /// 자체 클립보드 조작(히스토리 붙여넣기) 시 히스토리가 중복 추가되지 않도록 플래그 설정. public void SuppressNextCapture() => _ignoreNext = true; /// 항목을 사용했을 때 CopiedAt을 현재 시각으로 갱신하고 목록 맨 위로 이동합니다. public void PromoteEntry(ClipboardEntry entry) { lock (_lock) { _history.Remove(entry); var updated = new ClipboardEntry(entry.Text, DateTime.Now) { Image = entry.Image, OriginalImagePath = entry.OriginalImagePath, IsPinned = entry.IsPinned, Category = entry.Category, }; _history.Insert(0, updated); } HistoryChanged?.Invoke(this, EventArgs.Empty); _ = SaveHistoryAsync(); } public void ClearHistory() { lock (_lock) _history.Clear(); HistoryChanged?.Invoke(this, EventArgs.Empty); _ = SaveHistoryAsync(); } /// 항목의 핀 고정을 토글합니다. public void TogglePin(ClipboardEntry entry) { lock (_lock) { if (!entry.IsPinned) { // 최대 핀 개수 체크 var maxPins = _settings.Settings.Launcher.MaxPinnedClipboardItems; var currentPins = _history.Count(e => e.IsPinned); if (currentPins >= maxPins) return; // 최대 도달 시 무시 } entry.IsPinned = !entry.IsPinned; } HistoryChanged?.Invoke(this, EventArgs.Empty); _ = SaveHistoryAsync(); } /// 텍스트 내용에서 카테고리를 자동 감지합니다. private static string DetectCategory(string text) { var trimmed = text.Trim(); if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) return "URL"; if (trimmed.StartsWith("\\\\") || (trimmed.Length >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/'))) return "경로"; if (trimmed.Contains('{') && trimmed.Contains('}') || trimmed.Contains("function ") || trimmed.Contains("class ") || trimmed.Contains("public ") || trimmed.Contains("private ") || trimmed.Contains("def ") || trimmed.Contains("import ") || trimmed.Contains("using ")) return "코드"; return "일반"; } /// /// 클립보드 감지가 동작하지 않을 때 강제 재시작합니다. /// Dispose 후 호출하면 내부 상태를 초기화하고 클립보드 모니터링을 재개합니다. /// public void Reinitialize() { _disposed = false; _msgSource = null; Initialize(); LogService.Info("클립보드 히스토리 서비스 재초기화 완료"); } public void Dispose() { if (_disposed) return; _disposed = true; if (_msgSource != null) { RemoveClipboardFormatListener(_msgSource.Handle); _msgSource.Dispose(); _msgSource = null; } // 종료 시 히스토리 저장 (동기) SaveHistorySync(); } // ─── 히스토리 영속성 (DPAPI 암호화) ───────────────────────────────────── // Windows DPAPI(DataProtectionScope.CurrentUser)를 사용하여 // 현재 Windows 사용자 계정에서만 복호화 가능하도록 합니다. // 이 앱 외부에서는 파일 내용을 읽을 수 없습니다. private void LoadHistory() { try { // 구버전 평문 파일 → 암호화 파일 마이그레이션 if (!File.Exists(HistoryPath) && File.Exists(LegacyPath)) { MigrateLegacyFile(); } if (!File.Exists(HistoryPath)) return; var encrypted = File.ReadAllBytes(HistoryPath); var plain = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser); var json = Encoding.UTF8.GetString(plain); var saved = JsonSerializer.Deserialize>(json, JsonOpts); if (saved == null) return; int max = _settings.Settings.ClipboardHistory.MaxItems; lock (_lock) { foreach (var s in saved.Take(max)) { if (!string.IsNullOrEmpty(s.ImageBase64)) { var img = Base64ToImage(s.ImageBase64); if (img != null) { _history.Add(new ClipboardEntry("", s.CopiedAt) { Image = img, OriginalImagePath = s.OriginalImagePath, IsPinned = s.IsPinned, Category = s.Category, }); continue; } } _history.Add(new ClipboardEntry(s.Text, s.CopiedAt) { IsPinned = s.IsPinned, Category = s.Category }); } } // 시작 시 이미지 캐시 정리 Task.Run(CleanupImageCache); LogService.Info($"클립보드 히스토리 {_history.Count}개 복원 (암호화)"); } catch (Exception ex) { LogService.Warn($"클립보드 히스토리 로드 실패: {ex.Message}"); } } private void MigrateLegacyFile() { try { var json = File.ReadAllText(LegacyPath); var plain = Encoding.UTF8.GetBytes(json); var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!); File.WriteAllBytes(HistoryPath, encrypted); File.Delete(LegacyPath); // 평문 파일 삭제 LogService.Info("클립보드 히스토리 평문→암호화 마이그레이션 완료"); } catch (Exception ex) { LogService.Warn($"클립보드 히스토리 마이그레이션 실패: {ex.Message}"); } } private async Task SaveHistoryAsync() { try { var snapshot = BuildSnapshot(); var json = JsonSerializer.Serialize(snapshot, JsonOpts); var plain = Encoding.UTF8.GetBytes(json); var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!); await File.WriteAllBytesAsync(HistoryPath, encrypted).ConfigureAwait(false); } catch (Exception ex) { LogService.Warn($"클립보드 히스토리 저장 실패: {ex.Message}"); } } private void SaveHistorySync() { try { var snapshot = BuildSnapshot(); var json = JsonSerializer.Serialize(snapshot, JsonOpts); var plain = Encoding.UTF8.GetBytes(json); var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!); File.WriteAllBytes(HistoryPath, encrypted); } catch { /* 종료 시 실패 무시 */ } } private List BuildSnapshot() { lock (_lock) { return _history.Select(e => { if (e.IsText) return new SavedClipEntry { Text = e.Text, CopiedAt = e.CopiedAt, IsPinned = e.IsPinned, Category = e.Category }; var b64 = ImageToBase64(e.Image); return new SavedClipEntry { CopiedAt = e.CopiedAt, ImageBase64 = b64, OriginalImagePath = e.OriginalImagePath, IsPinned = e.IsPinned, Category = e.Category, }; }).ToList(); } } // System.Text.Json 역직렬화를 위해 기본 생성자 + 프로퍼티 형태로 선언 private class SavedClipEntry { public string Text { get; set; } = ""; public DateTime CopiedAt { get; set; } /// 이미지 썸네일 PNG 바이트 (Base64 인코딩). null이면 텍스트 항목. public string? ImageBase64 { get; set; } /// 원본 이미지 파일 경로 (clipboard_images 폴더). public string? OriginalImagePath { get; set; } public bool IsPinned { get; set; } public string Category { get; set; } = "일반"; } // ─── 내부 ────────────────────────────────────────────────────────────── private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == WM_CLIPBOARDUPDATE) { OnClipboardUpdate(); handled = false; } return IntPtr.Zero; } private void OnClipboardUpdate() { var sequence = GetClipboardSequenceNumber(); if (_ignoreNext) { _ignoreNext = false; _lastClipboardSequenceNumber = sequence; return; } if (sequence != 0 && sequence == _lastClipboardSequenceNumber) return; _lastClipboardSequenceNumber = sequence; if (!_settings.Settings.ClipboardHistory.Enabled) return; Application.Current.Dispatcher.Invoke(() => { try { ClipboardEntry? entry = null; // ─── 텍스트 ──────────────────────────────────────────────────── if (Clipboard.ContainsText()) { var text = Clipboard.GetText(); if (string.IsNullOrWhiteSpace(text)) return; if (text.Length > 10_000) return; // 제외 패턴 검사 foreach (var pattern in _settings.Settings.ClipboardHistory.ExcludePatterns) { try { if (Regex.IsMatch(text.Trim(), pattern, RegexOptions.None, TimeSpan.FromMilliseconds(200))) return; } catch { /* 잘못된 패턴 무시 */ } } var category = _settings.Settings.Launcher.EnableClipboardAutoCategory ? DetectCategory(text) : "일반"; entry = new ClipboardEntry(text, DateTime.Now) { Category = category }; } // ─── 이미지 ──────────────────────────────────────────────────── else if (Clipboard.ContainsImage()) { var src = Clipboard.GetImage(); if (src == null) return; // 원본 이미지를 캐시 폴더에 PNG로 저장 var originalPath = SaveOriginalImage(src); // 표시용 썸네일 (최대 80px 폭) var thumb = CreateThumbnail(src, 80); entry = new ClipboardEntry("", DateTime.Now) { Image = thumb, OriginalImagePath = originalPath, }; } if (entry == null) return; lock (_lock) { // 텍스트 중복 제거 if (entry.IsText) { if (_history.Count > 0 && _history[0].Text == entry.Text) return; _history.RemoveAll(e => e.IsText && e.Text == entry.Text); } _history.Insert(0, entry); int max = _settings.Settings.ClipboardHistory.MaxItems; while (_history.Count > max) { // 핀 고정 항목은 삭제 보호 — 뒤에서부터 핀 아닌 항목 제거 var removeIdx = _history.FindLastIndex(e => !e.IsPinned); if (removeIdx >= 0) _history.RemoveAt(removeIdx); else break; // 전부 핀이면 중단 } } HistoryChanged?.Invoke(this, EventArgs.Empty); _ = SaveHistoryAsync(); } catch (Exception ex) { LogService.Warn($"클립보드 캡처 실패: {ex.Message}"); } }); } /// 원본 이미지를 캐시 폴더에 PNG로 저장합니다. private static string? SaveOriginalImage(BitmapSource src) { try { Directory.CreateDirectory(ImageCachePath); var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; var filePath = Path.Combine(ImageCachePath, fileName); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(src)); using var fs = new FileStream(filePath, FileMode.Create); encoder.Save(fs); return filePath; } 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 { var bi = new BitmapImage(); bi.BeginInit(); bi.CacheOption = BitmapCacheOption.OnLoad; bi.UriSource = new Uri(path, UriKind.Absolute); bi.EndInit(); bi.Freeze(); return bi; } catch { return null; } } /// 이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제). public static void CleanupImageCache() { try { if (!Directory.Exists(ImageCachePath)) return; var files = new DirectoryInfo(ImageCachePath) .GetFiles("clip_*.png") .OrderBy(f => f.LastWriteTime) .ToList(); // 30일 초과 파일 삭제 var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays); foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList()) { try { f.Delete(); files.Remove(f); } catch { } } // 500MB 초과 시 오래된 파일부터 삭제 var totalSize = files.Sum(f => f.Length); while (totalSize > MaxCacheSizeBytes && files.Count > 0) { var oldest = files[0]; totalSize -= oldest.Length; try { oldest.Delete(); } catch { } files.RemoveAt(0); } } catch (Exception ex) { LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}"); } } private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth) { if (src.PixelWidth <= maxWidth) return src; var scale = (double)maxWidth / src.PixelWidth; return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale)); } private static string? ImageToBase64(BitmapSource? img) { if (img == null) return null; try { var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(img)); using var ms = new MemoryStream(); encoder.Save(ms); return Convert.ToBase64String(ms.ToArray()); } catch { return null; } } private static BitmapSource? Base64ToImage(string? base64) { if (string.IsNullOrEmpty(base64)) return null; try { var bytes = Convert.FromBase64String(base64); using var ms = new MemoryStream(bytes); var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); return decoder.Frames[0]; } catch { return null; } } // ─── P/Invoke ────────────────────────────────────────────────────────── [DllImport("user32.dll", SetLastError = true)] private static extern bool AddClipboardFormatListener(IntPtr hwnd); [DllImport("user32.dll", SetLastError = true)] private static extern bool RemoveClipboardFormatListener(IntPtr hwnd); [DllImport("user32.dll")] private static extern uint GetClipboardSequenceNumber(); } /// 클립보드 히스토리 단일 항목. 텍스트 또는 이미지 중 하나를 담습니다. public record ClipboardEntry(string Text, DateTime CopiedAt) { /// 이미지 항목의 표시용 썸네일 (텍스트 항목은 null) public BitmapSource? Image { get; init; } /// 원본 해상도 이미지 파일 경로 (clipboard_images 폴더). null이면 썸네일만 존재. public string? OriginalImagePath { get; init; } /// 핀 고정 여부 (핀 항목은 삭제되지 않고 상단에 표시) public bool IsPinned { get; set; } /// 카테고리 (URL, 코드, 경로, 일반) public string Category { get; set; } = "일반"; /// 텍스트 항목 여부 public bool IsText => Image == null; /// UI 표시용 첫 줄 미리보기 (최대 80자) public string Preview { get { if (!IsText) return "[이미지]"; var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ "); return line.Length > 80 ? line[..77] + "…" : line; } } /// 복사 시각 상대 표시 public string RelativeTime { get { var diff = DateTime.Now - CopiedAt; if (diff.TotalSeconds < 60) return "방금 전"; if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전"; if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전"; return CopiedAt.ToString("MM/dd HH:mm"); } } }