582 lines
14 KiB
C#
582 lines
14 KiB
C#
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<ClipboardEntry> _history = new List<ClipboardEntry>();
|
|
|
|
public IReadOnlyList<ClipboardEntry> 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<SavedClipEntry> list = JsonSerializer.Deserialize<List<SavedClipEntry>>(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<SavedClipEntry> 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<SavedClipEntry> 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<SavedClipEntry> 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<FileInfo> 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);
|
|
}
|