Initial commit to new repository
This commit is contained in:
49
src/AxCopilot/Core/BulkObservableCollection.cs
Normal file
49
src/AxCopilot/Core/BulkObservableCollection.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// ReplaceAll 호출 시 단일 Reset 이벤트만 발생시켜 WPF ItemsControl 레이아웃 재계산을 최소화합니다.
|
||||
/// 일반 Add/Remove/Clear는 기존 ObservableCollection과 동일하게 동작합니다.
|
||||
/// </summary>
|
||||
public sealed class BulkObservableCollection<T> : ObservableCollection<T>
|
||||
{
|
||||
private bool _suppressNotification;
|
||||
|
||||
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (!_suppressNotification)
|
||||
base.OnCollectionChanged(e);
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
if (!_suppressNotification)
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 항목을 모두 지우고 새 항목으로 교체합니다.
|
||||
/// CollectionChanged(Reset) 이벤트를 단 한 번만 발생시켜 WPF 레이아웃 1회만 갱신합니다.
|
||||
/// </summary>
|
||||
public void ReplaceAll(IEnumerable<T> items)
|
||||
{
|
||||
_suppressNotification = true;
|
||||
try
|
||||
{
|
||||
Items.Clear();
|
||||
foreach (var item in items)
|
||||
Items.Add(item);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressNotification = false;
|
||||
}
|
||||
// Count / Item[] 프로퍼티 변경 알림 + 단일 Reset 이벤트
|
||||
OnPropertyChanged(new PropertyChangedEventArgs("Count"));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
}
|
||||
217
src/AxCopilot/Core/CommandResolver.cs
Normal file
217
src/AxCopilot/Core/CommandResolver.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 입력된 텍스트를 파싱하여 적절한 ActionHandler로 라우팅합니다.
|
||||
/// Prefix 기반 라우팅 테이블을 관리합니다.
|
||||
/// </summary>
|
||||
public class CommandResolver
|
||||
{
|
||||
private readonly FuzzyEngine _fuzzy;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly Dictionary<string, IActionHandler> _handlers = new();
|
||||
/// <summary>Prefix = null 핸들러 목록 — 모든 쿼리에 병렬 실행</summary>
|
||||
private readonly List<IActionHandler> _fuzzyHandlers = new();
|
||||
|
||||
public CommandResolver(FuzzyEngine fuzzy, SettingsService settings)
|
||||
{
|
||||
_fuzzy = fuzzy;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 핸들러를 등록합니다. 플러그인 로드 시에도 이 메서드를 호출합니다.
|
||||
/// </summary>
|
||||
public void RegisterHandler(IActionHandler handler)
|
||||
{
|
||||
// Prefix 없는 핸들러 → 모든 쿼리에 부가 결과 제공 (예: BookmarkHandler)
|
||||
if (handler.Prefix == null)
|
||||
{
|
||||
_fuzzyHandlers.Add(handler);
|
||||
LogService.Info($"FuzzyHandler 등록: name='{handler.Metadata.Name}'");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_handlers.ContainsKey(handler.Prefix))
|
||||
LogService.Warn($"Prefix '{handler.Prefix}' 중복 등록: " +
|
||||
$"'{handler.Metadata.Name}'이 기존 핸들러를 덮어씁니다.");
|
||||
|
||||
_handlers[handler.Prefix] = handler;
|
||||
LogService.Info($"Handler 등록: prefix='{handler.Prefix}', name='{handler.Metadata.Name}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력 텍스트를 분석하여 결과 목록을 반환합니다.
|
||||
/// </summary>
|
||||
public async Task<IEnumerable<SDK.LauncherItem>> ResolveAsync(string input, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return Enumerable.Empty<SDK.LauncherItem>();
|
||||
|
||||
// 1. Prefix 기반 라우팅
|
||||
foreach (var (prefix, handler) in _handlers)
|
||||
{
|
||||
if (input.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var query = input.Length > prefix.Length
|
||||
? input[prefix.Length..].Trim()
|
||||
: "";
|
||||
|
||||
try
|
||||
{
|
||||
return await handler.GetItemsAsync(query, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"Handler '{handler.Metadata.Name}' 오류: {ex.Message}");
|
||||
return [new SDK.LauncherItem($"오류: {ex.Message}", handler.Metadata.Name, null, null)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행
|
||||
var maxResults = _settings.Settings.Launcher.MaxResults;
|
||||
|
||||
// Path 기반 중복 제거: 같은 경로의 항목이 여러 키워드로 매칭될 때 첫 번째만 표시
|
||||
var seenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// SortByUsage에 lazy 시퀀스를 직접 전달 → 중간 ToList 1회 제거
|
||||
var fuzzyItems = UsageRankingService.SortByUsage(
|
||||
_fuzzy.Search(input, maxResults * 2) // 중복 제거 여유분
|
||||
.Where(r => seenPaths.Add(r.Entry.Path)) // Path가 처음 등장할 때만 통과
|
||||
.Take(maxResults)
|
||||
.Select(r => new SDK.LauncherItem(
|
||||
r.Entry.DisplayName,
|
||||
r.Entry.Type == IndexEntryType.Alias ? r.Entry.AliasType switch
|
||||
{
|
||||
"url" => "URL 단축키",
|
||||
"batch" => "명령 단축키",
|
||||
_ => r.Entry.Path
|
||||
} : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기",
|
||||
null,
|
||||
r.Entry,
|
||||
Symbol: r.Entry.Type switch
|
||||
{
|
||||
IndexEntryType.App => Symbols.App,
|
||||
IndexEntryType.Folder => Symbols.Folder,
|
||||
IndexEntryType.Alias => r.Entry.AliasType switch
|
||||
{
|
||||
"url" => Symbols.Globe,
|
||||
"batch" => Symbols.Terminal,
|
||||
_ => Symbols.Plugin
|
||||
},
|
||||
_ => Symbols.File
|
||||
}
|
||||
)),
|
||||
item => (item.Data as IndexEntry)?.Path
|
||||
).ToList(); // 단일 ToList로 List<LauncherItem> 확정
|
||||
|
||||
// null-prefix 핸들러 결과를 뒤에 추가 (최대 3개씩)
|
||||
if (_fuzzyHandlers.Count > 0)
|
||||
{
|
||||
var extraTasks = _fuzzyHandlers
|
||||
.Select(h => SafeGetItemsAsync(h, input, ct))
|
||||
.ToList();
|
||||
await Task.WhenAll(extraTasks);
|
||||
|
||||
foreach (var task in extraTasks)
|
||||
{
|
||||
if (task.IsCompletedSuccessfully)
|
||||
fuzzyItems.AddRange(task.Result.Take(3));
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyItems;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택된 항목을 실행합니다.
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync(SDK.LauncherItem item, string lastInput, CancellationToken ct)
|
||||
{
|
||||
// Prefix 기반 실행
|
||||
foreach (var (prefix, handler) in _handlers)
|
||||
{
|
||||
if (lastInput.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 명령어 사용 통계 기록 (prefix + 첫 단어)
|
||||
var q = lastInput.Length > prefix.Length
|
||||
? lastInput[prefix.Length..].Trim().Split(' ')[0]
|
||||
: "";
|
||||
var cmdKey = string.IsNullOrEmpty(q) ? prefix : $"{prefix}{q}";
|
||||
UsageStatisticsService.RecordCommandUsage(cmdKey);
|
||||
|
||||
await handler.ExecuteAsync(item, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// null-prefix 핸들러 결과 실행 (Data가 string = URL인 경우)
|
||||
if (item.Data is string urlData && urlData.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (OperationModePolicy.IsInternal(_settings.Settings))
|
||||
{
|
||||
LogService.Warn($"사내모드 외부 URL 실행 차단: {urlData}");
|
||||
return;
|
||||
}
|
||||
await ExecuteNullPrefixAsync(item, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fuzzy 결과 실행 (IndexEntry 기반)
|
||||
if (item.Data is IndexEntry entry)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
try
|
||||
{
|
||||
// Process.Start를 먼저 실행하여 체감 속도 확보
|
||||
await Task.Run(() =>
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(expanded)
|
||||
{
|
||||
UseShellExecute = true
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"실행 실패: {expanded} - {ex.Message}");
|
||||
}
|
||||
|
||||
// 통계 기록은 파일 열기 이후 비동기로
|
||||
_ = Task.Run(() => UsageRankingService.RecordExecution(entry.Path));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, IActionHandler> RegisteredHandlers => _handlers;
|
||||
|
||||
// null-prefix 핸들러 실행 (ExecuteAsync 라우팅)
|
||||
public async Task ExecuteNullPrefixAsync(SDK.LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
foreach (var handler in _fuzzyHandlers)
|
||||
{
|
||||
try { await handler.ExecuteAsync(item, ct); return; }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"FuzzyHandler '{handler.Metadata.Name}' 실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<SDK.LauncherItem>> SafeGetItemsAsync(
|
||||
IActionHandler handler, string query, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await handler.GetItemsAsync(query, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"FuzzyHandler '{handler.Metadata.Name}' 오류: {ex.Message}");
|
||||
return Enumerable.Empty<SDK.LauncherItem>();
|
||||
}
|
||||
}
|
||||
}
|
||||
332
src/AxCopilot/Core/ContextManager.cs
Normal file
332
src/AxCopilot/Core/ContextManager.cs
Normal file
@@ -0,0 +1,332 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 창의 HWND, Rect, 프로세스 경로를 수집하고 복원합니다 (The Shifter).
|
||||
/// </summary>
|
||||
public class ContextManager
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public ContextManager(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
|
||||
// 모니터 구성 변경 감지는 MainWindow의 WndProc에서 WM_DISPLAYCHANGE를 통해 처리
|
||||
}
|
||||
|
||||
// ─── Snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 현재 화면에 열린 모든 업무용 창을 캡처하여 프로필로 저장합니다.
|
||||
/// </summary>
|
||||
public WorkspaceProfile CaptureProfile(string name)
|
||||
{
|
||||
var snapshots = new List<WindowSnapshot>();
|
||||
var monitorMap = BuildMonitorMap();
|
||||
|
||||
EnumWindows((hWnd, _) =>
|
||||
{
|
||||
if (!IsWindowVisible(hWnd)) return true;
|
||||
if (IsIconic(hWnd)) return true; // 최소화된 창 제외 여부는 설정으로 조정 가능
|
||||
|
||||
var title = GetWindowTitle(hWnd);
|
||||
if (string.IsNullOrWhiteSpace(title)) return true;
|
||||
|
||||
// 작업표시줄, 바탕화면 등 시스템 창 제외
|
||||
if (IsSystemWindow(hWnd)) return true;
|
||||
|
||||
var exePath = GetProcessPath(hWnd);
|
||||
if (string.IsNullOrEmpty(exePath)) return true;
|
||||
|
||||
GetWindowRect(hWnd, out RECT rect);
|
||||
GetWindowPlacement(hWnd, out WINDOWPLACEMENT placement);
|
||||
|
||||
var showCmd = placement.showCmd switch
|
||||
{
|
||||
1 => "Normal",
|
||||
2 => "Minimized",
|
||||
3 => "Maximized",
|
||||
_ => "Normal"
|
||||
};
|
||||
|
||||
int monitorIndex = GetMonitorIndex(hWnd, monitorMap);
|
||||
|
||||
snapshots.Add(new WindowSnapshot
|
||||
{
|
||||
Exe = exePath,
|
||||
Title = title,
|
||||
Rect = new WindowRect
|
||||
{
|
||||
X = rect.Left,
|
||||
Y = rect.Top,
|
||||
Width = rect.Right - rect.Left,
|
||||
Height = rect.Bottom - rect.Top
|
||||
},
|
||||
ShowCmd = showCmd,
|
||||
Monitor = monitorIndex
|
||||
});
|
||||
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
|
||||
var profile = new WorkspaceProfile
|
||||
{
|
||||
Name = name,
|
||||
Windows = snapshots,
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
|
||||
// settings.json에 저장
|
||||
var existing = _settings.Settings.Profiles.FirstOrDefault(p =>
|
||||
p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing != null)
|
||||
_settings.Settings.Profiles.Remove(existing);
|
||||
_settings.Settings.Profiles.Add(profile);
|
||||
_settings.Save();
|
||||
|
||||
LogService.Info($"프로필 '{name}' 저장 완료: {snapshots.Count}개 창");
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ─── Restore ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 저장된 프로필을 복원합니다.
|
||||
/// </summary>
|
||||
public async Task<RestoreResult> RestoreProfileAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var profile = _settings.Settings.Profiles
|
||||
.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (profile == null)
|
||||
return new RestoreResult(false, $"프로필 '{name}'을 찾을 수 없습니다.");
|
||||
|
||||
var results = new List<string>();
|
||||
var monitorCount = GetMonitorCount();
|
||||
|
||||
foreach (var snapshot in profile.Windows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. 실행 중인 창 찾기
|
||||
var hWnd = FindMatchingWindow(snapshot);
|
||||
|
||||
// 2. 창이 없으면 EXE 실행 후 대기
|
||||
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe))
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true });
|
||||
hWnd = await WaitForWindowAsync(snapshot.Exe, TimeSpan.FromSeconds(3), ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add($"⚠ {snapshot.Title}: 실행 실패 ({ex.Message})");
|
||||
LogService.Warn($"앱 실행 실패: {snapshot.Exe} - {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
results.Add($"⏭ {snapshot.Title}: 창 없음, 건너뜀");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 모니터 불일치 처리
|
||||
if (snapshot.Monitor >= monitorCount)
|
||||
{
|
||||
var policy = _settings.Settings.MonitorMismatch;
|
||||
if (policy == "skip")
|
||||
{
|
||||
results.Add($"⏭ {snapshot.Title}: 모니터 불일치, 건너뜀");
|
||||
continue;
|
||||
}
|
||||
// "fit" 또는 "warn" → 첫 번째 모니터에 배치
|
||||
}
|
||||
|
||||
// 4. 창 위치/크기 복원
|
||||
try
|
||||
{
|
||||
ShowWindow(hWnd, snapshot.ShowCmd switch
|
||||
{
|
||||
"Maximized" => 3,
|
||||
"Minimized" => 2,
|
||||
_ => 9 // SW_RESTORE
|
||||
});
|
||||
|
||||
if (snapshot.ShowCmd == "Normal")
|
||||
{
|
||||
SetWindowPos(hWnd, IntPtr.Zero,
|
||||
snapshot.Rect.X, snapshot.Rect.Y,
|
||||
snapshot.Rect.Width, snapshot.Rect.Height,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
results.Add($"✓ {snapshot.Title}: 복원 완료");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add($"⚠ {snapshot.Title}: 복원 실패 ({ex.Message})");
|
||||
LogService.Warn($"창 복원 실패 (권한 문제 가능): {snapshot.Exe}");
|
||||
}
|
||||
}
|
||||
|
||||
LogService.Info($"프로필 '{name}' 복원: {string.Join(", ", results)}");
|
||||
return new RestoreResult(true, string.Join("\n", results));
|
||||
}
|
||||
|
||||
// ─── Profile Management ──────────────────────────────────────────────────
|
||||
|
||||
public bool DeleteProfile(string name)
|
||||
{
|
||||
var profile = _settings.Settings.Profiles
|
||||
.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (profile == null) return false;
|
||||
_settings.Settings.Profiles.Remove(profile);
|
||||
_settings.Save();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RenameProfile(string oldName, string newName)
|
||||
{
|
||||
var profile = _settings.Settings.Profiles
|
||||
.FirstOrDefault(p => p.Name.Equals(oldName, StringComparison.OrdinalIgnoreCase));
|
||||
if (profile == null) return false;
|
||||
profile.Name = newName;
|
||||
_settings.Save();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot)
|
||||
{
|
||||
IntPtr found = IntPtr.Zero;
|
||||
EnumWindows((hWnd, _) =>
|
||||
{
|
||||
var path = GetProcessPath(hWnd);
|
||||
if (string.Equals(path, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = hWnd;
|
||||
return false; // 첫 번째 매칭 창에서 중단
|
||||
}
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return found;
|
||||
}
|
||||
|
||||
private static async Task<IntPtr> WaitForWindowAsync(string exePath, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var hWnd = FindMatchingWindow(new WindowSnapshot { Exe = exePath });
|
||||
if (hWnd != IntPtr.Zero) return hWnd;
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static string GetWindowTitle(IntPtr hWnd)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
GetWindowText(hWnd, sb, sb.Capacity);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetProcessPath(IntPtr hWnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
GetWindowThreadProcessId(hWnd, out uint pid);
|
||||
if (pid == 0) return "";
|
||||
var proc = Process.GetProcessById((int)pid);
|
||||
return proc.MainModule?.FileName ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static bool IsSystemWindow(IntPtr hWnd)
|
||||
{
|
||||
var cls = new StringBuilder(256);
|
||||
GetClassName(hWnd, cls, cls.Capacity);
|
||||
var name = cls.ToString();
|
||||
return name is "Shell_TrayWnd" or "Progman" or "WorkerW" or "DV2ControlHost";
|
||||
}
|
||||
|
||||
private static Dictionary<IntPtr, int> BuildMonitorMap()
|
||||
{
|
||||
var monitors = new List<IntPtr>();
|
||||
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
|
||||
(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprc, IntPtr dwData) =>
|
||||
{
|
||||
monitors.Add(hMonitor);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
|
||||
return monitors
|
||||
.Select((hm, idx) => (hm, idx))
|
||||
.ToDictionary(t => t.hm, t => t.idx);
|
||||
}
|
||||
|
||||
private static int GetMonitorIndex(IntPtr hWnd, Dictionary<IntPtr, int> map)
|
||||
{
|
||||
var monitor = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST);
|
||||
return map.TryGetValue(monitor, out int idx) ? idx : 0;
|
||||
}
|
||||
|
||||
private static int GetMonitorCount()
|
||||
{
|
||||
int count = 0;
|
||||
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprc, IntPtr dwData) => { count++; return true; }, IntPtr.Zero);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── P/Invoke ────────────────────────────────────────────────────────────
|
||||
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct WINDOWPLACEMENT
|
||||
{
|
||||
public uint length, flags;
|
||||
public uint showCmd;
|
||||
public POINT ptMinPosition, ptMaxPosition;
|
||||
public RECT rcNormalPosition;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT { public int x, y; }
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprcMonitor, IntPtr dwData);
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
[DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll")] private static extern bool GetWindowPlacement(IntPtr hWnd, out WINDOWPLACEMENT lpwndpl);
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
[DllImport("user32.dll")] private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||
[DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||
}
|
||||
|
||||
public record RestoreResult(bool Success, string Message);
|
||||
420
src/AxCopilot/Core/FuzzyEngine.cs
Normal file
420
src/AxCopilot/Core/FuzzyEngine.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 부분 입력·오타도 유사 항목을 찾아주는 Fuzzy 검색 엔진.
|
||||
/// 한글 초성 검색, 자모 분리 검색, 비연속 매칭을 지원합니다.
|
||||
/// </summary>
|
||||
public class FuzzyEngine
|
||||
{
|
||||
private readonly IndexService _index;
|
||||
|
||||
public FuzzyEngine(IndexService index)
|
||||
{
|
||||
_index = index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 쿼리에 대해 유사도 순으로 정렬된 결과를 반환합니다.
|
||||
/// 300개 이상 항목은 PLINQ 병렬 처리로 검색 속도를 개선합니다.
|
||||
/// </summary>
|
||||
public IEnumerable<FuzzyResult> Search(string query, int maxResults = 7)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Enumerable.Empty<FuzzyResult>();
|
||||
|
||||
var normalized = query.Trim().ToLowerInvariant();
|
||||
var entries = _index.Entries;
|
||||
|
||||
// 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음
|
||||
bool queryHasKorean = false;
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
if ((c >= 0xAC00 && c <= 0xD7A3) || ChosungSet.Contains(c))
|
||||
{ queryHasKorean = true; break; }
|
||||
}
|
||||
|
||||
// 300개 초과 시 PLINQ 병렬 처리
|
||||
if (entries.Count > 300)
|
||||
{
|
||||
return entries.AsParallel()
|
||||
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
||||
.Where(r => r.Score > 0)
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(maxResults);
|
||||
}
|
||||
|
||||
return entries
|
||||
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
||||
.Where(r => r.Score > 0)
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(maxResults);
|
||||
}
|
||||
|
||||
/// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary>
|
||||
private static int CalculateScoreFast(string query, IndexEntry entry, bool queryHasKorean)
|
||||
{
|
||||
// 캐시가 없으면(구버전 호환) 기존 방식으로 폴백
|
||||
var targetLower = string.IsNullOrEmpty(entry.NameLower)
|
||||
? entry.Name.ToLowerInvariant()
|
||||
: entry.NameLower;
|
||||
|
||||
if (query.Length == 0) return 0;
|
||||
|
||||
if (targetLower == query) return 1000 + entry.Score;
|
||||
if (targetLower.StartsWith(query)) return 800 + entry.Score;
|
||||
if (targetLower.Contains(query)) return 600 + entry.Score;
|
||||
|
||||
// 순수 ASCII 쿼리 — 한글 검색 로직(자모·초성) 전체 스킵
|
||||
if (!queryHasKorean)
|
||||
{
|
||||
var fs = FuzzyMatch(query, targetLower);
|
||||
return fs > 0 ? fs + entry.Score : 0;
|
||||
}
|
||||
|
||||
// 자모 분리 검색 (캐시된 NameJamo 활용)
|
||||
var jamoScore = JamoContainsScoreFast(
|
||||
string.IsNullOrEmpty(entry.NameJamo) ? DecomposeToJamo(targetLower) : entry.NameJamo,
|
||||
query);
|
||||
if (jamoScore > 0) return jamoScore + entry.Score;
|
||||
|
||||
// 초성 검색 (캐시된 NameChosung 활용)
|
||||
var chosungScore = ChosungMatchScoreFast(
|
||||
string.IsNullOrEmpty(entry.NameChosung) ? null : entry.NameChosung,
|
||||
targetLower, query);
|
||||
if (chosungScore > 0) return chosungScore + entry.Score;
|
||||
|
||||
// Fuzzy 매칭
|
||||
var fuzzyScore = FuzzyMatch(query, targetLower);
|
||||
if (fuzzyScore > 0) return fuzzyScore + entry.Score;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 점수 계산: 정확 일치 > 시작 일치 > 포함 일치 > 자모 포함 > 초성 > Fuzzy
|
||||
/// </summary>
|
||||
internal static int CalculateScore(string query, string target, int baseScore)
|
||||
{
|
||||
if (query.Length == 0) return 0;
|
||||
|
||||
if (target == query) return 1000 + baseScore; // 완전 일치
|
||||
if (target.StartsWith(query)) return 800 + baseScore; // 시작 일치
|
||||
if (target.Contains(query)) return 600 + baseScore; // 부분 일치
|
||||
|
||||
// 한글 자모 분리 후 부분 일치 ("모장" → 메모장)
|
||||
var jamoScore = JamoContainsScore(target, query);
|
||||
if (jamoScore > 0) return jamoScore + baseScore;
|
||||
|
||||
// 한글 초성 검색 (순수 초성 + 혼합 쿼리 모두 지원)
|
||||
var chosungScore = ChosungMatchScore(target, query);
|
||||
if (chosungScore > 0) return chosungScore + baseScore;
|
||||
|
||||
// 문자 순서 포함 (Fuzzy)
|
||||
var fuzzyScore = FuzzyMatch(query, target);
|
||||
if (fuzzyScore > 0) return fuzzyScore + baseScore;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── Fuzzy Match ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 쿼리의 모든 문자가 target에 순서대로 포함되는지 확인 (subsequence)
|
||||
/// </summary>
|
||||
internal static int FuzzyMatch(string query, string target)
|
||||
{
|
||||
int qi = 0, ti = 0;
|
||||
int score = 0;
|
||||
int lastMatchIdx = -1;
|
||||
|
||||
while (qi < query.Length && ti < target.Length)
|
||||
{
|
||||
if (query[qi] == target[ti])
|
||||
{
|
||||
if (lastMatchIdx == ti - 1) score += 30; // 연속 매칭 보너스
|
||||
else score += 10; // 비연속 매칭
|
||||
|
||||
if (ti == 0) score += 15; // 시작 위치 보너스
|
||||
|
||||
lastMatchIdx = ti;
|
||||
qi++;
|
||||
}
|
||||
ti++;
|
||||
}
|
||||
|
||||
return qi == query.Length ? Math.Max(score, 50) : 0; // 최소 50점 보장
|
||||
}
|
||||
|
||||
// ─── 한글 자모 분리 ─────────────────────────────────────────────────────
|
||||
|
||||
private static readonly char[] Chosungs =
|
||||
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||
|
||||
/// <summary>초성 O(1) 조회용 HashSet — HasChosung/IsChosung/MixedMatch에서 사용.</summary>
|
||||
private static readonly HashSet<char> ChosungSet =
|
||||
new(['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']);
|
||||
|
||||
private static readonly char[] Jungsungs =
|
||||
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
|
||||
|
||||
private static readonly char[] Jongsungs =
|
||||
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||
|
||||
/// <summary>한글 음절을 자모로 분리 (초성+중성+종성). 비한글은 그대로 반환.</summary>
|
||||
internal static string DecomposeToJamo(string text)
|
||||
{
|
||||
var result = new System.Text.StringBuilder(text.Length * 3);
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (c >= 0xAC00 && c <= 0xD7A3)
|
||||
{
|
||||
int offset = c - 0xAC00;
|
||||
int cho = offset / (21 * 28);
|
||||
int jung = (offset % (21 * 28)) / 28;
|
||||
int jong = offset % 28;
|
||||
|
||||
result.Append(Chosungs[cho]);
|
||||
result.Append(Jungsungs[jung]);
|
||||
if (jong > 0) result.Append(Jongsungs[jong]);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Append(c);
|
||||
}
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>한글 음절에서 초성만 추출. 비한글은 '\0'.</summary>
|
||||
internal static char GetChosung(char hangul)
|
||||
{
|
||||
if (hangul < 0xAC00 || hangul > 0xD7A3) return '\0';
|
||||
int offset = hangul - 0xAC00;
|
||||
return Chosungs[offset / (21 * 28)];
|
||||
}
|
||||
|
||||
// ─── 자모 기반 포함 검색 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 쿼리를 자모 분리 후 target의 자모에 연속 부분 문자열로 포함되는지 확인.
|
||||
/// "모장" → "ㅁㅗㅈㅏㅇ" 이 "ㅁㅔㅁㅗㅈㅏㅇ"(메모장)에 포함 → 550점
|
||||
/// </summary>
|
||||
internal static int JamoContainsScore(string target, string query)
|
||||
{
|
||||
if (!HasKorean(query)) return 0;
|
||||
|
||||
var targetJamo = DecomposeToJamo(target);
|
||||
var queryJamo = DecomposeToJamo(query);
|
||||
|
||||
if (queryJamo.Length == 0 || targetJamo.Length == 0) return 0;
|
||||
|
||||
// 자모 연속 포함
|
||||
if (targetJamo.Contains(queryJamo))
|
||||
{
|
||||
// 시작 위치가 음절 경계(초성)에 가까울수록 높은 점수
|
||||
int idx = targetJamo.IndexOf(queryJamo);
|
||||
return idx == 0 ? 580 : 550;
|
||||
}
|
||||
|
||||
// 자모 subsequence 매칭 (비연속이지만 순서 유지)
|
||||
int qi = 0;
|
||||
for (int ti = 0; ti < targetJamo.Length && qi < queryJamo.Length; ti++)
|
||||
{
|
||||
if (queryJamo[qi] == targetJamo[ti]) qi++;
|
||||
}
|
||||
if (qi == queryJamo.Length) return 400;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── 초성 검색 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>쿼리에 초성 문자가 하나라도 포함되어 있는지.</summary>
|
||||
internal static bool HasChosung(string text) => text.Any(c => ChosungSet.Contains(c));
|
||||
|
||||
/// <summary>문자열의 모든 문자가 초성인지.</summary>
|
||||
internal static bool IsChosung(string text) => text.Length > 0 && text.All(c => ChosungSet.Contains(c));
|
||||
|
||||
/// <summary>문자열에 한글(완성형)이 포함되어 있는지.</summary>
|
||||
private static bool HasKorean(string text) => text.Any(c => c >= 0xAC00 && c <= 0xD7A3);
|
||||
|
||||
/// <summary>
|
||||
/// 초성 매칭 점수. 순수 초성 쿼리 + 혼합 쿼리(초성+완성형) 모두 지원.
|
||||
/// 비연속 매칭도 허용 ("ㅁㅊ" → 메모장 OK).
|
||||
/// </summary>
|
||||
internal static int ChosungMatchScore(string target, string query)
|
||||
{
|
||||
// 초성이 하나도 없으면 초성 검색 아님
|
||||
if (!HasChosung(query)) return 0;
|
||||
|
||||
// 타겟에서 각 글자의 초성 추출
|
||||
var targetChosungs = new List<char>();
|
||||
var targetChars = new List<char>();
|
||||
foreach (var c in target)
|
||||
{
|
||||
var cho = GetChosung(c);
|
||||
if (cho != '\0')
|
||||
{
|
||||
targetChosungs.Add(cho);
|
||||
targetChars.Add(c);
|
||||
}
|
||||
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))
|
||||
{
|
||||
targetChosungs.Add(c);
|
||||
targetChars.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChosungs.Count == 0) return 0;
|
||||
|
||||
// 순수 초성 쿼리 ("ㅁㅁㅈ", "ㅁㅊ")
|
||||
if (IsChosung(query))
|
||||
{
|
||||
// 연속 매칭 시도
|
||||
if (ContainsChosungConsecutive(targetChosungs, query)) return 520;
|
||||
// 비연속(subsequence) 매칭
|
||||
if (ContainsChosungSubsequence(targetChosungs, query)) return 480;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 혼합 쿼리 ("ㅁ장", "계ㅅ기") — 초성+완성형 혼합
|
||||
return MixedChosungMatch(targetChars, targetChosungs, query);
|
||||
}
|
||||
|
||||
/// <summary>연속 초성 매칭 (기존 로직 유지)</summary>
|
||||
private static bool ContainsChosungConsecutive(List<char> targetChosungs, string query)
|
||||
{
|
||||
for (int i = 0; i <= targetChosungs.Count - query.Length; i++)
|
||||
{
|
||||
bool match = true;
|
||||
for (int j = 0; j < query.Length; j++)
|
||||
{
|
||||
if (targetChosungs[i + j] != query[j]) { match = false; break; }
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>비연속 초성 매칭 (subsequence)</summary>
|
||||
private static bool ContainsChosungSubsequence(List<char> targetChosungs, string query)
|
||||
{
|
||||
int qi = 0;
|
||||
for (int ti = 0; ti < targetChosungs.Count && qi < query.Length; ti++)
|
||||
{
|
||||
if (targetChosungs[ti] == query[qi]) qi++;
|
||||
}
|
||||
return qi == query.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 혼합 쿼리 매칭: 초성은 초성끼리, 완성형은 완성형끼리 비교.
|
||||
/// "ㅁ장" → target[i]의 초성이 'ㅁ'이고, target[j>i]가 '장'인지.
|
||||
/// </summary>
|
||||
private static int MixedChosungMatch(List<char> targetChars, List<char> targetChosungs, string query)
|
||||
{
|
||||
int qi = 0, ti = 0;
|
||||
while (qi < query.Length && ti < targetChars.Count)
|
||||
{
|
||||
var qc = query[qi];
|
||||
if (ChosungSet.Contains(qc))
|
||||
{
|
||||
// 쿼리 문자가 초성 → 타겟 초성과 비교
|
||||
if (targetChosungs[ti] == qc) qi++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 쿼리 문자가 완성형 → 타겟 원본 문자와 비교
|
||||
if (targetChars[ti] == qc) qi++;
|
||||
}
|
||||
ti++;
|
||||
}
|
||||
return qi == query.Length ? 460 : 0;
|
||||
}
|
||||
|
||||
// ─── 캐시 기반 빠른 검색 메서드 ────────────────────────────────────────────
|
||||
|
||||
/// <summary>미리 계산된 자모 문자열을 사용하는 빠른 자모 포함 검색.</summary>
|
||||
private static int JamoContainsScoreFast(string targetJamo, string query)
|
||||
{
|
||||
if (!HasKorean(query)) return 0;
|
||||
|
||||
var queryJamo = DecomposeToJamo(query); // 쿼리는 짧으므로 매번 분해해도 빠름
|
||||
if (queryJamo.Length == 0 || targetJamo.Length == 0) return 0;
|
||||
|
||||
if (targetJamo.Contains(queryJamo))
|
||||
{
|
||||
int idx = targetJamo.IndexOf(queryJamo, StringComparison.Ordinal);
|
||||
return idx == 0 ? 580 : 550;
|
||||
}
|
||||
|
||||
int qi = 0;
|
||||
for (int ti = 0; ti < targetJamo.Length && qi < queryJamo.Length; ti++)
|
||||
{
|
||||
if (queryJamo[qi] == targetJamo[ti]) qi++;
|
||||
}
|
||||
return qi == queryJamo.Length ? 400 : 0;
|
||||
}
|
||||
|
||||
/// <summary>미리 계산된 초성 문자열을 사용하는 빠른 초성 검색.</summary>
|
||||
private static int ChosungMatchScoreFast(string? targetChosung, string targetLower, string query)
|
||||
{
|
||||
if (!HasChosung(query)) return 0;
|
||||
|
||||
if (IsChosung(query))
|
||||
{
|
||||
if (string.IsNullOrEmpty(targetChosung)) return 0;
|
||||
// 연속 매칭: 단순 Contains
|
||||
if (targetChosung.Contains(query, StringComparison.Ordinal)) return 520;
|
||||
// 비연속 매칭 (subsequence)
|
||||
int qi2 = 0;
|
||||
for (int ti2 = 0; ti2 < targetChosung.Length && qi2 < query.Length; ti2++)
|
||||
{
|
||||
if (targetChosung[ti2] == query[qi2]) qi2++;
|
||||
}
|
||||
if (qi2 == query.Length) return 480;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 혼합 쿼리(초성+완성형): List<char> 할당 없는 인라인 매칭
|
||||
{
|
||||
int qi2 = 0, ti2 = 0;
|
||||
while (qi2 < query.Length && ti2 < targetLower.Length)
|
||||
{
|
||||
var qc = query[qi2];
|
||||
var tc = targetLower[ti2];
|
||||
if (ChosungSet.Contains(qc))
|
||||
{
|
||||
// 쿼리가 초성 → 타겟 문자의 초성과 비교
|
||||
var cho = GetChosung(tc);
|
||||
if (cho == '\0' && ((tc >= 'a' && tc <= 'z') || (tc >= '0' && tc <= '9')))
|
||||
cho = tc; // 영문/숫자는 초성 = 자신
|
||||
if (cho == qc) qi2++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 쿼리가 완성형 → 타겟 원본 문자와 비교
|
||||
if (tc == qc) qi2++;
|
||||
}
|
||||
ti2++;
|
||||
}
|
||||
return qi2 == query.Length ? 460 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 하위 호환 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>기존 API 호환용 — ContainsChosung(연속+비연속)</summary>
|
||||
internal static bool ContainsChosung(string target, string chosungQuery)
|
||||
{
|
||||
var targetChosungs = target.Select(GetChosung).Where(c => c != '\0').ToList();
|
||||
if (targetChosungs.Count < chosungQuery.Length) return false;
|
||||
|
||||
return ContainsChosungConsecutive(targetChosungs, chosungQuery)
|
||||
|| ContainsChosungSubsequence(targetChosungs, chosungQuery);
|
||||
}
|
||||
}
|
||||
|
||||
public record FuzzyResult(IndexEntry Entry, int Score);
|
||||
127
src/AxCopilot/Core/HotkeyParser.cs
Normal file
127
src/AxCopilot/Core/HotkeyParser.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// "Alt+Space", "Ctrl+Shift+K" 형식의 핫키 문자열 파싱/포맷 유틸리티.
|
||||
/// </summary>
|
||||
public static class HotkeyParser
|
||||
{
|
||||
private static readonly Dictionary<string, int> _keyMap =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 특수키
|
||||
["Space"] = 0x20, ["Enter"] = 0x0D, ["Return"] = 0x0D,
|
||||
["Tab"] = 0x09, ["Esc"] = 0x1B, ["Escape"] = 0x1B,
|
||||
["Backspace"]= 0x08, ["Back"] = 0x08,
|
||||
["Delete"] = 0x2E, ["Del"] = 0x2E,
|
||||
["Insert"] = 0x2D, ["Ins"] = 0x2D,
|
||||
["Home"] = 0x24, ["End"] = 0x23,
|
||||
["PageUp"] = 0x21, ["PgUp"] = 0x21,
|
||||
["PageDown"] = 0x22, ["PgDn"] = 0x22,
|
||||
["PrintScreen"] = 0x2C, ["PrtSc"] = 0x2C, ["Snapshot"] = 0x2C,
|
||||
["Pause"] = 0x13, ["Break"] = 0x13,
|
||||
["ScrollLock"] = 0x91,
|
||||
// 방향키
|
||||
["Left"] = 0x25, ["Up"] = 0x26, ["Right"] = 0x27, ["Down"] = 0x28,
|
||||
// 기호
|
||||
["`"] = 0xC0, ["Grave"] = 0xC0,
|
||||
["-"] = 0xBD, ["="] = 0xBB,
|
||||
["["] = 0xDB, ["]"] = 0xDD,
|
||||
["\\"] = 0xDC, [";"] = 0xBA,
|
||||
["'"] = 0xDE, [","] = 0xBC,
|
||||
["."] = 0xBE, ["/"] = 0xBF,
|
||||
};
|
||||
|
||||
static HotkeyParser()
|
||||
{
|
||||
// A–Z
|
||||
for (char c = 'A'; c <= 'Z'; c++)
|
||||
_keyMap[c.ToString()] = c;
|
||||
// 0–9
|
||||
for (char c = '0'; c <= '9'; c++)
|
||||
_keyMap[c.ToString()] = c;
|
||||
// F1–F24
|
||||
for (int i = 1; i <= 24; i++)
|
||||
_keyMap[$"F{i}"] = 0x6F + i;
|
||||
// Numpad 0–9
|
||||
for (int i = 0; i <= 9; i++)
|
||||
_keyMap[$"Num{i}"] = 0x60 + i;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Alt+Space" → <see cref="HotkeyDefinition"/>. 실패 시 false 반환.
|
||||
/// </summary>
|
||||
public static bool TryParse(string hotkey, out HotkeyDefinition result)
|
||||
{
|
||||
result = default;
|
||||
if (string.IsNullOrWhiteSpace(hotkey)) return false;
|
||||
|
||||
var parts = hotkey.Split('+',
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
bool ctrl = false, alt = false, shift = false, win = false;
|
||||
int? vkCode = null;
|
||||
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (p.Equals("Ctrl", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Equals("Control", StringComparison.OrdinalIgnoreCase))
|
||||
{ ctrl = true; continue; }
|
||||
|
||||
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase))
|
||||
{ alt = true; continue; }
|
||||
|
||||
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase))
|
||||
{ shift = true; continue; }
|
||||
|
||||
if (p.Equals("Win", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Equals("Windows", StringComparison.OrdinalIgnoreCase))
|
||||
{ win = true; continue; }
|
||||
|
||||
if (_keyMap.TryGetValue(p, out int vk))
|
||||
vkCode = vk;
|
||||
else
|
||||
return false; // 알 수 없는 키
|
||||
}
|
||||
|
||||
if (vkCode == null) return false;
|
||||
result = new HotkeyDefinition(vkCode.Value, ctrl, alt, shift, win);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="HotkeyDefinition"/> → "Alt+Space" 형식 문자열.
|
||||
/// </summary>
|
||||
public static string Format(HotkeyDefinition def)
|
||||
{
|
||||
var parts = new List<string>(5);
|
||||
if (def.Ctrl) parts.Add("Ctrl");
|
||||
if (def.Alt) parts.Add("Alt");
|
||||
if (def.Shift) parts.Add("Shift");
|
||||
if (def.Win) parts.Add("Win");
|
||||
parts.Add(VkToName(def.VkCode));
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
// VK 코드 → 읽기 좋은 이름 변환
|
||||
private static string VkToName(int vk)
|
||||
{
|
||||
if (vk >= 0x41 && vk <= 0x5A) return ((char)vk).ToString(); // A–Z
|
||||
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString(); // 0–9
|
||||
if (vk >= 0x70 && vk <= 0x87) return $"F{vk - 0x6F}"; // F1–F24
|
||||
if (vk >= 0x60 && vk <= 0x69) return $"Num{vk - 0x60}"; // Numpad
|
||||
|
||||
// 특수키 테이블에서 긴 이름 우선 검색
|
||||
string? best = null;
|
||||
foreach (var (name, code) in _keyMap)
|
||||
{
|
||||
if (code == vk && (best == null || name.Length > best.Length))
|
||||
best = name;
|
||||
}
|
||||
return best ?? $"0x{vk:X2}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 파싱된 핫키 정의. VK 코드 + 요구 수정자 키.
|
||||
/// </summary>
|
||||
public record struct HotkeyDefinition(int VkCode, bool Ctrl, bool Alt, bool Shift, bool Win);
|
||||
265
src/AxCopilot/Core/InputListener.cs
Normal file
265
src/AxCopilot/Core/InputListener.cs
Normal file
@@ -0,0 +1,265 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 글로벌 키보드 훅으로 어떤 앱이 포커스를 가져도 설정된 핫키를 감지합니다.
|
||||
/// WH_KEYBOARD_LL (Low-Level Keyboard Hook) 사용.
|
||||
/// </summary>
|
||||
public class InputListener : IDisposable
|
||||
{
|
||||
private const int WH_KEYBOARD_LL = 13;
|
||||
private const int WM_KEYDOWN = 0x0100;
|
||||
private const int WM_SYSKEYDOWN = 0x0104;
|
||||
private const int WM_KEYUP = 0x0101;
|
||||
private const int WM_SYSKEYUP = 0x0105;
|
||||
|
||||
// 수정자 키 VK 코드
|
||||
private const int VK_SHIFT = 0x10;
|
||||
private const int VK_CONTROL = 0x11;
|
||||
private const int VK_MENU = 0x12; // Alt
|
||||
private const int VK_LWIN = 0x5B;
|
||||
private const int VK_RWIN = 0x5C;
|
||||
|
||||
private IntPtr _hookHandle = IntPtr.Zero;
|
||||
private LowLevelKeyboardProc? _proc;
|
||||
private int _retryCount = 0;
|
||||
private const int MaxRetry = 3;
|
||||
|
||||
// 핫키 발동 후 잔여 KEYUP 이벤트 억제 플래그
|
||||
// Alt+X 핫키 → Alt KEYUP이 앱 메뉴바를 활성화하는 문제 방지
|
||||
private volatile bool _suppressNextAltUp;
|
||||
private volatile bool _suppressNextKeyUp;
|
||||
private volatile int _suppressKeyUpVk;
|
||||
|
||||
// 현재 설정된 핫키 (기본: Alt+Space)
|
||||
private HotkeyDefinition _hotkey = new(0x20, false, true, false, false);
|
||||
|
||||
// 글로벌 캡처 단축키 (기본: PrintScreen, 비활성)
|
||||
private HotkeyDefinition _captureHotkey;
|
||||
private bool _captureHotkeyEnabled;
|
||||
|
||||
public event EventHandler? HotkeyTriggered;
|
||||
public event EventHandler? CaptureHotkeyTriggered;
|
||||
public event EventHandler? HookFailed;
|
||||
|
||||
/// <summary>
|
||||
/// 핫키 녹화 중일 때 true로 설정하면 핫키 이벤트를 발생시키지 않습니다.
|
||||
/// </summary>
|
||||
public bool SuspendHotkey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 스니펫 확장기가 설정하는 키 필터.
|
||||
/// true를 반환하면 해당 키 이벤트를 소비(다른 앱으로 전달 차단)합니다.
|
||||
/// 훅 콜백 스레드에서 실행되므로 빠르고 스레드 안전하게 구현해야 합니다.
|
||||
/// </summary>
|
||||
public Func<int, bool>? KeyFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 설정에서 읽은 핫키 문자열로 핫키를 업데이트합니다. ("Alt+Space", "Ctrl+K" 등)
|
||||
/// </summary>
|
||||
public void UpdateHotkey(string hotkeyStr)
|
||||
{
|
||||
if (HotkeyParser.TryParse(hotkeyStr, out var def))
|
||||
{
|
||||
_hotkey = def;
|
||||
LogService.Info($"핫키 변경: {hotkeyStr}");
|
||||
}
|
||||
else
|
||||
{
|
||||
LogService.Warn($"핫키 파싱 실패: '{hotkeyStr}' — 기존 핫키 유지");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 글로벌 캡처 단축키를 설정합니다.
|
||||
/// </summary>
|
||||
public void UpdateCaptureHotkey(string hotkeyStr, bool enabled)
|
||||
{
|
||||
_captureHotkeyEnabled = enabled;
|
||||
if (enabled && HotkeyParser.TryParse(hotkeyStr, out var def))
|
||||
{
|
||||
_captureHotkey = def;
|
||||
LogService.Info($"캡처 단축키 활성화: {hotkeyStr}");
|
||||
}
|
||||
else if (!enabled)
|
||||
{
|
||||
LogService.Info("캡처 단축키 비활성화");
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_proc = HookCallback;
|
||||
Register();
|
||||
}
|
||||
|
||||
private void Register()
|
||||
{
|
||||
using var curProcess = Process.GetCurrentProcess();
|
||||
using var curModule = curProcess.MainModule!;
|
||||
_hookHandle = SetWindowsHookEx(WH_KEYBOARD_LL, _proc!,
|
||||
GetModuleHandle(curModule.ModuleName!), 0);
|
||||
|
||||
if (_hookHandle == IntPtr.Zero)
|
||||
{
|
||||
var err = Marshal.GetLastWin32Error();
|
||||
LogService.Error($"Global Hook 등록 실패 (에러 코드: {err})");
|
||||
TryRetryRegister();
|
||||
}
|
||||
else
|
||||
{
|
||||
_retryCount = 0;
|
||||
LogService.Info($"Global Keyboard Hook 등록 완료 ({HotkeyParser.Format(_hotkey)})");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRetryRegister()
|
||||
{
|
||||
if (_retryCount < MaxRetry)
|
||||
{
|
||||
_retryCount++;
|
||||
LogService.Warn($"Hook 재등록 시도 {_retryCount}/{MaxRetry}");
|
||||
Task.Delay(1000).ContinueWith(_ => Register());
|
||||
}
|
||||
else
|
||||
{
|
||||
LogService.Error("Hook 재등록 최대 횟수 초과");
|
||||
HookFailed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 포그라운드 창이 핫키·스니펫 확장을 억제해야 하는 시스템 대화상자인지 확인합니다.
|
||||
/// Windows 공통 대화상자(#32770): 파일 열기/저장, 브라우저 파일 업로드 등.
|
||||
/// </summary>
|
||||
private static bool IsSuppressedForegroundWindow()
|
||||
{
|
||||
var hwnd = GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero) return false;
|
||||
var sb = new StringBuilder(64);
|
||||
GetClassName(hwnd, sb, 64);
|
||||
var cls = sb.ToString();
|
||||
// #32770 = Windows 공통 대화상자 (파일 열기/저장/선택)
|
||||
// SunAwtDialog = Java Swing 파일 대화상자 (일부 앱)
|
||||
return cls == "#32770" || cls == "SunAwtDialog";
|
||||
}
|
||||
|
||||
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode < 0)
|
||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||
|
||||
var vkCode = Marshal.ReadInt32(lParam);
|
||||
|
||||
// ─── KEYUP: 핫키 발동 후 잔여 이벤트 억제 ─────────────────────────────
|
||||
// Alt+X 핫키를 누른 직후 Alt KEYUP이 일부 앱의 메뉴바를 활성화하는 문제를 방지.
|
||||
// 메인 키 KEYUP도 억제하여 앱이 "키를 완전히 처리"한 것으로 착각하지 않도록 함.
|
||||
if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||||
{
|
||||
if (_suppressNextAltUp && vkCode == VK_MENU)
|
||||
{
|
||||
_suppressNextAltUp = false;
|
||||
return (IntPtr)1; // Alt KEYUP 억제 → 메뉴바 활성화 차단
|
||||
}
|
||||
if (_suppressNextKeyUp && vkCode == _suppressKeyUpVk)
|
||||
{
|
||||
_suppressNextKeyUp = false;
|
||||
return (IntPtr)1; // 메인 키 KEYUP 억제
|
||||
}
|
||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// ─── 이하 KEYDOWN / SYSKEYDOWN 처리 ──────────────────────────────────
|
||||
if (wParam != WM_KEYDOWN && wParam != WM_SYSKEYDOWN)
|
||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||
|
||||
// ─── 시스템 파일 대화상자에서는 핫키·스니펫 전부 비활성 ──────────────
|
||||
if (IsSuppressedForegroundWindow())
|
||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||
|
||||
// ─── 핫키 감지 ──────────────────────────────────────────────────────
|
||||
if (!SuspendHotkey && vkCode == _hotkey.VkCode)
|
||||
{
|
||||
bool ctrlOk = !_hotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
bool altOk = !_hotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
||||
bool shiftOk = !_hotkey.Shift || (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
bool winOk = !_hotkey.Win || (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0
|
||||
|| (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0;
|
||||
|
||||
if (ctrlOk && altOk && shiftOk && winOk)
|
||||
{
|
||||
HotkeyTriggered?.Invoke(this, EventArgs.Empty);
|
||||
// 이후 KEYUP 억제 설정
|
||||
_suppressNextKeyUp = true;
|
||||
_suppressKeyUpVk = vkCode;
|
||||
if (_hotkey.Alt) _suppressNextAltUp = true;
|
||||
return (IntPtr)1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 글로벌 캡처 단축키 감지 ─────────────────────────────────────────
|
||||
if (!SuspendHotkey && _captureHotkeyEnabled && vkCode == _captureHotkey.VkCode)
|
||||
{
|
||||
bool ctrlOk = !_captureHotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
bool altOk = !_captureHotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
||||
bool shiftOk = !_captureHotkey.Shift || (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
bool winOk = !_captureHotkey.Win || (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0
|
||||
|| (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0;
|
||||
|
||||
if (ctrlOk && altOk && shiftOk && winOk)
|
||||
{
|
||||
CaptureHotkeyTriggered?.Invoke(this, EventArgs.Empty);
|
||||
_suppressNextKeyUp = true;
|
||||
_suppressKeyUpVk = vkCode;
|
||||
if (_captureHotkey.Alt) _suppressNextAltUp = true;
|
||||
return (IntPtr)1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스니펫 키 필터 ─────────────────────────────────────────────────
|
||||
if (KeyFilter?.Invoke(vkCode) == true)
|
||||
return (IntPtr)1;
|
||||
|
||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_hookHandle != IntPtr.Zero)
|
||||
{
|
||||
UnhookWindowsHookEx(_hookHandle);
|
||||
_hookHandle = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── P/Invoke ────────────────────────────────────────────────────────────
|
||||
|
||||
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn,
|
||||
IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short GetAsyncKeyState(int vKey);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||
}
|
||||
238
src/AxCopilot/Core/PluginHost.cs
Normal file
238
src/AxCopilot/Core/PluginHost.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using AxCopilot.Handlers;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 외부 .dll 플러그인을 로드하고 CommandResolver에 등록합니다.
|
||||
/// </summary>
|
||||
public class PluginHost
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private readonly CommandResolver _resolver;
|
||||
private readonly List<IActionHandler> _loadedPlugins = new();
|
||||
|
||||
public IReadOnlyList<IActionHandler> LoadedPlugins => _loadedPlugins;
|
||||
|
||||
public PluginHost(SettingsService settings, CommandResolver resolver)
|
||||
{
|
||||
_settings = settings;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// settings.json의 plugins 목록에서 .dll을 로드합니다.
|
||||
/// </summary>
|
||||
public void LoadAll()
|
||||
{
|
||||
_loadedPlugins.Clear();
|
||||
|
||||
foreach (var entry in _settings.Settings.Plugins.Where(p => p.Enabled))
|
||||
{
|
||||
LoadPlugin(entry.Path);
|
||||
}
|
||||
|
||||
// skills 폴더의 .skill.json 파일도 로드 (JSON 스킬)
|
||||
var skillsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
|
||||
if (Directory.Exists(skillsDir))
|
||||
{
|
||||
foreach (var skillFile in Directory.EnumerateFiles(skillsDir, "*.skill.json"))
|
||||
{
|
||||
LoadJsonSkill(skillFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadPlugin(string dllPath)
|
||||
{
|
||||
if (!File.Exists(dllPath))
|
||||
{
|
||||
LogService.Warn($"플러그인 파일 없음: {dllPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dllPath);
|
||||
var handlerTypes = assembly.GetExportedTypes()
|
||||
.Where(t => typeof(IActionHandler).IsAssignableFrom(t) && !t.IsAbstract);
|
||||
|
||||
foreach (var type in handlerTypes)
|
||||
{
|
||||
if (Activator.CreateInstance(type) is IActionHandler handler)
|
||||
{
|
||||
_resolver.RegisterHandler(handler);
|
||||
_loadedPlugins.Add(handler);
|
||||
LogService.Info($"플러그인 로드: {handler.Metadata.Name} v{handler.Metadata.Version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"플러그인 로드 실패 ({dllPath}): {ex.Message}");
|
||||
// 플러그인 오류는 앱 전체를 중단하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadJsonSkill(string skillPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var skill = JsonSkillLoader.Load(skillPath);
|
||||
if (skill != null)
|
||||
{
|
||||
_resolver.RegisterHandler(skill);
|
||||
_loadedPlugins.Add(skill);
|
||||
LogService.Info($"JSON 스킬 로드: {skill.Metadata.Name}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"JSON 스킬 로드 실패 ({skillPath}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 플러그인을 언로드하고 재로드합니다 (개발자 모드 핫 리로드).
|
||||
/// </summary>
|
||||
public void Reload()
|
||||
{
|
||||
LogService.Info("플러그인 전체 재로드 시작");
|
||||
LoadAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로컬 zip 파일에서 플러그인을 설치합니다.
|
||||
/// zip 내 .dll 파일을 plugins 폴더에 추출하고 settings.json에 등록합니다.
|
||||
/// </summary>
|
||||
/// <returns>설치된 핸들러 수</returns>
|
||||
public int InstallFromZip(string zipPath)
|
||||
{
|
||||
if (!File.Exists(zipPath))
|
||||
{
|
||||
LogService.Warn($"플러그인 zip 파일 없음: {zipPath}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var pluginsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "plugins");
|
||||
|
||||
Directory.CreateDirectory(pluginsDir);
|
||||
|
||||
int installed = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
var pluginName = Path.GetFileNameWithoutExtension(zipPath);
|
||||
var targetDir = Path.Combine(pluginsDir, pluginName);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Name)) continue; // 폴더 엔트리 건너뛰기
|
||||
|
||||
var destPath = Path.Combine(targetDir, entry.Name);
|
||||
|
||||
// 경로 탐색 공격 방지
|
||||
if (!Path.GetFullPath(destPath).StartsWith(Path.GetFullPath(targetDir)))
|
||||
{
|
||||
LogService.Warn($"플러그인 zip 경로 위험: {entry.FullName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.ExtractToFile(destPath, overwrite: true);
|
||||
}
|
||||
|
||||
// .dll 파일 찾아서 플러그인으로 등록
|
||||
foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll"))
|
||||
{
|
||||
// 이미 등록된 플러그인인지 확인
|
||||
if (_settings.Settings.Plugins.Any(p => p.Path == dllFile))
|
||||
continue;
|
||||
|
||||
// settings.json에 등록
|
||||
_settings.Settings.Plugins.Add(new Models.PluginEntry
|
||||
{
|
||||
Enabled = true,
|
||||
Path = dllFile,
|
||||
});
|
||||
|
||||
// 즉시 로드 시도
|
||||
LoadPlugin(dllFile);
|
||||
installed++;
|
||||
}
|
||||
|
||||
// .skill.json 파일도 skills 폴더로 복사
|
||||
var skillsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
Directory.CreateDirectory(skillsDir);
|
||||
|
||||
foreach (var skillFile in Directory.EnumerateFiles(targetDir, "*.skill.json"))
|
||||
{
|
||||
var destSkill = Path.Combine(skillsDir, Path.GetFileName(skillFile));
|
||||
File.Copy(skillFile, destSkill, overwrite: true);
|
||||
LoadJsonSkill(destSkill);
|
||||
installed++;
|
||||
}
|
||||
|
||||
if (installed > 0)
|
||||
_settings.Save();
|
||||
|
||||
LogService.Info($"플러그인 설치 완료: {zipPath} → {installed}개 핸들러");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"플러그인 zip 설치 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
return installed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 설치된 플러그인을 제거합니다 (settings에서 삭제 + 파일 삭제).
|
||||
/// </summary>
|
||||
public bool UninstallPlugin(string dllPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = _settings.Settings.Plugins.FirstOrDefault(p => p.Path == dllPath);
|
||||
if (entry != null)
|
||||
{
|
||||
_settings.Settings.Plugins.Remove(entry);
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
// 플러그인 폴더 전체 삭제 (해당 dll이 있는 폴더)
|
||||
var dir = Path.GetDirectoryName(dllPath);
|
||||
if (dir != null && Directory.Exists(dir))
|
||||
{
|
||||
var pluginsRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "plugins");
|
||||
// plugins 하위 폴더인 경우만 삭제 (안전장치)
|
||||
if (Path.GetFullPath(dir).StartsWith(Path.GetFullPath(pluginsRoot)))
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
LogService.Info($"플러그인 제거 완료: {dllPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"플러그인 제거 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
252
src/AxCopilot/Core/SnippetExpander.cs
Normal file
252
src/AxCopilot/Core/SnippetExpander.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 모든 앱에서 ';키워드 + Space/Enter' 패턴을 감지해 스니펫을 자동 확장합니다.
|
||||
/// InputListener.KeyFilter에 <see cref="HandleKey"/> 을 등록하여 사용합니다.
|
||||
/// </summary>
|
||||
public class SnippetExpander
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private readonly StringBuilder _buffer = new();
|
||||
private bool _tracking;
|
||||
|
||||
// VK 상수
|
||||
private const ushort VK_BACK = 0x08;
|
||||
private const int VK_ESCAPE = 0x1B;
|
||||
private const int VK_SPACE = 0x20;
|
||||
private const int VK_RETURN = 0x0D;
|
||||
private const int VK_OEM_1 = 0xBA; // ; (US QWERTY)
|
||||
private const int VK_SHIFT = 0x10;
|
||||
private const int VK_CONTROL = 0x11;
|
||||
private const int VK_MENU = 0x12;
|
||||
private const ushort VK_CTRL_US = 0x11;
|
||||
|
||||
// 방향키 / 기능키 — 이 키가 오면 버퍼 초기화
|
||||
private static readonly HashSet<int> ClearKeys = new()
|
||||
{
|
||||
0x21, 0x22, 0x23, 0x24, // PgUp, PgDn, End, Home
|
||||
0x25, 0x26, 0x27, 0x28, // ←↑→↓
|
||||
0x2E, // Delete
|
||||
};
|
||||
|
||||
public SnippetExpander(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InputListener.KeyFilter에 등록할 메서드.
|
||||
/// true → 해당 키 이벤트 소비(차단), false → 통과.
|
||||
/// 훅 스레드에서 호출되므로 신속히 처리해야 합니다.
|
||||
/// </summary>
|
||||
public bool HandleKey(int vkCode)
|
||||
{
|
||||
// 자동 확장 비활성화 시 즉시 통과
|
||||
if (!_settings.Settings.Launcher.SnippetAutoExpand) return false;
|
||||
|
||||
// Ctrl/Alt 조합은 무시 (단축키와 충돌 방지)
|
||||
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
||||
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
||||
|
||||
// ─── 트리거 시작: ';' 입력 ──────────────────────────────────────────
|
||||
if (vkCode == VK_OEM_1 && (GetAsyncKeyState(VK_SHIFT) & 0x8000) == 0)
|
||||
{
|
||||
_tracking = true;
|
||||
_buffer.Clear();
|
||||
_buffer.Append(';');
|
||||
return false; // ';'는 소비하지 않고 앱으로 전달
|
||||
}
|
||||
|
||||
if (!_tracking) return false;
|
||||
|
||||
// ─── 영문자/숫자 — 버퍼에 추가 ─────────────────────────────────────
|
||||
if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z
|
||||
(vkCode >= 0x30 && vkCode <= 0x39) || // 0-9
|
||||
(vkCode >= 0x60 && vkCode <= 0x69) || // Numpad 0-9
|
||||
vkCode == 0xBD) // -
|
||||
{
|
||||
bool shifted = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
char c = VkToChar(vkCode, shifted);
|
||||
if (c != '\0') _buffer.Append(char.ToLowerInvariant(c));
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── BackSpace — 버퍼에서 한 글자 제거 ──────────────────────────────
|
||||
if (vkCode == VK_BACK)
|
||||
{
|
||||
if (_buffer.Length > 1)
|
||||
_buffer.Remove(_buffer.Length - 1, 1);
|
||||
else
|
||||
{
|
||||
_tracking = false;
|
||||
_buffer.Clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Space / Enter — 스니펫 확장 시도 ───────────────────────────────
|
||||
if (vkCode == VK_SPACE || vkCode == VK_RETURN)
|
||||
{
|
||||
if (_buffer.Length > 1) // ';' 이후 한 글자 이상
|
||||
{
|
||||
var keyword = _buffer.ToString(1, _buffer.Length - 1);
|
||||
_tracking = false;
|
||||
_buffer.Clear();
|
||||
|
||||
var snippet = _settings.Settings.Snippets.FirstOrDefault(
|
||||
s => s.Key.Equals(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (snippet != null)
|
||||
{
|
||||
var expanded = ExpandVariables(snippet.Content);
|
||||
var deleteCount = keyword.Length + 1; // ';' + keyword
|
||||
// 트리거 키(Space/Enter) 소비 후 UI 스레드에서 확장 처리
|
||||
Application.Current.Dispatcher.BeginInvoke(() =>
|
||||
PasteExpansion(expanded, deleteCount));
|
||||
return true; // 트리거 키 소비
|
||||
}
|
||||
}
|
||||
_tracking = false;
|
||||
_buffer.Clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Escape / 방향키 / 기능키 — 추적 중단 ───────────────────────────
|
||||
if (vkCode == VK_ESCAPE || ClearKeys.Contains(vkCode) || vkCode >= 0x70)
|
||||
{
|
||||
_tracking = false;
|
||||
_buffer.Clear();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── 확장 실행 (UI 스레드) ───────────────────────────────────────────────
|
||||
|
||||
private static void PasteExpansion(string text, int deleteCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Backspace × deleteCount
|
||||
var inputs = new INPUT[deleteCount * 2];
|
||||
for (int i = 0; i < deleteCount; i++)
|
||||
{
|
||||
inputs[i * 2] = MakeKeyInput(VK_BACK, false);
|
||||
inputs[i * 2 + 1] = MakeKeyInput(VK_BACK, true);
|
||||
}
|
||||
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
|
||||
// 2. 클립보드 → Ctrl+V
|
||||
Clipboard.SetText(text);
|
||||
|
||||
var paste = new[]
|
||||
{
|
||||
MakeKeyInput(VK_CTRL_US, false),
|
||||
MakeKeyInput(0x56, false), // V
|
||||
MakeKeyInput(0x56, true),
|
||||
MakeKeyInput(VK_CTRL_US, true),
|
||||
};
|
||||
SendInput((uint)paste.Length, paste, Marshal.SizeOf<INPUT>());
|
||||
|
||||
LogService.Info($"스니펫 확장 완료: {deleteCount}자 삭제 후 붙여넣기");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"스니펫 확장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static INPUT MakeKeyInput(ushort vk, bool keyUp)
|
||||
{
|
||||
var input = new INPUT { type = 1 };
|
||||
input.u.ki.wVk = vk;
|
||||
input.u.ki.dwFlags = keyUp ? 0x0002u : 0u; // KEYEVENTF_KEYUP
|
||||
return input;
|
||||
}
|
||||
|
||||
// ─── 변수 치환 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ExpandVariables(string content)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
return content
|
||||
.Replace("{date}", now.ToString("yyyy-MM-dd"))
|
||||
.Replace("{time}", now.ToString("HH:mm:ss"))
|
||||
.Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss"))
|
||||
.Replace("{year}", now.Year.ToString())
|
||||
.Replace("{month}", now.Month.ToString("D2"))
|
||||
.Replace("{day}", now.Day.ToString("D2"));
|
||||
}
|
||||
|
||||
// ─── VK → Char 매핑 (US QWERTY 기준) ────────────────────────────────────
|
||||
|
||||
private static char VkToChar(int vk, bool shifted)
|
||||
{
|
||||
if (vk >= 0x41 && vk <= 0x5A)
|
||||
return shifted ? (char)vk : char.ToLowerInvariant((char)vk);
|
||||
if (vk >= 0x30 && vk <= 0x39)
|
||||
return shifted ? ")!@#$%^&*("[vk - 0x30] : (char)vk;
|
||||
if (vk >= 0x60 && vk <= 0x69)
|
||||
return (char)('0' + (vk - 0x60));
|
||||
if (vk == 0xBD)
|
||||
return shifted ? '_' : '-';
|
||||
return '\0';
|
||||
}
|
||||
|
||||
// ─── P/Invoke ────────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short GetAsyncKeyState(int vKey);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct INPUT
|
||||
{
|
||||
public int type;
|
||||
public InputUnion u;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
private struct InputUnion
|
||||
{
|
||||
[FieldOffset(0)] public MOUSEINPUT mi;
|
||||
[FieldOffset(0)] public KEYBDINPUT ki;
|
||||
[FieldOffset(0)] public HARDWAREINPUT hi;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MOUSEINPUT
|
||||
{
|
||||
public int dx;
|
||||
public int dy;
|
||||
public uint mouseData;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct HARDWAREINPUT
|
||||
{
|
||||
public uint uMsg;
|
||||
public ushort wParamL;
|
||||
public ushort wParamH;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user