Files
AX-Copilot-Codex/src/AxCopilot/Services/ClipboardHistoryService.cs
lacvet a027ea4f9a
Some checks failed
Release Gate / gate (push) Has been cancelled
재구성 AX Agent 설정과 채팅 UI를 Claude형 구조로
2026-04-04 17:48:51 +09:00

592 lines
22 KiB
C#

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;
/// <summary>
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
/// </summary>
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");
/// <summary>원본 이미지 캐시 폴더 (%APPDATA%\AxCopilot\clipboard_images\)</summary>
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<ClipboardEntry> _history = new();
public IReadOnlyList<ClipboardEntry> History
{
get { lock (_lock) return _history.ToList(); }
}
public event EventHandler? HistoryChanged;
public ClipboardHistoryService(SettingsService settings)
{
_settings = settings;
}
/// <summary>메시지 창을 생성하고 클립보드 알림을 등록합니다.</summary>
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("클립보드 히스토리 서비스 시작");
});
}
/// <summary>자체 클립보드 조작(히스토리 붙여넣기) 시 히스토리가 중복 추가되지 않도록 플래그 설정.</summary>
public void SuppressNextCapture() => _ignoreNext = true;
/// <summary>항목을 사용했을 때 CopiedAt을 현재 시각으로 갱신하고 목록 맨 위로 이동합니다.</summary>
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();
}
/// <summary>항목의 핀 고정을 토글합니다.</summary>
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();
}
/// <summary>텍스트 내용에서 카테고리를 자동 감지합니다.</summary>
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 "일반";
}
/// <summary>
/// 클립보드 감지가 동작하지 않을 때 강제 재시작합니다.
/// Dispose 후 호출하면 내부 상태를 초기화하고 클립보드 모니터링을 재개합니다.
/// </summary>
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<List<SavedClipEntry>>(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<SavedClipEntry> 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; }
/// <summary>이미지 썸네일 PNG 바이트 (Base64 인코딩). null이면 텍스트 항목.</summary>
public string? ImageBase64 { get; set; }
/// <summary>원본 이미지 파일 경로 (clipboard_images 폴더).</summary>
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}");
}
});
}
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
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;
}
}
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
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; }
}
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
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();
}
/// <summary>클립보드 히스토리 단일 항목. 텍스트 또는 이미지 중 하나를 담습니다.</summary>
public record ClipboardEntry(string Text, DateTime CopiedAt)
{
/// <summary>이미지 항목의 표시용 썸네일 (텍스트 항목은 null)</summary>
public BitmapSource? Image { get; init; }
/// <summary>원본 해상도 이미지 파일 경로 (clipboard_images 폴더). null이면 썸네일만 존재.</summary>
public string? OriginalImagePath { get; init; }
/// <summary>핀 고정 여부 (핀 항목은 삭제되지 않고 상단에 표시)</summary>
public bool IsPinned { get; set; }
/// <summary>카테고리 (URL, 코드, 경로, 일반)</summary>
public string Category { get; set; } = "일반";
/// <summary>텍스트 항목 여부</summary>
public bool IsText => Image == null;
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자)</summary>
public string Preview
{
get
{
if (!IsText) return "[이미지]";
var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ ");
return line.Length > 80 ? line[..77] + "…" : line;
}
}
/// <summary>복사 시각 상대 표시</summary>
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");
}
}
}