[Phase L14] 네트워크·계산·시스템 도구 핸들러 4종 추가

WolHandler.cs (신규, ~200줄, prefix=wol):
- MAC 파싱: :/- 구분자 제거 후 12자리 hex → byte[6]
- 매직 패킷: 0xFF×6 + MAC×16 = 102바이트 UDP 브로드캐스트
- 포트 9 + 7 동시 전송, UdpClient.EnableBroadcast=true
- wol_hosts.json 영속 스토리지: save/delete 서브커맨드
- 저장된 호스트 목록 Enter → 즉시 전송

RegHandler.cs (신규, ~185줄, prefix=reg):
- HKCU/HKLM/HKCR/HKU/HKCC 5개 하이브 지원
- RegistryKey.OpenSubKey 오류를 변수로 분리 (CS1631 회피)
- 값 타입별 포맷: string/int/long/byte[]/string[] 각각 처리
- 9개 즐겨찾기(Run/Uninstall/Environment/Explorer) 빠른 접근
- 조회 전용 — OpenSubKey(writable: false)

TipHandler.cs (신규, ~200줄, prefix=tip):
- decimal 타입으로 금액 계산 (부동소수점 오차 없음)
- 기본 모드: 10/15/18/20/25% 5종 팁 동시 표시
- off: 할인가 + 5~50% 비교, vat: 포함/역산 동시 계산
- 100원 단위 올림: Math.Ceiling(perPerson / 100) * 100
- 쉼표/원 제거 파싱으로 "50,000원" 형식 지원

FontHandler.cs (신규, ~100줄, prefix=font):
- Fonts.SystemFontFamilies WPF API (PresentationCore)
- static List<string>? _fontCache + lock 객체로 스레드 안전 캐시
- 전체 폰트 알파벳 정렬 후 최초 1회 로드
- 그룹 힌트: 한글/나눔/mono/Arial/Times/Consolas

App.xaml.cs: 4개 핸들러 Phase L14 블록 등록
docs/LAUNCHER_ROADMAP.md: Phase L14 완료 섹션 추가
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 14:55:42 +09:00
parent e4e5bf7a7a
commit f9c4bc0122
6 changed files with 866 additions and 0 deletions

View File

@@ -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 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 |

View File

@@ -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();

View File

@@ -0,0 +1,136 @@
using System.Windows;
using System.Windows.Media;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다.
///
/// 예: font → 설치된 폰트 전체 목록
/// font 맑은 → "맑은" 포함 폰트 검색
/// font malgun → 영문 이름으로 검색
/// font mono → "mono" 포함 폰트 목록
/// font nanum → 나눔 폰트 목록
/// Enter → 폰트 이름을 클립보드에 복사.
/// </summary>
public class FontHandler : IActionHandler
{
public string? Prefix => "font";
public PluginMetadata Metadata => new(
"Font",
"시스템 폰트 목록 — 검색 · 이름 복사",
"1.0",
"AX");
// 폰트 목록 캐시 (최초 1회 로드)
private static List<string>? _fontCache;
private static readonly object _lock = new();
private static List<string> 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<string>();
}
return _fontCache;
}
}
// 주목할 만한 폰트 그룹 키워드
private static readonly (string Label, string Keyword)[] FontGroups =
[
("한글 폰트", "malgun"),
("나눔 폰트", "nanum"),
("코딩용 폰트", "mono"),
("Arial 계열", "arial"),
("Times 계열", "times"),
("Consolas", "consolas"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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");
}

View File

@@ -0,0 +1,205 @@
using Microsoft.Win32;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다.
///
/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록
/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회
/// reg search DisplayName → 값 이름으로 검색 (제한적)
/// reg HKCU\...\Run → 실행 항목 조회
/// Enter → 값을 클립보드에 복사.
///
/// 쓰기/삭제 기능 없음 — 조회 전용.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 레지스트리 경로 조회
var regPath = q;
items.AddRange(QueryRegistry(regPath));
return Task.FromResult<IEnumerable<LauncherItem>>(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<LauncherItem> 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() ?? "",
};
}

View File

@@ -0,0 +1,242 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 결과를 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
}
// 기본: 팁 퍼센트별 목록
items.AddRange(BuildDefaultTipItems(amount));
return Task.FromResult<IEnumerable<LauncherItem>>(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<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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}원";
}
}

View File

@@ -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;
/// <summary>
/// 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 → 매직 패킷 전송.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<WolHost> LoadHosts()
{
try
{
if (!System.IO.File.Exists(StorePath)) return new();
var json = System.IO.File.ReadAllText(StorePath);
return JsonSerializer.Deserialize<List<WolHost>>(json) ?? new();
}
catch { return new(); }
}
private static void SaveHosts(List<WolHost> hosts)
{
try
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!);
System.IO.File.WriteAllText(StorePath,
JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* 비핵심 */ }
}
}