Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View 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));
}
}

View 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>();
}
}
}

View 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);

View 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);

View 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()
{
// AZ
for (char c = 'A'; c <= 'Z'; c++)
_keyMap[c.ToString()] = c;
// 09
for (char c = '0'; c <= '9'; c++)
_keyMap[c.ToString()] = c;
// F1F24
for (int i = 1; i <= 24; i++)
_keyMap[$"F{i}"] = 0x6F + i;
// Numpad 09
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(); // AZ
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString(); // 09
if (vk >= 0x70 && vk <= 0x87) return $"F{vk - 0x6F}"; // F1F24
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);

View 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);
}

View 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;
}
}
}

View 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;
}
}