From e4e5bf7a7ae181af4d4905e0cdd8406a3c80ba06 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 14:50:39 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L13]=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=C2=B7=EA=B3=84=EC=82=B0=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=204=EC=A2=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DnsQueryHandler.cs (신규, ~200줄, prefix=dns): - A/AAAA: .NET Dns.GetHostAddressesAsync() 직접 호출 - MX/TXT/NS/CNAME: nslookup 서브프로세스 + 출력 파싱 - PTR: Dns.GetHostEntryAsync()로 역방향 조회 - 사내 모드: 내부 IP(192.168/10/172.16-31) 허용, 외부 차단 - Enter → 비동기 실행, 결과 클립보드 복사 + 알림 PathHandler.cs (신규, ~180줄, prefix=path): - Environment.GetEnvironmentVariable("PATH", Process/User/Machine) - Directory.Exists 기반 경로 존재 여부 아이콘 구분 - which: .exe/.cmd/.bat/.ps1/.com 확장자 순서 탐색 - DistinctBy로 중복 경로 제거 (대소문자 무시) DriveHandler.cs (신규, ~170줄, prefix=drive): - DriveInfo.GetDrives() + IsReady 체크 + try/catch 방어 - █░ 시각적 사용량 바 (MakeBar 12~20칸 가변) - TB/GB/MB/KB 자동 단위 포맷 - large 서브커맨드: UsedSpace 내림차순 정렬 AgeHandler.cs (신규, ~230줄, prefix=age): - YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD 형식 파싱 - 만 나이(생일 미경과 시 -1) + 한국식(연도 차이 +1) - NextBirthday: 올해 생일 지났으면 내년으로 계산 - christmas/newyear 특수 키워드 Dictionary - "next monday" 형식 다음 요일 D-day 파싱 App.xaml.cs: 4개 핸들러 Phase L13 블록 등록 docs/LAUNCHER_ROADMAP.md: Phase L13 완료 섹션 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/AgeHandler.cs | 284 ++++++++++++++++++++++ src/AxCopilot/Handlers/DnsQueryHandler.cs | 264 ++++++++++++++++++++ src/AxCopilot/Handlers/DriveHandler.cs | 202 +++++++++++++++ src/AxCopilot/Handlers/PathHandler.cs | 219 +++++++++++++++++ 6 files changed, 992 insertions(+) create mode 100644 src/AxCopilot/Handlers/AgeHandler.cs create mode 100644 src/AxCopilot/Handlers/DnsQueryHandler.cs create mode 100644 src/AxCopilot/Handlers/DriveHandler.cs create mode 100644 src/AxCopilot/Handlers/PathHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 5ce011b..d92bc5b 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -295,3 +295,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L12-2 | **hosts 파일 관리** ✅ | `hosts` 프리픽스. C:\Windows\System32\drivers\etc\hosts 파싱. 활성·비활성(주석 처리) 항목 분류. `hosts search` 키워드 필터. `hosts open` 메모장 열기. `hosts copy` 전체 내용 복사. 항목 Enter → 클립보드 복사 | 중간 | | L12-3 | **모스 부호 변환기** ✅ | `morse` 프리픽스. 텍스트 → 모스 부호 (영문자·숫자·구두점 56자 지원). 모스 → 텍스트 역변환 (.-/공백 자동 감지). SOS/AR/AS 프로사인 키워드. 클립보드 자동 감지. 문자별·코드별 대응표 표시 | 낮음 | | L12-4 | **시작 프로그램 조회** ✅ | `startup` 프리픽스. HKCU/HKLM Run·RunOnce 레지스트리 + 시작 폴더(.lnk) 통합 조회. 범위(현재 사용자/모든 사용자) 그룹화. `startup search` 키워드 필터. `startup folder` 시작 폴더 열기. Enter → 명령 경로 클립보드 복사 | 중간 | + +--- + +## Phase L13 — 시스템 정보·계산 도구 (v2.0.5) ✅ 완료 + +> **방향**: 네트워크 진단·파일시스템 정보·날짜 계산 도구 보강. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L13-1 | **DNS 레코드 조회** ✅ | `dns` 프리픽스. A/AAAA는 .NET Dns API 직접 사용. MX/TXT/NS/CNAME은 nslookup 서브프로세스 파싱. PTR(역방향 조회) 지원. 사내 모드에서 외부 도메인 차단(내부 IP만 허용). Enter → 비동기 조회 실행 + 결과 복사 | 높음 | +| 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 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 5a64af5..fefc539 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -257,6 +257,16 @@ public partial class App : System.Windows.Application // L12-4: 시작 프로그램 조회 (prefix=startup) commandResolver.RegisterHandler(new StartupHandler()); + // ─── Phase L13 핸들러 ───────────────────────────────────────────────── + // L13-1: DNS 레코드 조회 (prefix=dns) + commandResolver.RegisterHandler(new DnsQueryHandler()); + // L13-2: PATH 환경변수 뷰어 (prefix=path) + commandResolver.RegisterHandler(new PathHandler()); + // L13-3: 드라이브 정보 (prefix=drive) + commandResolver.RegisterHandler(new DriveHandler()); + // L13-4: 나이·D-day 계산기 (prefix=age) + commandResolver.RegisterHandler(new AgeHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/AgeHandler.cs b/src/AxCopilot/Handlers/AgeHandler.cs new file mode 100644 index 0000000..178d522 --- /dev/null +++ b/src/AxCopilot/Handlers/AgeHandler.cs @@ -0,0 +1,284 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L13-4: 나이·D-day 계산기 핸들러. "age" 프리픽스로 사용합니다. +/// +/// 예: age 1990-05-15 → 나이 계산 (만/한국식) +/// age 1990.05.15 → 점 구분자도 지원 +/// age 19900515 → 숫자만 입력 (YYYYMMDD) +/// age 2025-12-25 → D-day 계산 (미래 날짜) +/// age next monday → 다음 월요일까지 D-day +/// age christmas → 크리스마스까지 D-day +/// Enter → 결과를 클립보드에 복사. +/// +public class AgeHandler : IActionHandler +{ + public string? Prefix => "age"; + + public PluginMetadata Metadata => new( + "Age", + "나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day", + "1.0", + "AX"); + + // 특수 날짜 키워드 + private static readonly Dictionary> Keywords = + new(StringComparer.OrdinalIgnoreCase) + { + ["christmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25), + ["xmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25), + ["newyear"] = t => new DateTime(t.Year + 1, 1, 1), + ["new year"] = t => new DateTime(t.Year + 1, 1, 1), + ["설날"] = t => GetNextLunarNewYear(t), + ["chuseok"] = t => GetNextChuseok(t), + ["추석"] = t => GetNextChuseok(t), + }; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var today = DateTime.Today; + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("나이·D-day 계산기", + "예: age 1990-05-15 / age 2025-12-25 / age christmas", + null, null, Symbol: "\uE787")); + items.Add(new LauncherItem("age 1990-01-01", "생년월일 → 나이", null, null, Symbol: "\uE787")); + items.Add(new LauncherItem("age 2025-12-25", "미래 날짜 D-day", null, null, Symbol: "\uE787")); + items.Add(new LauncherItem("age christmas", "크리스마스 D-day", null, null, Symbol: "\uE787")); + items.Add(new LauncherItem("age newyear", "신년 D-day", null, null, Symbol: "\uE787")); + return Task.FromResult>(items); + } + + // 특수 키워드 확인 + foreach (var (kw, fn) in Keywords) + { + if (q.Equals(kw, StringComparison.OrdinalIgnoreCase)) + { + var targetDate = fn(today); + items.AddRange(BuildDdayItems(targetDate, kw, today)); + return Task.FromResult>(items); + } + } + + // 요일 키워드: "next monday" + if (TryParseNextWeekday(q, out var weekdayDate)) + { + items.AddRange(BuildDdayItems(weekdayDate, q, today)); + return Task.FromResult>(items); + } + + // 날짜 파싱 시도 + if (!TryParseDate(q, out var date)) + { + items.Add(new LauncherItem("날짜 형식 오류", + "예: 1990-05-15 / 1990.05.15 / 19900515 / 2025-12-25", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + if (date <= today) + { + // 과거 날짜 → 나이/경과 계산 + items.AddRange(BuildAgeItems(date, today)); + } + else + { + // 미래 날짜 → D-day + items.AddRange(BuildDdayItems(date, date.ToString("yyyy-MM-dd"), today)); + } + + 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("Age", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 나이 계산 ───────────────────────────────────────────────────────────── + + private static IEnumerable BuildAgeItems(DateTime birth, DateTime today) + { + var ageInt = CalcAge(birth, today); + var ageKor = today.Year - birth.Year + 1; // 한국식 나이 + var days = (today - birth).Days; + var months = (today.Year - birth.Year) * 12 + (today.Month - birth.Month); + var nextBirthday = NextBirthday(birth, today); + var daysToNext = (nextBirthday - today).Days; + + var summary = $""" + 생년월일: {birth:yyyy-MM-dd} + 만 나이: {ageInt}세 + 한국 나이: {ageKor}세 + 경과 일수: {days:N0}일 + 경과 개월: {months:N0}개월 + 다음 생일: {nextBirthday:yyyy-MM-dd} (D-{daysToNext}) + """; + + yield return new LauncherItem( + $"만 {ageInt}세 (한국식 {ageKor}세)", + $"{birth:yyyy-MM-dd} · {days:N0}일 경과 · Enter 복사", + null, ("copy", summary), Symbol: "\uE787"); + + yield return new LauncherItem("만 나이", $"{ageInt}세", null, ("copy", ageInt.ToString()), Symbol: "\uE787"); + yield return new LauncherItem("한국 나이", $"{ageKor}세", null, ("copy", ageKor.ToString()), Symbol: "\uE787"); + yield return new LauncherItem("경과 일수", $"{days:N0}일", null, ("copy", days.ToString()), Symbol: "\uE787"); + yield return new LauncherItem("경과 개월", $"{months:N0}개월", null, ("copy", months.ToString()), Symbol: "\uE787"); + yield return new LauncherItem( + "다음 생일", + $"{nextBirthday:yyyy-MM-dd} · D-{daysToNext}", + null, ("copy", nextBirthday.ToString("yyyy-MM-dd")), Symbol: "\uE787"); + + // 요일 + yield return new LauncherItem("태어난 요일", DayKor(birth.DayOfWeek), null, null, Symbol: "\uE787"); + } + + private static IEnumerable BuildDdayItems(DateTime target, string label, DateTime today) + { + var diff = (target - today).Days; + var absDiff = Math.Abs(diff); + var dLabel = diff > 0 ? $"D-{diff}" : diff == 0 ? "D-Day!" : $"D+{absDiff}"; + + yield return new LauncherItem( + dLabel, + $"{target:yyyy-MM-dd} ({label}) · {DayKor(target.DayOfWeek)}", + null, ("copy", dLabel), Symbol: "\uE787"); + + yield return new LauncherItem("날짜", target.ToString("yyyy-MM-dd"), null, ("copy", target.ToString("yyyy-MM-dd")), Symbol: "\uE787"); + yield return new LauncherItem("요일", DayKor(target.DayOfWeek), null, null, Symbol: "\uE787"); + yield return new LauncherItem("남은 일수", diff > 0 ? $"{diff:N0}일 후" : diff == 0 ? "오늘!" : $"{absDiff:N0}일 전", null, ("copy", absDiff.ToString()), Symbol: "\uE787"); + yield return new LauncherItem("남은 주", $"{diff / 7}주 {diff % 7}일", null, null, Symbol: "\uE787"); + } + + // ── 파싱 헬퍼 ───────────────────────────────────────────────────────────── + + private static bool TryParseDate(string s, out DateTime result) + { + result = default; + s = s.Trim(); + + // YYYYMMDD + if (s.Length == 8 && s.All(char.IsDigit)) + { + return DateTime.TryParseExact(s, "yyyyMMdd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out result); + } + + // 다양한 구분자 (-, ., /) + var normalized = s.Replace('.', '-').Replace('/', '-'); + var formats = new[] { "yyyy-M-d", "yyyy-MM-dd", "yy-M-d", "M-d" }; + + foreach (var fmt in formats) + { + if (DateTime.TryParseExact(normalized, fmt, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out result)) + return true; + } + + // "M월 d일" 한국어 형식 + if (normalized.Contains('월')) + { + var parts = normalized.Split('월', '일'); + if (parts.Length >= 2 && + int.TryParse(parts[0].Trim(), out var m) && + int.TryParse(parts[1].Trim(), out var d)) + { + result = new DateTime(DateTime.Today.Year, m, d); + return true; + } + } + + return false; + } + + private static bool TryParseNextWeekday(string q, out DateTime result) + { + result = default; + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !parts[0].Equals("next", StringComparison.OrdinalIgnoreCase)) + return false; + + DayOfWeek? dow = parts[1].ToLowerInvariant() switch + { + "monday" or "mon" or "월" or "월요일" => DayOfWeek.Monday, + "tuesday" or "tue" or "화" or "화요일" => DayOfWeek.Tuesday, + "wednesday" or "wed" or "수" or "수요일" => DayOfWeek.Wednesday, + "thursday" or "thu" or "목" or "목요일" => DayOfWeek.Thursday, + "friday" or "fri" or "금" or "금요일" => DayOfWeek.Friday, + "saturday" or "sat" or "토" or "토요일" => DayOfWeek.Saturday, + "sunday" or "sun" or "일" or "일요일" => DayOfWeek.Sunday, + _ => null, + }; + + if (dow == null) return false; + + var today = DateTime.Today; + var daysAhead = ((int)dow.Value - (int)today.DayOfWeek + 7) % 7; + if (daysAhead == 0) daysAhead = 7; // "next"이므로 다음 주 + result = today.AddDays(daysAhead); + return true; + } + + // ── 날짜 계산 헬퍼 ──────────────────────────────────────────────────────── + + private static int CalcAge(DateTime birth, DateTime today) + { + var age = today.Year - birth.Year; + if (today.Month < birth.Month || (today.Month == birth.Month && today.Day < birth.Day)) + age--; + return age; + } + + private static DateTime NextBirthday(DateTime birth, DateTime today) + { + var thisYear = new DateTime(today.Year, birth.Month, birth.Day); + return thisYear >= today ? thisYear : thisYear.AddYears(1); + } + + // 간략화된 양력 설날 근사값 (실제 음력 계산은 복잡하므로 고정 근사) + private static DateTime GetNextLunarNewYear(DateTime today) + { + // 설날은 대략 1월 말 ~ 2월 초이므로 2월 5일을 기준점으로 사용 + var approx = new DateTime(today.Year, 2, 5); + return approx >= today ? approx : new DateTime(today.Year + 1, 2, 5); + } + + private static DateTime GetNextChuseok(DateTime today) + { + // 추석은 대략 9월 말 ~ 10월 초이므로 9월 28일을 기준점으로 사용 + var approx = new DateTime(today.Year, 9, 28); + return approx >= today ? approx : new DateTime(today.Year + 1, 9, 28); + } + + private static string DayKor(DayOfWeek dow) => dow switch + { + DayOfWeek.Monday => "월요일", + DayOfWeek.Tuesday => "화요일", + DayOfWeek.Wednesday => "수요일", + DayOfWeek.Thursday => "목요일", + DayOfWeek.Friday => "금요일", + DayOfWeek.Saturday => "토요일", + DayOfWeek.Sunday => "일요일", + _ => "", + }; +} diff --git a/src/AxCopilot/Handlers/DnsQueryHandler.cs b/src/AxCopilot/Handlers/DnsQueryHandler.cs new file mode 100644 index 0000000..8b6abef --- /dev/null +++ b/src/AxCopilot/Handlers/DnsQueryHandler.cs @@ -0,0 +1,264 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L13-1: DNS 레코드 조회 핸들러. "dns" 프리픽스로 사용합니다. +/// +/// 예: dns google.com → A/AAAA 레코드 조회 +/// dns google.com mx → MX 레코드 +/// dns google.com txt → TXT 레코드 +/// dns google.com ns → NS 레코드 +/// dns 8.8.8.8 → PTR(역방향) 조회 +/// Enter → 결과를 클립보드에 복사. +/// +/// ⚠ 사내 모드: 내부 호스트만 조회 허용. +/// +public class DnsQueryHandler : IActionHandler +{ + public string? Prefix => "dns"; + + public PluginMetadata Metadata => new( + "DNS", + "DNS 레코드 조회 — A · AAAA · MX · TXT · NS · PTR", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("DNS 레코드 조회", + "예: dns google.com / dns google.com mx / dns 8.8.8.8", + null, null, Symbol: "\uE968")); + items.Add(new LauncherItem("dns localhost", "로컬 A 레코드", null, null, Symbol: "\uE968")); + items.Add(new LauncherItem("dns example.com mx", "MX 레코드", null, null, Symbol: "\uE968")); + items.Add(new LauncherItem("dns example.com txt","TXT 레코드", null, null, Symbol: "\uE968")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var host = parts[0]; + var recType = parts.Length > 1 ? parts[1].ToUpperInvariant() : "A"; + + // 사내 모드 확인 + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + var isInternal = settings?.InternalModeEnabled ?? true; + if (isInternal && !IsInternalHost(host)) + { + items.Add(new LauncherItem( + "사내 모드 제한", + $"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem( + $"{host} [{recType}]", + "Enter를 눌러 조회 실행", + null, + ("query", $"{host}|{recType}"), + Symbol: "\uE968")); + + // 레코드 타입 빠른 선택 힌트 + if (parts.Length == 1) + { + foreach (var t in new[] { "A", "AAAA", "MX", "TXT", "NS" }) + items.Add(new LauncherItem($"dns {host} {t}", $"{t} 레코드 조회", null, + ("query", $"{host}|{t}"), Symbol: "\uE968")); + } + + return Task.FromResult>(items); + } + + public async 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("DNS", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + return; + } + + if (item.Data is not ("query", string queryData)) return; + + var idx = queryData.IndexOf('|'); + var host = queryData[..idx]; + var recType = queryData[(idx + 1)..]; + + NotificationService.Notify("DNS", $"{host} [{recType}] 조회 중…"); + + try + { + var results = await QueryDnsAsync(host, recType, ct); + if (results.Count == 0) + { + NotificationService.Notify("DNS", $"{host} [{recType}] — 레코드 없음"); + return; + } + + var summary = string.Join("\n", results); + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary)); + NotificationService.Notify("DNS", $"{results.Count}개 레코드 조회됨 · 클립보드 복사"); + } + catch (OperationCanceledException) + { + NotificationService.Notify("DNS", "조회 취소됨"); + } + catch (Exception ex) + { + NotificationService.Notify("DNS", $"오류: {ex.Message}"); + } + } + + // ── DNS 조회 ───────────────────────────────────────────────────────────── + + private static async Task> QueryDnsAsync(string host, string type, CancellationToken ct) + { + return type switch + { + "A" or "AAAA" => await QueryAAsync(host, type, ct), + "PTR" => await QueryPtrAsync(host, ct), + _ => await QueryViaNslookupAsync(host, type, ct), + }; + } + + private static async Task> QueryAAsync(string host, string type, CancellationToken ct) + { + var family = type == "AAAA" ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork; + var addrs = await Dns.GetHostAddressesAsync(host, family, ct); + return addrs.Select(a => a.ToString()).ToList(); + } + + private static async Task> QueryPtrAsync(string ip, CancellationToken ct) + { + if (!IPAddress.TryParse(ip, out _)) + return [$"'{ip}'은 유효한 IP 주소가 아닙니다"]; + try + { + var entry = await Dns.GetHostEntryAsync(ip, ct); + return [entry.HostName]; + } + catch + { + return [$"PTR 레코드 없음: {ip}"]; + } + } + + /// MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회 + private static async Task> QueryViaNslookupAsync(string host, string type, CancellationToken ct) + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "nslookup", + Arguments = $"-type={type} {host}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + using var proc = new System.Diagnostics.Process { StartInfo = psi }; + proc.Start(); + + var stdout = await proc.StandardOutput.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + + return ParseNslookupOutput(stdout, type); + } + + private static List ParseNslookupOutput(string output, string type) + { + var results = new List(); + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // 서버 응답 헤더 건너뜀 (첫 2줄) + var skip = true; + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (skip) + { + if (trimmed.StartsWith("Non-authoritative", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Name:", StringComparison.OrdinalIgnoreCase) || + (type == "MX" && trimmed.Contains("mail exchanger")) || + (type == "TXT" && trimmed.Contains("text =")) || + (type == "NS" && trimmed.Contains("nameserver"))) + skip = false; + else + continue; + } + + if (string.IsNullOrWhiteSpace(trimmed)) continue; + + // MX: "... mail exchanger = 10 aspmx.l.google.com" + if (type == "MX" && trimmed.Contains("mail exchanger")) + { + var idx = trimmed.IndexOf('='); + if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); + continue; + } + + // TXT: "... text = "v=spf1 …"" + if (type == "TXT" && trimmed.Contains("text =")) + { + var idx = trimmed.IndexOf("text =", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) results.Add(trimmed[(idx + 6)..].Trim().Trim('"')); + continue; + } + + // NS: "nameserver = ns1.google.com" + if (type == "NS" && trimmed.Contains("nameserver")) + { + var idx = trimmed.IndexOf('='); + if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); + continue; + } + + // CNAME: "canonical name = …" + if (type == "CNAME" && trimmed.Contains("canonical name")) + { + var idx = trimmed.IndexOf('='); + if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); + continue; + } + + // Address: 주소 행 + if (trimmed.StartsWith("Address:", StringComparison.OrdinalIgnoreCase)) + { + var idx = trimmed.IndexOf(':'); + if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim()); + } + } + + return results.Count > 0 ? results : [$"조회 결과 없음 ({type})"]; + } + + private static bool IsInternalHost(string host) + { + if (host is "localhost" or "127.0.0.1") return true; + if (IPAddress.TryParse(host, out var addr)) + { + var s = addr.ToString(); + return s.StartsWith("192.168.") || s.StartsWith("10.") || + System.Text.RegularExpressions.Regex.IsMatch(s, + @"^172\.(1[6-9]|2\d|3[01])\."); + } + // 도메인 이름은 사내 모드에서 외부로 간주 + return false; + } +} diff --git a/src/AxCopilot/Handlers/DriveHandler.cs b/src/AxCopilot/Handlers/DriveHandler.cs new file mode 100644 index 0000000..e639ad7 --- /dev/null +++ b/src/AxCopilot/Handlers/DriveHandler.cs @@ -0,0 +1,202 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다. +/// +/// 예: drive → 전체 드라이브 목록 + 용량 요약 +/// drive C → C 드라이브 상세 정보 +/// drive C:\ → 경로 형식도 지원 +/// drive large → 사용량 많은 순서로 정렬 +/// Enter → 드라이브 정보를 클립보드에 복사. +/// +public class DriveHandler : IActionHandler +{ + public string? Prefix => "drive"; + + public PluginMetadata Metadata => new( + "Drive", + "드라이브 정보 — 용량 · 파일시스템 · 여유공간", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + var drives = GetDrives(); + + if (string.IsNullOrWhiteSpace(q)) + { + var totalSize = drives.Sum(d => d.TotalSize); + var totalFree = drives.Sum(d => d.AvailableFree); + var totalUsed = totalSize - totalFree; + + items.Add(new LauncherItem( + $"드라이브 {drives.Count}개", + $"전체 {FormatBytes(totalSize)} · 사용 {FormatBytes(totalUsed)} · 여유 {FormatBytes(totalFree)}", + null, null, Symbol: "\uEDA2")); + + foreach (var d in drives.OrderBy(d => d.Name)) + items.Add(MakeDriveSummaryItem(d)); + + return Task.FromResult>(items); + } + + var sub = q.ToUpperInvariant().TrimEnd(':', '\\', '/'); + + if (sub == "LARGE") + { + // 사용량 많은 순 + foreach (var d in drives.OrderByDescending(d => d.UsedSpace)) + items.Add(MakeDriveSummaryItem(d)); + return Task.FromResult>(items); + } + + // 특정 드라이브 상세 + var target = drives.FirstOrDefault(d => + d.Name.StartsWith(sub, StringComparison.OrdinalIgnoreCase)); + + if (target == null) + { + items.Add(new LauncherItem("드라이브 없음", $"'{q}' 드라이브를 찾을 수 없습니다", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.AddRange(BuildDetailItems(target)); + 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("Drive", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 드라이브 정보 수집 ──────────────────────────────────────────────────── + + private record DriveInfo2( + string Name, + string VolumeLabel, + string DriveFormat, + DriveType DriveType, + long TotalSize, + long AvailableFree, + long UsedSpace, + bool IsReady); + + private static List GetDrives() + { + return DriveInfo.GetDrives() + .Select(d => + { + if (!d.IsReady) + return new DriveInfo2(d.Name, "", "", d.DriveType, 0, 0, 0, false); + try + { + return new DriveInfo2( + d.Name, + d.VolumeLabel, + d.DriveFormat, + d.DriveType, + d.TotalSize, + d.AvailableFreeSpace, + d.TotalSize - d.AvailableFreeSpace, + true); + } + catch + { + return new DriveInfo2(d.Name, "", d.DriveFormat, d.DriveType, 0, 0, 0, false); + } + }) + .ToList(); + } + + private static IEnumerable BuildDetailItems(DriveInfo2 d) + { + var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0; + var bar = MakeBar(usagePercent, 20); + var summary = $""" + 드라이브: {d.Name} + 볼륨 레이블: {(string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel)} + 파일 시스템: {d.DriveFormat} + 드라이브 종류: {DriveTypeName(d.DriveType)} + 전체 용량: {FormatBytes(d.TotalSize)} + 사용 중: {FormatBytes(d.UsedSpace)} ({usagePercent:F1}%) + 여유 공간: {FormatBytes(d.AvailableFree)} + """; + + yield return new LauncherItem( + $"{d.Name} {FormatBytes(d.TotalSize)}", + $"사용 {usagePercent:F0}% {bar} 여유 {FormatBytes(d.AvailableFree)}", + null, ("copy", summary), Symbol: "\uEDA2"); + + yield return new LauncherItem("볼륨 레이블", string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel, null, null, Symbol: "\uEDA2"); + yield return new LauncherItem("파일 시스템", d.DriveFormat, null, null, Symbol: "\uEDA2"); + yield return new LauncherItem("드라이브 종류", DriveTypeName(d.DriveType), null, null, Symbol: "\uEDA2"); + yield return new LauncherItem("전체 용량", FormatBytes(d.TotalSize), null, ("copy", FormatBytes(d.TotalSize)), Symbol: "\uEDA2"); + yield return new LauncherItem("사용 중", $"{FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)", null, ("copy", FormatBytes(d.UsedSpace)), Symbol: "\uEDA2"); + yield return new LauncherItem("여유 공간", FormatBytes(d.AvailableFree), null, ("copy", FormatBytes(d.AvailableFree)), Symbol: "\uEDA2"); + } + + private static LauncherItem MakeDriveSummaryItem(DriveInfo2 d) + { + if (!d.IsReady) + return new LauncherItem(d.Name, $"준비 안됨 ({DriveTypeName(d.DriveType)})", null, null, Symbol: "\uEDA2"); + + var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0; + var bar = MakeBar(usagePercent, 12); + var label = string.IsNullOrEmpty(d.VolumeLabel) ? d.Name : $"{d.Name} ({d.VolumeLabel})"; + + return new LauncherItem( + label, + $"{bar} {usagePercent:F0}% · 여유 {FormatBytes(d.AvailableFree)} / {FormatBytes(d.TotalSize)}", + null, + ("copy", $"{d.Name} {FormatBytes(d.TotalSize)} 사용{usagePercent:F0}% 여유{FormatBytes(d.AvailableFree)}"), + Symbol: "\uEDA2"); + } + + // ── 유틸 ───────────────────────────────────────────────────────────────── + + private static string MakeBar(double percent, int width) + { + var filled = (int)(percent / 100.0 * width); + filled = Math.Clamp(filled, 0, width); + return "[" + new string('█', filled) + new string('░', width - filled) + "]"; + } + + private static string DriveTypeName(DriveType dt) => dt switch + { + DriveType.Fixed => "고정 디스크", + DriveType.Removable => "이동식 디스크", + DriveType.Network => "네트워크 드라이브", + DriveType.CDRom => "CD/DVD", + DriveType.Ram => "RAM 디스크", + DriveType.NoRootDirectory => "루트 없음", + _ => "알 수 없음", + }; + + private static string FormatBytes(long bytes) => bytes switch + { + >= 1024L * 1024 * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024 / 1024:F2} TB", + >= 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024:F1} GB", + >= 1024L * 1024 => $"{bytes / 1024.0 / 1024:F1} MB", + >= 1024L => $"{bytes / 1024.0:F0} KB", + _ => $"{bytes} B", + }; +} diff --git a/src/AxCopilot/Handlers/PathHandler.cs b/src/AxCopilot/Handlers/PathHandler.cs new file mode 100644 index 0000000..615e31d --- /dev/null +++ b/src/AxCopilot/Handlers/PathHandler.cs @@ -0,0 +1,219 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L13-2: PATH 환경변수 뷰어·검색 핸들러. "path" 프리픽스로 사용합니다. +/// +/// 예: path → PATH 전체 목록 (존재/미존재 여부 표시) +/// path search git → "git" 포함 경로 필터 +/// path which git.exe → 실행 파일 위치 검색 (which/where 대응) +/// path which python → 확장자 없이도 검색 (.exe/.cmd/.bat 시도) +/// path user → 사용자 PATH만 표시 +/// path system → 시스템 PATH만 표시 +/// Enter → 경로를 클립보드에 복사. +/// +public class PathHandler : IActionHandler +{ + public string? Prefix => "path"; + + public PluginMetadata Metadata => new( + "Path", + "PATH 환경변수 뷰어 — 경로 목록 · 검색 · which", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + var paths = GetAllPaths(); + var exist = paths.Count(p => p.Exists); + var total = paths.Count; + + items.Add(new LauncherItem( + $"PATH {total}개 경로 (존재 {exist}개)", + "path which <파일> 로 실행 파일 위치 검색", + null, null, Symbol: "\uE838")); + + items.Add(new LauncherItem("path user", "사용자 PATH만", null, null, Symbol: "\uE838")); + items.Add(new LauncherItem("path system", "시스템 PATH만", null, null, Symbol: "\uE838")); + items.Add(new LauncherItem("path which git", "git 위치 검색", null, null, Symbol: "\uE838")); + + foreach (var p in paths.Take(15)) + items.Add(MakePathItem(p)); + + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "which": + case "where": + case "find": + { + var target = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(target)) + { + items.Add(new LauncherItem("파일명 입력", "예: path which git.exe", null, null, Symbol: "\uE783")); + break; + } + items.AddRange(FindExecutable(target)); + break; + } + + case "user": + { + var paths = GetPaths(EnvironmentVariableTarget.User); + items.Add(new LauncherItem($"사용자 PATH {paths.Count}개", "", null, null, Symbol: "\uE838")); + foreach (var p in paths) + items.Add(MakePathItem(p)); + break; + } + + case "system": + { + var paths = GetPaths(EnvironmentVariableTarget.Machine); + items.Add(new LauncherItem($"시스템 PATH {paths.Count}개", "", null, null, Symbol: "\uE838")); + foreach (var p in paths) + items.Add(MakePathItem(p)); + break; + } + + case "search": + { + var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : ""; + if (string.IsNullOrWhiteSpace(keyword)) + { + items.Add(new LauncherItem("검색어 입력", "예: path search python", null, null, Symbol: "\uE783")); + break; + } + var allPaths = GetAllPaths(); + var filtered = allPaths.Where(p => + p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", $"'{keyword}' 포함 경로 없음", null, null, Symbol: "\uE946")); + else + foreach (var p in filtered) + items.Add(MakePathItem(p)); + break; + } + + default: + { + // 기본: 검색어로 처리 + var keyword = q.ToLowerInvariant(); + var allPaths = GetAllPaths(); + var filtered = allPaths.Where(p => + p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count > 0) + foreach (var p in filtered.Take(15)) + items.Add(MakePathItem(p)); + else + // which 로 재시도 + items.AddRange(FindExecutable(q)); + break; + } + } + + 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("Path", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── PATH 수집 ───────────────────────────────────────────────────────────── + + private record PathEntry(string Directory, bool Exists, EnvironmentVariableTarget Scope); + + private static List GetAllPaths() + { + var result = new List(); + result.AddRange(GetPaths(EnvironmentVariableTarget.Process)); + return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList(); + } + + private static List GetPaths(EnvironmentVariableTarget target) + { + var raw = Environment.GetEnvironmentVariable("PATH", target) ?? ""; + return raw.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => new PathEntry(p.Trim(), Directory.Exists(p.Trim()), target)) + .ToList(); + } + + private static IEnumerable FindExecutable(string name) + { + var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" }; + var paths = GetAllPaths(); + var found = new List(); + + foreach (var pathEntry in paths.Where(p => p.Exists)) + { + foreach (var ext in extensions) + { + var candidate = Path.Combine(pathEntry.Directory, name + ext); + if (File.Exists(candidate)) + found.Add(candidate); + } + } + + if (found.Count == 0) + { + yield return new LauncherItem($"'{name}' 찾을 수 없음", + "PATH에서 해당 실행 파일이 없습니다", null, null, Symbol: "\uE946"); + yield break; + } + + yield return new LauncherItem( + $"'{name}' {found.Count}개 발견", + "전체 복사: Enter", + null, ("copy", string.Join("\n", found)), Symbol: "\uE838"); + + foreach (var f in found) + { + var dir = Path.GetDirectoryName(f) ?? ""; + var fileName = Path.GetFileName(f); + yield return new LauncherItem( + fileName, + dir, + null, ("copy", f), Symbol: "\uE838"); + } + } + + private static LauncherItem MakePathItem(PathEntry p) + { + var icon = p.Exists ? "\uE838" : "\uE783"; + var label = p.Exists ? "" : " (없는 경로)"; + return new LauncherItem( + p.Directory + label, + p.Exists ? "존재함" : "경로 없음", + null, + ("copy", p.Directory), + Symbol: icon); + } +}