using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다. /// 파일 시스템 결과는 디스크 캐시에 영속 저장하고, FileSystemWatcher 이벤트는 /// 가능한 범위에서 증분 반영하여 전체 재색인 비용을 줄입니다. /// public class IndexService : IDisposable { private readonly SettingsService _settings; private readonly List _watchers = new(); private readonly object _timerLock = new(); private readonly object _indexLock = new(); private readonly SemaphoreSlim _rebuildLock = new(1, 1); private System.Threading.Timer? _rebuildTimer; private System.Threading.Timer? _persistTimer; private List _fileSystemEntries = new(); private List _index = new(); private const int RebuildDebounceMs = 3000; private const int PersistDebounceMs = 800; private static readonly JsonSerializerOptions CacheJsonOptions = new() { WriteIndented = false, PropertyNameCaseInsensitive = true }; public IReadOnlyList Entries => _index; public event EventHandler? IndexRebuilt; public TimeSpan LastIndexDuration { get; private set; } public int LastIndexCount { get; private set; } public bool HasCachedIndexLoaded { get; private set; } public IndexService(SettingsService settings) { _settings = settings; } public void LoadCachedIndex() { try { var cachePath = GetCachePath(); if (!File.Exists(cachePath)) return; var cache = JsonSerializer.Deserialize(File.ReadAllText(cachePath), CacheJsonOptions); if (cache == null) return; var currentSignature = BuildCacheSignature(); if (!string.Equals(cache.Signature, currentSignature, StringComparison.Ordinal)) { LogService.Info("런처 인덱스 캐시 무효화: 설정 서명이 변경되었습니다."); return; } var entries = cache.Entries .Select(FromCacheEntry) .Where(static e => !string.IsNullOrWhiteSpace(e.Name) && !string.IsNullOrWhiteSpace(e.Path)) .ToList(); ComputeAllSearchCaches(entries); lock (_indexLock) { _fileSystemEntries = entries; PublishIndexSnapshot_NoLock(); } HasCachedIndexLoaded = entries.Count > 0; LastIndexDuration = TimeSpan.Zero; LastIndexCount = _index.Count; LogService.Info($"런처 인덱스 캐시 로드: {entries.Count}개 파일 시스템 항목"); } catch (Exception ex) { LogService.Warn($"런처 인덱스 캐시 로드 실패: {ex.Message}"); } } /// /// 전체 파일 시스템 항목을 다시 스캔한 뒤 디스크 캐시를 갱신합니다. /// public async Task BuildAsync(CancellationToken ct = default) { await _rebuildLock.WaitAsync(ct); var sw = Stopwatch.StartNew(); try { var fileSystemEntries = new List(); var paths = GetExpandedIndexPaths(); var allowedExts = GetAllowedExtensions(); var indexSpeed = _settings.Settings.IndexSpeed ?? "normal"; foreach (var dir in paths) { if (!Directory.Exists(dir)) continue; await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct); } ComputeAllSearchCaches(fileSystemEntries); lock (_indexLock) { _fileSystemEntries = fileSystemEntries; PublishIndexSnapshot_NoLock(); } sw.Stop(); LastIndexDuration = sw.Elapsed; LastIndexCount = _index.Count; HasCachedIndexLoaded = fileSystemEntries.Count > 0; PersistCache(); LogService.Info($"런처 인덱싱 완료: {fileSystemEntries.Count}개 파일 시스템 항목 ({sw.Elapsed.TotalSeconds:F1}초)"); IndexRebuilt?.Invoke(this, EventArgs.Empty); } finally { _rebuildLock.Release(); } } /// /// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다. /// 캐시 로드 후에도 바로 호출해 증분 반영을 유지합니다. /// public void StartWatchers() { StopWatchers(); foreach (var dir in GetExpandedIndexPaths()) { if (!Directory.Exists(dir)) continue; try { var watcher = new FileSystemWatcher(dir) { Filter = "*", IncludeSubdirectories = true, NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName, EnableRaisingEvents = true }; watcher.Created += OnWatcherEvent; watcher.Deleted += OnWatcherEvent; watcher.Renamed += OnWatcherRenamed; _watchers.Add(watcher); } catch (Exception ex) { LogService.Warn($"FileSystemWatcher 생성 실패: {dir} - {ex.Message}"); } } LogService.Info($"런처 파일 감시 시작: {_watchers.Count}개 경로"); } private IEnumerable GetExpandedIndexPaths() => _settings.Settings.IndexPaths .Select(path => Environment.ExpandEnvironmentVariables(path)) .Distinct(StringComparer.OrdinalIgnoreCase); private HashSet GetAllowedExtensions() => new( _settings.Settings.IndexExtensions .Select(ext => ext.ToLowerInvariant().StartsWith(".") ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant()), StringComparer.OrdinalIgnoreCase); private void StopWatchers() { foreach (var watcher in _watchers) { watcher.EnableRaisingEvents = false; watcher.Dispose(); } _watchers.Clear(); } private void OnWatcherEvent(object sender, FileSystemEventArgs e) { var rootPath = (sender as FileSystemWatcher)?.Path; if (TryApplyIncrementalChange(e.ChangeType, e.FullPath, rootPath)) return; ScheduleRebuild(e.FullPath); } private void OnWatcherRenamed(object sender, RenamedEventArgs e) { var rootPath = (sender as FileSystemWatcher)?.Path; if (TryApplyIncrementalRename(e.OldFullPath, e.FullPath, rootPath)) return; ScheduleRebuild(e.FullPath); } private bool TryApplyIncrementalChange(WatcherChangeTypes changeType, string fullPath, string? rootPath) { try { if (string.IsNullOrWhiteSpace(rootPath)) return false; // 디렉터리 삭제/이름변경은 하위 파일 경로까지 바뀔 수 있어 전체 재색인으로 폴백합니다. if (changeType == WatcherChangeTypes.Deleted && Path.GetExtension(fullPath).Length == 0) return false; var changed = changeType switch { WatcherChangeTypes.Created => AddPathEntryIncrementally(fullPath, rootPath), WatcherChangeTypes.Deleted => RemovePathEntriesIncrementally(fullPath), _ => false }; if (!changed) return false; AfterIncrementalUpdate(fullPath); return true; } catch (Exception ex) { LogService.Warn($"런처 인덱스 증분 반영 실패: {fullPath} - {ex.Message}"); return false; } } private bool TryApplyIncrementalRename(string oldFullPath, string newFullPath, string? rootPath) { try { if (string.IsNullOrWhiteSpace(rootPath)) return false; if (Directory.Exists(newFullPath)) return false; var removed = RemovePathEntriesIncrementally(oldFullPath); var added = AddPathEntryIncrementally(newFullPath, rootPath); if (!removed && !added) return false; AfterIncrementalUpdate(newFullPath); return true; } catch (Exception ex) { LogService.Warn($"런처 인덱스 이름변경 증분 반영 실패: {newFullPath} - {ex.Message}"); return false; } } private void AfterIncrementalUpdate(string triggerPath) { lock (_indexLock) PublishIndexSnapshot_NoLock(); PersistCacheDeferred(); LastIndexCount = _index.Count; IndexRebuilt?.Invoke(this, EventArgs.Empty); LogService.Info($"런처 인덱스 증분 갱신: {triggerPath}"); } private bool AddPathEntryIncrementally(string fullPath, string rootPath) { var updated = false; lock (_indexLock) { var snapshot = CloneEntries(_fileSystemEntries); if (Directory.Exists(fullPath)) { if (IsTopLevelDirectory(rootPath, fullPath)) updated = UpsertEntry(snapshot, CreateFolderEntry(fullPath)); } else if (File.Exists(fullPath)) { var ext = Path.GetExtension(fullPath).ToLowerInvariant(); var allowedExts = GetAllowedExtensions(); if (allowedExts.Count == 0 || allowedExts.Contains(ext)) updated = UpsertEntry(snapshot, CreateFileEntry(fullPath)); } if (!updated) return false; ComputeAllSearchCaches(snapshot); _fileSystemEntries = snapshot; } return true; } private bool RemovePathEntriesIncrementally(string fullPath) { var normalizedPath = NormalizePath(fullPath); lock (_indexLock) { var snapshot = CloneEntries(_fileSystemEntries); var removed = snapshot.RemoveAll(entry => string.Equals(NormalizePath(entry.Path), normalizedPath, StringComparison.OrdinalIgnoreCase)) > 0; if (!removed) return false; _fileSystemEntries = snapshot; return true; } } private static bool UpsertEntry(List entries, IndexEntry newEntry) { var normalizedPath = NormalizePath(newEntry.Path); var index = entries.FindIndex(entry => string.Equals(NormalizePath(entry.Path), normalizedPath, StringComparison.OrdinalIgnoreCase)); if (index >= 0) { entries[index] = newEntry; return true; } entries.Add(newEntry); return true; } private void ScheduleRebuild(string triggerPath) { LogService.Info($"파일 변경 감지: {triggerPath} — {RebuildDebounceMs}ms 후 전체 재빌드 예약"); lock (_timerLock) { _rebuildTimer?.Dispose(); _rebuildTimer = new System.Threading.Timer(_ => _ = BuildAsync(), null, RebuildDebounceMs, System.Threading.Timeout.Infinite); } } private void PersistCacheDeferred() { lock (_timerLock) { _persistTimer?.Dispose(); _persistTimer = new System.Threading.Timer(_ => PersistCache(), null, PersistDebounceMs, System.Threading.Timeout.Infinite); } } private void PersistCache() { try { List cacheEntries; lock (_indexLock) { cacheEntries = _fileSystemEntries .Select(ToCacheEntry) .ToList(); } var cache = new LauncherIndexCache { Signature = BuildCacheSignature(), SavedAtUtc = DateTime.UtcNow, Entries = cacheEntries }; var cachePath = GetCachePath(); Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); File.WriteAllText(cachePath, JsonSerializer.Serialize(cache, CacheJsonOptions)); } catch (Exception ex) { LogService.Warn($"런처 인덱스 캐시 저장 실패: {ex.Message}"); } } private string GetCachePath() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "index", "launcher-index.json"); private string BuildCacheSignature() { var pathPart = string.Join("|", GetExpandedIndexPaths() .Select(path => NormalizePath(path)) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)); var extPart = string.Join("|", GetAllowedExtensions() .OrderBy(ext => ext, StringComparer.OrdinalIgnoreCase)); return $"{pathPart}::{extPart}"; } private void PublishIndexSnapshot_NoLock() { var entries = CloneEntries(_fileSystemEntries); 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 }, Score = 100 }); } RegisterBuiltInApps(entries); ComputeAllSearchCaches(entries); _index = entries; } private static List CloneEntries(IEnumerable entries) => entries.Select(entry => new IndexEntry { Name = entry.Name, DisplayName = entry.DisplayName, Path = entry.Path, Type = entry.Type, Score = entry.Score, AliasType = entry.AliasType, NameLower = entry.NameLower, NameJamo = entry.NameJamo, NameChosung = entry.NameChosung }).ToList(); public void Dispose() { _rebuildTimer?.Dispose(); _persistTimer?.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:"), ("캡처 도구 (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", 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", new[] { "edge", "엣지", "브라우저" }, @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), ("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" }, FindOfficeApp("EXCEL.EXE")), ("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" }, FindOfficeApp("POWERPNT.EXE")), ("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" }, FindOfficeApp("WINWORD.EXE")), ("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" }, FindOfficeApp("OUTLOOK.EXE")), ("Microsoft Teams", new[] { "팀즈", "teams" }, FindTeams()), ("Microsoft OneNote", new[] { "원노트", "onenote" }, FindOfficeApp("ONENOTE.EXE")), ("Microsoft Access", new[] { "액세스", "access", "msaccess" }, FindOfficeApp("MSACCESS.EXE")), ("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" }, FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")), ("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", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" }, FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")), ("OneDrive", new[] { "원드라이브", "onedrive" }, FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")), ("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" }, FindInProgramFiles(@"Google\Chrome\Application\chrome.exe") ?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")), ("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" }, FindInProgramFiles(@"Mozilla Firefox\firefox.exe")), ("Naver Whale", new[] { "웨일", "네이버웨일", "whale" }, FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")), ("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" }, FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")), ("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" }, FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")), ("Zoom", new[] { "줌", "zoom" }, FindInRoaming(@"Zoom\bin\Zoom.exe") ?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")), ("Slack", new[] { "슬랙", "slack" }, FindInLocalAppData(@"slack\slack.exe")), ("Figma", new[] { "피그마", "figma" }, FindInLocalAppData(@"Figma\Figma.exe")), ("Notepad++", new[] { "노트패드++", "npp", "notepad++" }, FindInProgramFiles(@"Notepad++\notepad++.exe")), ("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" }, FindInProgramFiles(@"7-Zip\7zFM.exe")), ("Bandizip", new[] { "반디집", "bandizip" }, FindInProgramFiles(@"Bandizip\Bandizip.exe") ?? FindInLocalAppData(@"Bandizip\Bandizip.exe")), ("ALZip", new[] { "알집", "alzip", "이스트소프트" }, FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")), ("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" }, FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe") ?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")), ("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" }, FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")), ("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", 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")), }; var nameToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); var pathToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var 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; if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc", StringComparison.OrdinalIgnoreCase)) continue; if (pathToIdx.TryGetValue(exe, out var existingIdx)) { entries[existingIdx].DisplayName = display; if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95; } var mainAdded = pathToIdx.ContainsKey(exe); foreach (var name in names) { if (nameToIdx.ContainsKey(name)) { if (nameToIdx.TryGetValue(name, out var idx) && entries[idx].Score < 95) entries[idx].Score = 95; continue; } var isKoreanName = name.Any(static c => c is >= '\uAC00' and <= '\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 { 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; 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; } private static string? FindInProgramFiles(string relativePath) { var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); var path1 = Path.Combine(pf, relativePath); if (File.Exists(path1)) return path1; var path2 = Path.Combine(pf86, relativePath); return File.Exists(path2) ? path2 : null; } 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 entry in entries) ComputeSearchCache(entry); } private static void ComputeSearchCache(IndexEntry entry) { entry.NameLower = entry.Name.ToLowerInvariant(); entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower); var sb = new 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(); } private static (int BatchSize, int DelayMs) GetThrottle(string speed) => speed switch { "fast" => (500, 0), "slow" => (50, 15), _ => (150, 5) }; 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 { var 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; entries.Add(CreateFileEntry(file)); if (delayMs > 0 && ++count % batchSize == 0) await Task.Delay(delayMs, ct); } foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly)) { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(subDir); if (name.StartsWith(".", StringComparison.Ordinal)) continue; entries.Add(CreateFolderEntry(subDir)); } } catch (UnauthorizedAccessException ex) { LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}"); } }, ct); } private static IndexEntry CreateFileEntry(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); var name = Path.GetFileNameWithoutExtension(filePath); var type = ext switch { ".exe" => IndexEntryType.App, ".lnk" or ".url" => IndexEntryType.File, _ => IndexEntryType.File }; return new IndexEntry { Name = name, DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext, Path = filePath, Type = type }; } private static IndexEntry CreateFolderEntry(string folderPath) { var name = Path.GetFileName(folderPath); return new IndexEntry { Name = name, DisplayName = name, Path = folderPath, Type = IndexEntryType.Folder }; } private static bool IsTopLevelDirectory(string rootPath, string directoryPath) { var normalizedRoot = NormalizePath(rootPath).TrimEnd(Path.DirectorySeparatorChar); var parent = Directory.GetParent(directoryPath)?.FullName; if (parent == null) return false; return string.Equals( NormalizePath(parent).TrimEnd(Path.DirectorySeparatorChar), normalizedRoot, StringComparison.OrdinalIgnoreCase); } private static string NormalizePath(string path) => Path.GetFullPath(path) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); private static LauncherIndexCacheEntry ToCacheEntry(IndexEntry entry) => new() { Name = entry.Name, DisplayName = entry.DisplayName, Path = entry.Path, Type = entry.Type, Score = entry.Score, AliasType = entry.AliasType }; private static IndexEntry FromCacheEntry(LauncherIndexCacheEntry entry) => new() { Name = entry.Name, DisplayName = entry.DisplayName, Path = entry.Path, Type = entry.Type, Score = entry.Score, AliasType = entry.AliasType }; private sealed class LauncherIndexCache { public string Signature { get; set; } = ""; public DateTime SavedAtUtc { get; set; } public List Entries { get; set; } = new(); } private sealed class LauncherIndexCacheEntry { 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; } public string? AliasType { get; set; } } } 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; } public string? AliasType { get; set; } public string NameLower { get; set; } = ""; public string NameJamo { get; set; } = ""; public string NameChosung { get; set; } = ""; } public enum IndexEntryType { App, File, Alias, Folder }