Files
AX-Copilot-Codex/src/AxCopilot/Services/IndexService.cs
lacvet 5bf323d4bf
Some checks failed
Release Gate / gate (push) Has been cancelled
런처 색인 영속 캐시와 증분 갱신 구조 적용
런처 색인 구조를 전체 재색인 중심에서 영속 캐시와 watcher 증분 반영 방식으로 재구성했다.

IndexService에 launcher-index.json 기반 캐시 로드/저장, 파일 생성·삭제·이름변경 증분 갱신, 디렉터리 고위험 변경 시 전체 재색인 폴백을 추가했다.

App 시작 시 캐시 로드와 파일 감시를 바로 시작하고, 무거운 전체 스캔은 실제 검색 시 한 번만 보강 실행하도록 정리했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:24 (KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 18:06:03 +09:00

827 lines
31 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
/// 파일 시스템 결과는 디스크 캐시에 영속 저장하고, FileSystemWatcher 이벤트는
/// 가능한 범위에서 증분 반영하여 전체 재색인 비용을 줄입니다.
/// </summary>
public class IndexService : IDisposable
{
private readonly SettingsService _settings;
private readonly List<FileSystemWatcher> _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<IndexEntry> _fileSystemEntries = new();
private List<IndexEntry> _index = new();
private const int RebuildDebounceMs = 3000;
private const int PersistDebounceMs = 800;
private static readonly JsonSerializerOptions CacheJsonOptions = new()
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
public IReadOnlyList<IndexEntry> 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<LauncherIndexCache>(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}");
}
}
/// <summary>
/// 전체 파일 시스템 항목을 다시 스캔한 뒤 디스크 캐시를 갱신합니다.
/// </summary>
public async Task BuildAsync(CancellationToken ct = default)
{
await _rebuildLock.WaitAsync(ct);
var sw = Stopwatch.StartNew();
try
{
var fileSystemEntries = new List<IndexEntry>();
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();
}
}
/// <summary>
/// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다.
/// 캐시 로드 후에도 바로 호출해 증분 반영을 유지합니다.
/// </summary>
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<string> GetExpandedIndexPaths() =>
_settings.Settings.IndexPaths
.Select(path => Environment.ExpandEnvironmentVariables(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
private HashSet<string> 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<IndexEntry> 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<LauncherIndexCacheEntry> 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<IndexEntry> CloneEntries(IEnumerable<IndexEntry> 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();
}
/// <summary>
/// 자주 사용하는 Windows 기본 앱을 한글+영문 이름으로 등록합니다.
/// 실제로 존재하는 경우에만 인덱스에 추가됩니다.
/// </summary>
private static void RegisterBuiltInApps(List<IndexEntry> 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<string, int>(StringComparer.OrdinalIgnoreCase);
var pathToIdx = new Dictionary<string, int>(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<IndexEntry> 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<IndexEntry> entries,
HashSet<string> 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<LauncherIndexCacheEntry> 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
}