Some checks failed
Release Gate / gate (push) Has been cancelled
다른 앱 타이핑 시에도 AX Copilot가 과하게 개입하던 글로벌 키보드 훅의 핫패스를 줄였다. InputListener는 모든 키마다 파일 대화상자 억제 창 검사를 하지 않고 실제 핫키·캡처·키필터 후보 키에서만 검사하도록 최적화했다. SnippetExpander는 추적 중이 아닐 때 ';' 시작 키 외에는 즉시 반환하게 바꿔 일반 타이핑 중 반복적인 modifier 상태 확인과 버퍼 처리를 제거했다. README와 DEVELOPMENT 문서를 2026-04-06 18:34 (KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 / 오류 0을 확인했다.
263 lines
9.4 KiB
C#
263 lines
9.4 KiB
C#
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;
|
||
|
||
// 추적 중이 아닐 때는 ';' 시작 키만 검사해서 훅 경로 부담을 최소화합니다.
|
||
if (!_tracking)
|
||
{
|
||
if (vkCode != VK_OEM_1)
|
||
return false;
|
||
|
||
if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0)
|
||
return false;
|
||
|
||
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0)
|
||
return false;
|
||
|
||
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0)
|
||
return false;
|
||
|
||
_tracking = true;
|
||
_buffer.Clear();
|
||
_buffer.Append(';');
|
||
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 >= 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;
|
||
}
|
||
}
|