diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index d92bc5b..c95aa82 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -308,3 +308,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L13-2 | **PATH 환경변수** ✅ | `path` 프리픽스. Process/User/Machine 세 범위 통합 조회. 경로 존재 여부 아이콘 표시. `path which <파일>` .exe/.cmd/.bat/.ps1 확장자 자동 시도. `path user/system` 범위별 표시. `path search` 키워드 필터 | 높음 | | L13-3 | **드라이브 정보** ✅ | `drive` 프리픽스. DriveInfo.GetDrives() 기반 전체 드라이브 목록. 고정/이동식/네트워크/CD 드라이브 종류 구분. █░ 시각적 사용량 바 그래프. `drive C` 특정 드라이브 상세. `drive large` 사용량 많은 순 정렬. TB/GB/MB/KB 자동 단위 | 중간 | | L13-4 | **나이·D-day 계산기** ✅ | `age` 프리픽스. YYYY-MM-DD / YYYYMMDD / M.d 형식 파싱. 과거 날짜 → 만 나이·한국 나이·경과 일수·다음 생일 D-day. 미래 날짜 → D-day·남은 주 계산. `age christmas/newyear` 특수 키워드. `age next monday` 다음 요일까지 D-day | 높음 | + +--- + +## Phase L14 — 네트워크·계산·시스템 도구 (v2.0.6) ✅ 완료 + +> **방향**: IT 관리·개발 실무 도구 보강 — WoL, 레지스트리 조회, 비용 계산, 폰트 검색. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L14-1 | **Wake-on-LAN** ✅ | `wol` 프리픽스. MAC 주소(AA:BB:CC:DD:EE:FF, 대시, 구분자 없음) 매직 패킷 전송. 포트 9+7 브로드캐스트(255.255.255.255). `wol save 이름 MAC`으로 호스트 저장(`wol_hosts.json`). `wol delete 이름` 삭제. 저장 항목 목록에서 Enter → 즉시 전송 | 중간 | +| L14-2 | **레지스트리 조회** ✅ | `reg` 프리픽스. HKCU/HKLM/HKCR/HKU/HKCC 모든 하이브 지원. 하위 키·값 목록 표시. 값 타입(REG_SZ/DWORD/BINARY/MULTI_SZ) 포맷 출력. 9개 즐겨찾기 경로 빠른 접근. `reg search` 즐겨찾기 필터. 조회 전용(쓰기/삭제 없음) | 높음 | +| L14-3 | **팁·할인·분할 계산기** ✅ | `tip` 프리픽스. 금액만 입력 시 10/15/18/20/25% 팁 전체 표시. `tip 금액 %` 특정 팁. `tip 금액 % 인원` 팁+분할. `tip 금액 off %` 할인가 계산. `tip 금액 vat` VAT 포함/역산. `tip 금액 / 인원` 균등 분할. 100원 단위 올림 계산 | 높음 | +| L14-4 | **시스템 폰트 목록** ✅ | `font` 프리픽스. `Fonts.SystemFontFamilies` WPF API 기반. 설치 폰트 전체 목록 캐시(최초 1회 로드). `font 맑은/nanum/mono` 키워드 필터. 한글·나눔·코딩·Arial·Times 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index fefc539..bd53c91 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -267,6 +267,16 @@ public partial class App : System.Windows.Application // L13-4: 나이·D-day 계산기 (prefix=age) commandResolver.RegisterHandler(new AgeHandler()); + // ─── Phase L14 핸들러 ───────────────────────────────────────────────── + // L14-1: Wake-on-LAN (prefix=wol) + commandResolver.RegisterHandler(new WolHandler()); + // L14-2: 레지스트리 조회 (prefix=reg) + commandResolver.RegisterHandler(new RegHandler()); + // L14-3: 팁·할인·분할 계산기 (prefix=tip) + commandResolver.RegisterHandler(new TipHandler()); + // L14-4: 시스템 폰트 목록 (prefix=font) + commandResolver.RegisterHandler(new FontHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/FontHandler.cs b/src/AxCopilot/Handlers/FontHandler.cs new file mode 100644 index 0000000..4db0d8a --- /dev/null +++ b/src/AxCopilot/Handlers/FontHandler.cs @@ -0,0 +1,136 @@ +using System.Windows; +using System.Windows.Media; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다. +/// +/// 예: font → 설치된 폰트 전체 목록 +/// font 맑은 → "맑은" 포함 폰트 검색 +/// font malgun → 영문 이름으로 검색 +/// font mono → "mono" 포함 폰트 목록 +/// font nanum → 나눔 폰트 목록 +/// Enter → 폰트 이름을 클립보드에 복사. +/// +public class FontHandler : IActionHandler +{ + public string? Prefix => "font"; + + public PluginMetadata Metadata => new( + "Font", + "시스템 폰트 목록 — 검색 · 이름 복사", + "1.0", + "AX"); + + // 폰트 목록 캐시 (최초 1회 로드) + private static List? _fontCache; + private static readonly object _lock = new(); + + private static List GetFonts() + { + if (_fontCache != null) return _fontCache; + lock (_lock) + { + if (_fontCache != null) return _fontCache; + try + { + _fontCache = Fonts.SystemFontFamilies + .Select(f => f.Source) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + _fontCache = new List(); + } + return _fontCache; + } + } + + // 주목할 만한 폰트 그룹 키워드 + private static readonly (string Label, string Keyword)[] FontGroups = + [ + ("한글 폰트", "malgun"), + ("나눔 폰트", "nanum"), + ("코딩용 폰트", "mono"), + ("Arial 계열", "arial"), + ("Times 계열", "times"), + ("Consolas", "consolas"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var fonts = GetFonts(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + $"설치된 폰트 {fonts.Count}개", + "font <검색어> 로 필터링", + null, null, Symbol: "\uE8D2")); + + // 그룹 힌트 + foreach (var (label, kw) in FontGroups) + { + var cnt = fonts.Count(f => f.Contains(kw, StringComparison.OrdinalIgnoreCase)); + if (cnt > 0) + items.Add(new LauncherItem(label, $"{cnt}개 · font {kw}", null, null, Symbol: "\uE8D2")); + } + + // 첫 15개 표시 + foreach (var f in fonts.Take(15)) + items.Add(MakeFontItem(f)); + + return Task.FromResult>(items); + } + + // 검색 + var filtered = fonts + .Where(f => f.Contains(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (filtered.Count == 0) + { + items.Add(new LauncherItem("결과 없음", + $"'{q}' 포함 폰트가 없습니다", null, null, Symbol: "\uE946")); + } + else + { + items.Add(new LauncherItem( + $"'{q}' 검색 결과 {filtered.Count}개", + "전체 복사: 첫 항목 Enter", + null, + ("copy", string.Join("\n", filtered)), + Symbol: "\uE8D2")); + + foreach (var f in filtered.Take(30)) + items.Add(MakeFontItem(f)); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Font", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + private static LauncherItem MakeFontItem(string fontName) => + new(fontName, "폰트 이름 · Enter 복사", null, ("copy", fontName), Symbol: "\uE8D2"); +} diff --git a/src/AxCopilot/Handlers/RegHandler.cs b/src/AxCopilot/Handlers/RegHandler.cs new file mode 100644 index 0000000..785f2d9 --- /dev/null +++ b/src/AxCopilot/Handlers/RegHandler.cs @@ -0,0 +1,205 @@ +using Microsoft.Win32; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다. +/// +/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록 +/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회 +/// reg search DisplayName → 값 이름으로 검색 (제한적) +/// reg HKCU\...\Run → 실행 항목 조회 +/// Enter → 값을 클립보드에 복사. +/// +/// 쓰기/삭제 기능 없음 — 조회 전용. +/// +public class RegHandler : IActionHandler +{ + public string? Prefix => "reg"; + + public PluginMetadata Metadata => new( + "Reg", + "레지스트리 조회 — HKCU · HKLM 키·값 빠른 조회", + "1.0", + "AX"); + + // 자주 쓰는 즐겨찾기 경로 + private static readonly (string Label, string Path)[] Favorites = + [ + ("현재 사용자 Run", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"), + ("모든 사용자 Run", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"), + ("설치된 프로그램 (32bit)", @"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"), + ("설치된 프로그램 (64bit)", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ("환경 변수 (사용자)", @"HKCU\Environment"), + ("환경 변수 (시스템)", @"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), + ("Internet Explorer 설정", @"HKCU\SOFTWARE\Microsoft\Internet Explorer\Main"), + ("Windows 테마", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes"), + ("탐색기 설정", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("레지스트리 조회", + "예: reg HKCU\\Software\\Microsoft / reg HKLM\\SOFTWARE\\...", + null, null, Symbol: "\uE8BE")); + items.Add(new LauncherItem("── 즐겨찾기 ──", "", null, null, Symbol: "\uE8BE")); + foreach (var (label, path) in Favorites) + items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + if (sub == "search" || sub == "find") + { + var keyword = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(keyword)) + { + items.Add(new LauncherItem("검색어 입력", "예: reg search DisplayName", null, null, Symbol: "\uE783")); + } + else + { + // 즐겨찾기 경로에서 해당 이름 검색 + items.Add(new LauncherItem($"'{keyword}' 즐겨찾기에서 검색", "일치하는 경로:", null, null, Symbol: "\uE8BE")); + foreach (var (label, path) in Favorites.Where(f => + f.Label.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + f.Path.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + { + items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE")); + } + } + return Task.FromResult>(items); + } + + // 레지스트리 경로 조회 + var regPath = q; + items.AddRange(QueryRegistry(regPath)); + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("Reg", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + break; + + case ("query", string path): + // 이미 GetItemsAsync에서 처리됨 — 재조회용 트리거 (런처 재갱신 불가 시 알림만) + NotificationService.Notify("Reg", $"조회: {path.Split('\\').Last()}"); + break; + } + return Task.CompletedTask; + } + + // ── 레지스트리 조회 ────────────────────────────────────────────────────── + + private static IEnumerable QueryRegistry(string path) + { + var (hive, subKey) = SplitPath(path); + if (hive == null) + { + yield return new LauncherItem("형식 오류", + "HKCU 또는 HKLM으로 시작하는 경로를 입력하세요", null, null, Symbol: "\uE783"); + yield break; + } + + RegistryKey? key = null; + string? openError = null; + try { key = hive.OpenSubKey(subKey, writable: false); } + catch (Exception ex) { openError = ex.Message; } + + if (openError != null) + { + yield return new LauncherItem("접근 오류", openError, null, null, Symbol: "\uE783"); + yield break; + } + + if (key == null) + { + yield return new LauncherItem("키 없음", $"'{path}' 키가 존재하지 않습니다", null, null, Symbol: "\uE946"); + yield break; + } + + using (key) + { + // 하위 키 목록 + var subKeys = key.GetSubKeyNames(); + if (subKeys.Length > 0) + { + yield return new LauncherItem($"하위 키 {subKeys.Length}개", path, null, null, Symbol: "\uE8BE"); + foreach (var sk in subKeys.Take(10)) + { + var fullPath = path.TrimEnd('\\') + @"\" + sk; + yield return new LauncherItem($"[{sk}]", fullPath, null, ("copy", fullPath), Symbol: "\uE8BE"); + } + } + + // 값 목록 + var valueNames = key.GetValueNames(); + if (valueNames.Length > 0) + { + yield return new LauncherItem($"── 값 {valueNames.Length}개 ──", "", null, null, Symbol: "\uE8BE"); + foreach (var vn in valueNames.Take(20)) + { + var val = key.GetValue(vn); + var valStr = FormatValue(val); + var display = string.IsNullOrEmpty(vn) ? "(기본값)" : vn; + yield return new LauncherItem( + display, + valStr.Length > 80 ? valStr[..80] + "…" : valStr, + null, ("copy", valStr), Symbol: "\uE8BE"); + } + } + + if (subKeys.Length == 0 && valueNames.Length == 0) + yield return new LauncherItem("빈 키", "하위 키와 값이 없습니다", null, null, Symbol: "\uE946"); + } + } + + private static (RegistryKey? Hive, string SubKey) SplitPath(string path) + { + path = path.Replace('/', '\\'); + var sep = path.IndexOf('\\'); + var hiveStr = sep >= 0 ? path[..sep].ToUpperInvariant() : path.ToUpperInvariant(); + var sub = sep >= 0 ? path[(sep + 1)..] : ""; + + var hive = hiveStr switch + { + "HKCU" or "HKEY_CURRENT_USER" => Registry.CurrentUser, + "HKLM" or "HKEY_LOCAL_MACHINE" => Registry.LocalMachine, + "HKCR" or "HKEY_CLASSES_ROOT" => Registry.ClassesRoot, + "HKU" or "HKEY_USERS" => Registry.Users, + "HKCC" or "HKEY_CURRENT_CONFIG" => Registry.CurrentConfig, + _ => null, + }; + return (hive, sub); + } + + private static string FormatValue(object? val) => val switch + { + null => "(null)", + string s => s, + int i => $"{i} (0x{i:X8})", + long l => $"{l} (0x{l:X16})", + byte[] bytes => BitConverter.ToString(bytes).Replace("-", " "), + string[] arr => string.Join(" | ", arr), + _ => val.ToString() ?? "", + }; +} diff --git a/src/AxCopilot/Handlers/TipHandler.cs b/src/AxCopilot/Handlers/TipHandler.cs new file mode 100644 index 0000000..c95b63b --- /dev/null +++ b/src/AxCopilot/Handlers/TipHandler.cs @@ -0,0 +1,242 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-3: 팁·할인·분할 계산기 핸들러. "tip" 프리픽스로 사용합니다. +/// +/// 예: tip 50000 → 50,000원에 대한 팁 퍼센트별 계산 +/// tip 50000 15 → 15% 팁 계산 +/// tip 50000 / 4 → 4명 분할 +/// tip 50000 15 4 → 15% 팁 포함 4명 분할 +/// tip 50000 off 20 → 20% 할인가 계산 +/// tip 50000 vat → 부가가치세 10% 계산 +/// Enter → 결과를 클립보드에 복사. +/// +public class TipHandler : IActionHandler +{ + public string? Prefix => "tip"; + + public PluginMetadata Metadata => new( + "Tip", + "팁·할인·분할 계산기 — 팁 % · 할인 · VAT · 인원 분할", + "1.0", + "AX"); + + private static readonly int[] DefaultTipRates = [10, 15, 18, 20, 25]; + private static readonly int[] DefaultDiscountRates = [5, 10, 15, 20, 30, 50]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("팁·할인·분할 계산기", + "예: tip 50000 / tip 50000 15 / tip 50000 off 20 / tip 50000 / 4", + null, null, Symbol: "\uE8F0")); + items.Add(new LauncherItem("tip 50000", "50,000원 팁 계산", null, null, Symbol: "\uE8F0")); + items.Add(new LauncherItem("tip 50000 15 4", "15% 팁 + 4명 분할", null, null, Symbol: "\uE8F0")); + items.Add(new LauncherItem("tip 50000 off 20", "20% 할인가", null, null, Symbol: "\uE8F0")); + items.Add(new LauncherItem("tip 50000 vat", "VAT 10% 계산", null, null, Symbol: "\uE8F0")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // 금액 파싱 (쉼표 제거) + if (!TryParseAmount(parts[0], out var amount)) + { + items.Add(new LauncherItem("금액 형식 오류", + "예: tip 50000 또는 tip 50,000", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 서브커맨드 분기 + if (parts.Length >= 2) + { + var sub2 = parts[1].ToLowerInvariant(); + + // 할인: tip 50000 off 20 + if (sub2 is "off" or "discount" or "할인") + { + var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? r : 10; + items.AddRange(BuildDiscountItems(amount, (double)rate)); + return Task.FromResult>(items); + } + + // VAT: tip 50000 vat [rate] + if (sub2 is "vat" or "세금" or "tax") + { + var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? (double)r : 10.0; + items.AddRange(BuildVatItems(amount, rate)); + return Task.FromResult>(items); + } + + // 분할: tip 50000 / 4 + if (sub2 == "/" && parts.Length >= 3 && TryParseAmount(parts[2], out var people2)) + { + items.AddRange(BuildSplitItems(amount, 0, (int)people2)); + return Task.FromResult>(items); + } + + // 팁%: tip 50000 15 또는 tip 50000 15 4 + if (TryParseAmount(parts[1], out var tipRate)) + { + var people = parts.Length >= 3 && TryParseAmount(parts[2], out var p) ? (int)p : 1; + items.AddRange(BuildTipItems(amount, (double)tipRate, people)); + return Task.FromResult>(items); + } + } + + // 기본: 팁 퍼센트별 목록 + items.AddRange(BuildDefaultTipItems(amount)); + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Tip", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 계산 빌더 ───────────────────────────────────────────────────────────── + + private static IEnumerable BuildDefaultTipItems(decimal amount) + { + yield return new LauncherItem( + $"원금 {FormatKrw(amount)}", + "팁 퍼센트별 합계", + null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0"); + + foreach (var rate in DefaultTipRates) + { + var tip = amount * rate / 100; + var total = amount + tip; + yield return new LauncherItem( + $"{rate}% → {FormatKrw(total)}", + $"팁 {FormatKrw(tip)} · 합계 {FormatKrw(total)}", + null, ("copy", FormatKrw(total)), Symbol: "\uE8F0"); + } + + // 분할 미리보기 + yield return new LauncherItem($"2명 분할", FormatKrw(amount / 2), null, ("copy", FormatKrw(amount / 2)), Symbol: "\uE8F0"); + yield return new LauncherItem($"4명 분할", FormatKrw(amount / 4), null, ("copy", FormatKrw(amount / 4)), Symbol: "\uE8F0"); + } + + private static IEnumerable BuildTipItems(decimal amount, double tipPct, int people) + { + var tip = amount * (decimal)tipPct / 100; + var total = amount + tip; + var perPerson = people > 1 ? total / people : total; + + yield return new LauncherItem( + $"합계 {FormatKrw(total)}", + $"원금 {FormatKrw(amount)} + 팁 {tipPct}% ({FormatKrw(tip)})", + null, ("copy", FormatKrw(total)), Symbol: "\uE8F0"); + + yield return new LauncherItem("원금", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0"); + yield return new LauncherItem($"팁 {tipPct}%", FormatKrw(tip), null, ("copy", FormatKrw(tip)), Symbol: "\uE8F0"); + yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0"); + + if (people > 1) + { + yield return new LauncherItem( + $"{people}명 분할", + $"1인당 {FormatKrw(perPerson)}", + null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0"); + } + } + + private static IEnumerable BuildDiscountItems(decimal amount, double discountPct) + { + var discount = amount * (decimal)discountPct / 100; + var discounted = amount - discount; + + yield return new LauncherItem( + $"할인가 {FormatKrw(discounted)}", + $"{discountPct}% 할인 (할인액 {FormatKrw(discount)})", + null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0"); + + yield return new LauncherItem("원가", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0"); + yield return new LauncherItem($"할인 {discountPct}%", FormatKrw(discount), null, ("copy", FormatKrw(discount)), Symbol: "\uE8F0"); + yield return new LauncherItem("할인가", FormatKrw(discounted), null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0"); + + // 다른 할인율 비교 + yield return new LauncherItem("── 할인율 비교 ──", "", null, null, Symbol: "\uE8F0"); + foreach (var rate in DefaultDiscountRates.Where(r => r != (int)discountPct)) + { + var d = amount * rate / 100; + yield return new LauncherItem($"{rate}% → {FormatKrw(amount - d)}", + $"할인 {FormatKrw(d)}", null, ("copy", FormatKrw(amount - d)), Symbol: "\uE8F0"); + } + } + + private static IEnumerable BuildVatItems(decimal amount, double vatRate) + { + var vat = amount * (decimal)vatRate / 100; + var withVat = amount + vat; + var exVat = amount / (1 + (decimal)vatRate / 100); + var vatOnly = amount - exVat; + + yield return new LauncherItem( + $"VAT 포함 {FormatKrw(withVat)}", + $"VAT {vatRate}% ({FormatKrw(vat)})", + null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0"); + + yield return new LauncherItem($"입력액 (VAT 별도)", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0"); + yield return new LauncherItem($"VAT {vatRate}%", FormatKrw(vat), null, ("copy", FormatKrw(vat)), Symbol: "\uE8F0"); + yield return new LauncherItem("VAT 포함 합계", FormatKrw(withVat), null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0"); + yield return new LauncherItem("── 역산 (VAT 포함가 입력 시) ──", "", null, null, Symbol: "\uE8F0"); + yield return new LauncherItem("공급가액 (VAT 제외)", $"{FormatKrw(exVat)}", null, ("copy", FormatKrw(exVat)), Symbol: "\uE8F0"); + yield return new LauncherItem("VAT 금액", $"{FormatKrw(vatOnly)}", null, ("copy", FormatKrw(vatOnly)), Symbol: "\uE8F0"); + } + + private static IEnumerable BuildSplitItems(decimal amount, double tipPct, int people) + { + if (people <= 0) people = 1; + var tip = tipPct > 0 ? amount * (decimal)tipPct / 100 : 0; + var total = amount + tip; + var perPerson = total / people; + var rounded = Math.Ceiling(perPerson / 100) * 100; // 100원 단위 올림 + + yield return new LauncherItem( + $"{people}명 분할 1인 {FormatKrw(perPerson)}", + $"합계 {FormatKrw(total)}", + null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0"); + + yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0"); + yield return new LauncherItem($"1인 (정확)", FormatKrw(perPerson), null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0"); + yield return new LauncherItem($"1인 (100원↑)", FormatKrw(rounded), null, ("copy", FormatKrw(rounded)), Symbol: "\uE8F0"); + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static bool TryParseAmount(string s, out decimal result) + { + result = 0; + s = s.Replace(",", "").Replace("원", "").Trim(); + return decimal.TryParse(s, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out result); + } + + private static string FormatKrw(decimal amount) + { + if (amount == Math.Floor(amount)) + return $"{amount:N0}원"; + return $"{amount:N2}원"; + } +} diff --git a/src/AxCopilot/Handlers/WolHandler.cs b/src/AxCopilot/Handlers/WolHandler.cs new file mode 100644 index 0000000..68845ff --- /dev/null +++ b/src/AxCopilot/Handlers/WolHandler.cs @@ -0,0 +1,260 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-1: Wake-on-LAN 핸들러. "wol" 프리픽스로 사용합니다. +/// +/// 예: wol → 저장된 호스트 목록 +/// wol AA:BB:CC:DD:EE:FF → 매직 패킷 전송 +/// wol AA-BB-CC-DD-EE-FF → 대시 구분자도 지원 +/// wol AABBCCDDEEFF → 구분자 없는 형식 +/// wol save PC-1 AA:BB:CC:DD:EE:FF → 호스트 저장 +/// wol delete PC-1 → 저장 항목 삭제 +/// Enter → 매직 패킷 전송. +/// +public class WolHandler : IActionHandler +{ + public string? Prefix => "wol"; + + public PluginMetadata Metadata => new( + "WoL", + "Wake-on-LAN — 매직 패킷 전송 · 호스트 관리", + "1.0", + "AX"); + + private static readonly string StorePath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "wol_hosts.json"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var hosts = LoadHosts(); + + if (string.IsNullOrWhiteSpace(q)) + { + if (hosts.Count == 0) + { + items.Add(new LauncherItem("Wake-on-LAN", + "예: wol AA:BB:CC:DD:EE:FF / wol save 이름 AA:BB:CC:...", + null, null, Symbol: "\uE823")); + } + else + { + items.Add(new LauncherItem($"저장된 호스트 {hosts.Count}개", + "Enter → 매직 패킷 전송", null, null, Symbol: "\uE823")); + foreach (var h in hosts) + items.Add(MakeHostItem(h)); + } + items.Add(new LauncherItem("wol save 이름 MAC", "호스트 저장", null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("wol AA:BB:CC:…", "직접 전송", null, null, Symbol: "\uE823")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "save": + case "add": + { + if (parts.Length < 3) + { + items.Add(new LauncherItem("형식 오류", "예: wol save PC-이름 AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE783")); + break; + } + var name = parts[1]; + var mac = parts[2]; + if (!TryParseMac(mac, out var macBytes)) + { + items.Add(new LauncherItem("MAC 형식 오류", $"'{mac}'은 유효한 MAC 주소가 아닙니다", null, null, Symbol: "\uE783")); + break; + } + items.Add(new LauncherItem( + $"저장: {name} ({mac})", + "Enter → 저장", + null, ("save", $"{name}|{mac}"), Symbol: "\uE823")); + break; + } + + case "delete": + case "del": + case "remove": + { + var target = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : ""; + var found = hosts.FirstOrDefault(h => + h.Name.Equals(target, StringComparison.OrdinalIgnoreCase)); + if (found == null) + items.Add(new LauncherItem("없는 항목", $"'{target}'을 찾을 수 없습니다", null, null, Symbol: "\uE783")); + else + items.Add(new LauncherItem($"삭제: {found.Name}", found.Mac, null, ("delete", found.Name), Symbol: "\uE74D")); + break; + } + + default: + { + // MAC 주소 직접 입력 + if (TryParseMac(q, out _)) + { + items.Add(new LauncherItem( + $"매직 패킷 전송: {q}", + "Enter → 브로드캐스트 전송 (255.255.255.255:9)", + null, ("send", q), Symbol: "\uE823")); + } + // 저장된 호스트 이름 검색 + else + { + var filtered = hosts.Where(h => + h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", + $"'{q}' 항목 없음. MAC 형식: AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE946")); + else + foreach (var h in filtered) + items.Add(MakeHostItem(h)); + } + break; + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("send", string mac): + SendMagicPacket(mac); + break; + + case ("wake", string mac): + SendMagicPacket(mac); + break; + + case ("save", string data): + { + var idx = data.IndexOf('|'); + var name = data[..idx]; + var mac = data[(idx + 1)..]; + var hosts = LoadHosts(); + hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + hosts.Add(new WolHost { Name = name, Mac = mac }); + SaveHosts(hosts); + NotificationService.Notify("WoL", $"'{name}' ({mac}) 저장됨"); + break; + } + + case ("delete", string name): + { + var hosts = LoadHosts(); + hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + SaveHosts(hosts); + NotificationService.Notify("WoL", $"'{name}' 삭제됨"); + break; + } + + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("WoL", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + break; + } + return Task.CompletedTask; + } + + // ── 매직 패킷 전송 ──────────────────────────────────────────────────────── + + private static void SendMagicPacket(string mac) + { + if (!TryParseMac(mac, out var macBytes)) + { + NotificationService.Notify("WoL", $"MAC 형식 오류: {mac}"); + return; + } + + // 매직 패킷: 0xFF × 6 + MAC × 16 + var packet = new byte[102]; + for (var i = 0; i < 6; i++) + packet[i] = 0xFF; + for (var i = 1; i <= 16; i++) + Array.Copy(macBytes, 0, packet, i * 6, 6); + + try + { + using var udp = new UdpClient(); + udp.EnableBroadcast = true; + udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 9)); + // 포트 7도 함께 전송 (일부 장치) + udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 7)); + NotificationService.Notify("WoL", $"매직 패킷 전송됨 → {mac}"); + } + catch (Exception ex) + { + NotificationService.Notify("WoL", $"전송 실패: {ex.Message}"); + } + } + + // ── 파싱·저장 헬퍼 ──────────────────────────────────────────────────────── + + private static bool TryParseMac(string s, out byte[] bytes) + { + bytes = []; + s = s.Trim().Replace(":", "").Replace("-", "").Replace(".", ""); + if (s.Length != 12) return false; + try + { + bytes = Enumerable.Range(0, 6) + .Select(i => Convert.ToByte(s.Substring(i * 2, 2), 16)) + .ToArray(); + return true; + } + catch { return false; } + } + + private static LauncherItem MakeHostItem(WolHost h) => + new(h.Name, h.Mac, null, ("wake", h.Mac), Symbol: "\uE823"); + + // ── 영속 스토리지 ───────────────────────────────────────────────────────── + + private class WolHost + { + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("mac")] public string Mac { get; set; } = ""; + } + + private static List LoadHosts() + { + try + { + if (!System.IO.File.Exists(StorePath)) return new(); + var json = System.IO.File.ReadAllText(StorePath); + return JsonSerializer.Deserialize>(json) ?? new(); + } + catch { return new(); } + } + + private static void SaveHosts(List hosts) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!); + System.IO.File.WriteAllText(StorePath, + JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true })); + } + catch { /* 비핵심 */ } + } +}