using System.Diagnostics; using System.IO; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다. /// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다. /// public class IndexService : IDisposable { private readonly SettingsService _settings; private List _index = new(); private readonly List _watchers = new(); private System.Threading.Timer? _debounceTimer; private readonly object _timerLock = new(); private const int DebounceMs = 3000; private readonly SemaphoreSlim _rebuildLock = new(1, 1); public IReadOnlyList Entries => _index; public event EventHandler? IndexRebuilt; public TimeSpan LastIndexDuration { get; private set; } public int LastIndexCount { get; private set; } public IndexService(SettingsService settings) { _settings = settings; } /// /// 앱 시작 시 전체 인덱스 빌드 후 FileSystemWatcher 시작. /// public async Task BuildAsync(CancellationToken ct = default) { await _rebuildLock.WaitAsync(ct); var sw = Stopwatch.StartNew(); try { var entries = new List(); var paths = _settings.Settings.IndexPaths .Select(p => Environment.ExpandEnvironmentVariables(p)); var allowedExts = new HashSet( _settings.Settings.IndexExtensions .Select(e => e.ToLowerInvariant().StartsWith(".") ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()), StringComparer.OrdinalIgnoreCase); var indexSpeed = _settings.Settings.IndexSpeed ?? "normal"; foreach (var dir in paths) { if (!Directory.Exists(dir)) continue; await ScanDirectoryAsync(dir, entries, allowedExts, indexSpeed, ct); } // Alias도 인덱스에 포함 (type에 따라 IndexEntryType 구분) foreach (var alias in _settings.Settings.Aliases) { entries.Add(new IndexEntry { Name = alias.Key, DisplayName = alias.Description ?? alias.Key, Path = alias.Target, AliasType = alias.Type, Type = alias.Type switch { "app" => IndexEntryType.App, "folder" => IndexEntryType.Folder, _ => IndexEntryType.Alias // url, batch, api, clipboard }, Score = 100 // Alias는 최우선 }); } // Built-in 앱 별칭 (한글+영문 이름으로 즉시 실행) RegisterBuiltInApps(entries); // 검색 가속 캐시 일괄 계산 (ToLower·자모·초성을 빌드 시 1회만 수행) ComputeAllSearchCaches(entries); _index = entries; sw.Stop(); LastIndexDuration = sw.Elapsed; LastIndexCount = entries.Count; LogService.Info($"인덱싱 완료: {entries.Count}개 항목 ({sw.Elapsed.TotalSeconds:F1}초)"); IndexRebuilt?.Invoke(this, EventArgs.Empty); } finally { _rebuildLock.Release(); } } /// /// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다. /// BuildAsync() 완료 후 호출하세요. /// public void StartWatchers() { StopWatchers(); foreach (var rawPath in _settings.Settings.IndexPaths) { var dir = Environment.ExpandEnvironmentVariables(rawPath); if (!Directory.Exists(dir)) continue; try { var w = new FileSystemWatcher(dir) { Filter = "*.*", IncludeSubdirectories = true, NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName, EnableRaisingEvents = true }; w.Created += OnWatcherEvent; w.Deleted += OnWatcherEvent; w.Renamed += OnWatcherRenamed; _watchers.Add(w); } catch (Exception ex) { LogService.Warn($"FileSystemWatcher 생성 실패: {dir} - {ex.Message}"); } } LogService.Info($"파일 감시 시작: {_watchers.Count}개 경로"); } private void StopWatchers() { foreach (var w in _watchers) { w.EnableRaisingEvents = false; w.Dispose(); } _watchers.Clear(); } private void OnWatcherEvent(object sender, FileSystemEventArgs e) { ScheduleRebuild(e.FullPath); } private void OnWatcherRenamed(object sender, RenamedEventArgs e) { ScheduleRebuild(e.FullPath); } private void ScheduleRebuild(string triggerPath) { LogService.Info($"파일 변경 감지: {triggerPath} — {DebounceMs}ms 후 재빌드 예약"); lock (_timerLock) { _debounceTimer?.Dispose(); _debounceTimer = new System.Threading.Timer(__ => { _ = BuildAsync(); }, null, DebounceMs, System.Threading.Timeout.Infinite); } } public void Dispose() { _debounceTimer?.Dispose(); StopWatchers(); } /// /// 자주 사용하는 Windows 기본 앱을 한글+영문 이름으로 등록합니다. /// 실제로 존재하는 경우에만 인덱스에 추가됩니다. /// private static void RegisterBuiltInApps(List entries) { // (displayName, keywords[], exePath) var builtIns = new (string Display, string[] Names, string? Exe)[] { // 메모장 ("메모장 (Notepad)", new[] { "메모장", "notepad", "note", "txt" }, @"C:\Windows\notepad.exe"), // 계산기 ("계산기 (Calculator)", new[] { "계산기", "calc", "calculator" }, "calculator:"), // UWP protocol // 캡처 도구 (Snipping Tool) ("캡처 도구 (Snipping Tool)", new[] { "캡처", "캡처도구", "snippingtool", "snip", "스크린샷" }, @"C:\Windows\System32\SnippingTool.exe"), // 그림판 ("그림판 (Paint)", new[] { "그림판", "mspaint", "paint" }, @"C:\Windows\System32\mspaint.exe"), // 탐색기 ("파일 탐색기 (Explorer)", new[] { "탐색기", "explorer", "파일탐색기" }, @"C:\Windows\explorer.exe"), // 명령 프롬프트 ("명령 프롬프트 (CMD)", new[] { "cmd", "명령프롬프트", "커맨드", "터미널" }, @"C:\Windows\System32\cmd.exe"), // PowerShell ("PowerShell", new[] { "powershell", "파워쉘" }, @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), // 제어판 ("제어판 (Control Panel)", new[] { "제어판", "control", "controlpanel" }, @"C:\Windows\System32\control.exe"), // 작업 관리자 ("작업 관리자 (Task Manager)", new[] { "작업관리자", "taskmgr", "taskmanager" }, @"C:\Windows\System32\Taskmgr.exe"), // 원격 데스크톱 ("원격 데스크톱 (Remote Desktop)", new[] { "원격", "mstsc", "rdp", "원격데스크톱" }, @"C:\Windows\System32\mstsc.exe"), // 레지스트리 편집기 ("레지스트리 편집기", new[] { "regedit", "레지스트리" }, @"C:\Windows\regedit.exe"), // 장치 관리자 ("장치 관리자", new[] { "장치관리자", "devmgmt", "devicemanager" }, @"C:\Windows\System32\devmgmt.msc"), // Microsoft Edge ("Microsoft Edge", new[] { "edge", "엣지", "브라우저" }, @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), // Excel — 확장자 + MS prefix + 한글 ("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" }, FindOfficeApp("EXCEL.EXE")), // PowerPoint ("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" }, FindOfficeApp("POWERPNT.EXE")), // Word ("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" }, FindOfficeApp("WINWORD.EXE")), // Outlook ("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" }, FindOfficeApp("OUTLOOK.EXE")), // Teams ("Microsoft Teams", new[] { "팀즈", "teams" }, FindTeams()), // OneNote ("Microsoft OneNote", new[] { "원노트", "onenote" }, FindOfficeApp("ONENOTE.EXE")), // Access ("Microsoft Access", new[] { "액세스", "access", "msaccess" }, FindOfficeApp("MSACCESS.EXE")), // VS Code ("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" }, FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")), // Visual Studio ("Visual Studio", new[] { "비주얼스튜디오", "devenv", "vs2022", "vs2019", "visualstudio" }, FindInProgramFiles(@"Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.exe") ?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Professional\Common7\IDE\devenv.exe") ?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Enterprise\Common7\IDE\devenv.exe")), // Windows Terminal ("Windows Terminal", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" }, FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")), // OneDrive ("OneDrive", new[] { "원드라이브", "onedrive" }, FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")), // Google Chrome ("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" }, FindInProgramFiles(@"Google\Chrome\Application\chrome.exe") ?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")), // Firefox ("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" }, FindInProgramFiles(@"Mozilla Firefox\firefox.exe")), // Naver Whale ("Naver Whale", new[] { "웨일", "네이버웨일", "whale" }, FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")), // KakaoTalk ("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" }, FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")), // KakaoWork ("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" }, FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")), // Zoom ("Zoom", new[] { "줌", "zoom" }, FindInRoaming(@"Zoom\bin\Zoom.exe") ?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")), // Slack ("Slack", new[] { "슬랙", "slack" }, FindInLocalAppData(@"slack\slack.exe")), // Figma ("Figma", new[] { "피그마", "figma" }, FindInLocalAppData(@"Figma\Figma.exe")), // Notepad++ ("Notepad++", new[] { "노트패드++", "npp", "notepad++" }, FindInProgramFiles(@"Notepad++\notepad++.exe")), // 7-Zip ("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" }, FindInProgramFiles(@"7-Zip\7zFM.exe")), // Bandizip ("Bandizip", new[] { "반디집", "bandizip" }, FindInProgramFiles(@"Bandizip\Bandizip.exe") ?? FindInLocalAppData(@"Bandizip\Bandizip.exe")), // ALZip ("ALZip", new[] { "알집", "alzip", "이스트소프트" }, FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")), // PotPlayer ("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" }, FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe") ?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")), // GOM Player ("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" }, FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")), // Adobe Photoshop ("Adobe Photoshop", new[] { "포토샵", "포샵", "photoshop", "ps" }, FindInProgramFiles(@"Adobe\Adobe Photoshop 2025\Photoshop.exe") ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2024\Photoshop.exe") ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2023\Photoshop.exe")), // Adobe Acrobat ("Adobe Acrobat", new[] { "아크로뱃", "acrobat", "아도비pdf" }, FindInProgramFiles(@"Adobe\Acrobat DC\Acrobat\Acrobat.exe")), // 한컴 한글 ("한컴 한글", new[] { "한글", "한컴한글", "hwp", "hangul", "아래아한글" }, FindInProgramFiles(@"HNC\Hwp\Hwp.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hwp.exe") ?? FindInProgramFiles(@"Hnc\Hwp80\Hwp.exe")), // 한컴 한셀 ("한컴 한셀", new[] { "한셀", "hcell", "한컴스프레드" }, FindInProgramFiles(@"HNC\Hwp\Hcell.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hcell.exe")), // 한컴 한쇼 ("한컴 한쇼", new[] { "한쇼", "hshow", "한컴프레젠테이션" }, FindInProgramFiles(@"HNC\Hwp\Show.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Show.exe")), }; // 기존 항목의 이름 → 인덱스 매핑 + Path 기반 중복 방지 var nameToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); var pathToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < entries.Count; i++) { nameToIdx.TryAdd(entries[i].Name, i); pathToIdx.TryAdd(entries[i].Path, i); } foreach (var (display, names, exe) in builtIns) { if (string.IsNullOrEmpty(exe)) continue; // UWP protocol은 파일 존재 체크 불필요 if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc")) continue; // 같은 경로가 이미 인덱스에 있으면 DisplayName과 Score만 업데이트 if (pathToIdx.TryGetValue(exe, out var existingIdx)) { entries[existingIdx].DisplayName = display; if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95; } // ── 키워드별 IndexEntry 등록 ───────────────────────────────────────── // - 한글 이름: 항상 IndexEntry로 추가 (앱이 이미 스캔됐어도) // → "엑" 입력 시 "엑셀" IndexEntry의 StartsWith("엑")으로 매칭 // - 영문/약어: 앱이 미스캔 상태일 때 첫 키워드만 대표 항목으로 등록 // - 경로 중복은 CommandResolver 레이어에서 seenPaths로 최고점만 표시 bool mainAdded = pathToIdx.ContainsKey(exe); foreach (var name in names) { if (nameToIdx.ContainsKey(name)) { // 이미 같은 이름 존재 → Score만 부스트 if (nameToIdx.TryGetValue(name, out var idx) && entries[idx].Score < 95) entries[idx].Score = 95; continue; } // 한글 음절이 포함된 이름은 항상 IndexEntry로 추가 bool isKoreanName = name.Any(c => c >= '\uAC00' && c <= '\uD7A3'); if (!mainAdded || isKoreanName) { entries.Add(new IndexEntry { Name = name, DisplayName = display, Path = exe, Type = IndexEntryType.App, Score = 95 }); var newIdx = entries.Count - 1; nameToIdx[name] = newIdx; if (!mainAdded) { pathToIdx[exe] = newIdx; mainAdded = true; } } else { // 영문/약어는 pathToIdx 참조만 (FuzzyEngine은 영문 앱명 직접 매칭) nameToIdx[name] = pathToIdx[exe]; } } } } private static string? FindOfficeApp(string exeName) { var roots = new[] { @"C:\Program Files\Microsoft Office\root\Office16", @"C:\Program Files (x86)\Microsoft Office\root\Office16", @"C:\Program Files\Microsoft Office\Office16", @"C:\Program Files (x86)\Microsoft Office\Office16", }; foreach (var root in roots) { var path = Path.Combine(root, exeName); if (File.Exists(path)) return path; } return null; } private static string? FindTeams() { var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe"); if (File.Exists(path)) return path; // New Teams (MSIX) var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe"); return File.Exists(msTeams) ? msTeams : null; } private static string? FindInPath(string fileName) { var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in pathEnv.Split(';')) { if (string.IsNullOrWhiteSpace(dir)) continue; var full = Path.Combine(dir.Trim(), fileName); if (File.Exists(full)) return full; } return null; } private static string? FindInLocalAppData(string relativePath) { var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var path = Path.Combine(localApp, relativePath); return File.Exists(path) ? path : null; } /// ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다. private static string? FindInProgramFiles(string relativePath) { var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); var p1 = Path.Combine(pf, relativePath); if (File.Exists(p1)) return p1; var p2 = Path.Combine(pf86, relativePath); return File.Exists(p2) ? p2 : null; } /// AppData\Roaming 경로를 탐색합니다. private static string? FindInRoaming(string relativePath) { var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var path = Path.Combine(roaming, relativePath); return File.Exists(path) ? path : null; } // ─── 검색 가속 캐시 계산 ────────────────────────────────────────────────── /// 빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다. private static void ComputeAllSearchCaches(List entries) { foreach (var e in entries) ComputeSearchCache(e); } /// 항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다. private static void ComputeSearchCache(IndexEntry entry) { entry.NameLower = entry.Name.ToLowerInvariant(); // 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근) entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower); // 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ") var sb = new System.Text.StringBuilder(entry.NameLower.Length); foreach (var c in entry.NameLower) { var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c); if (cho != '\0') sb.Append(cho); } entry.NameChosung = sb.ToString(); } /// 인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield. private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch { "fast" => (500, 0), // 최대 속도, CPU 양보 없음 "slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소 _ => (150, 5), // normal: 150개마다 5ms → 적정 균형 }; private static async Task ScanDirectoryAsync(string dir, List entries, HashSet allowedExts, string indexSpeed, CancellationToken ct) { var (batchSize, delayMs) = GetThrottle(indexSpeed); await Task.Run(async () => { try { int count = 0; // 파일 인덱싱 (확장자 필터 적용) foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); var ext = Path.GetExtension(file).ToLowerInvariant(); if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue; var name = Path.GetFileNameWithoutExtension(file); if (string.IsNullOrEmpty(name)) continue; var type = ext switch { ".exe" => IndexEntryType.App, ".lnk" or ".url" => IndexEntryType.File, _ => IndexEntryType.File }; entries.Add(new IndexEntry { Name = name, DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext, Path = file, Type = type }); // 속도 조절: batchSize개마다 CPU 양보 if (delayMs > 0 && ++count % batchSize == 0) await Task.Delay(delayMs, ct); } // 폴더 인덱싱 (1단계 하위 폴더) foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly)) { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(subDir); if (name.StartsWith(".")) continue; entries.Add(new IndexEntry { Name = name, DisplayName = name, Path = subDir, Type = IndexEntryType.Folder }); } } catch (UnauthorizedAccessException ex) { LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}"); } }, ct); } } public class IndexEntry { public string Name { get; set; } = ""; public string DisplayName { get; set; } = ""; public string Path { get; set; } = ""; public IndexEntryType Type { get; set; } public int Score { get; set; } = 0; /// Alias 항목의 원본 type 문자열 (url | folder | app | batch | api | clipboard) public string? AliasType { get; set; } // ─ 검색 가속 캐시 (BuildAsync 시 1회 계산, 검색마다 재계산 방지) ────── /// ToLowerInvariant() 결과 캐시 public string NameLower { get; set; } = ""; /// 자모 분리 문자열 캐시 (DecomposeToJamo 결과) public string NameJamo { get; set; } = ""; /// 초성만 연결한 문자열 캐시 (예: "엑셀" → "ㅇㅅ") public string NameChosung { get; set; } = ""; } public enum IndexEntryType { App, File, Alias, Folder }