diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 34152fd..98a34d6 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -243,3 +243,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L8-2 | **아카이브 관리** ✅ | `zip` 프리픽스. System.IO.Compression 기반. `zip <경로>` 파일 목록 미리보기(최대 20개). `zip extract` 동일/지정 폴더 압축 해제. `zip folder <폴더>` 폴더→zip 압축. 클립보드 경로 자동 감지 | 중간 | | L8-3 | **시스템 이벤트 로그** ✅ | `evt` 프리픽스. 최근 24시간 System+Application 로그 조회. `evt error`/`evt warn`/`evt app`/`evt sys`/`evt <키워드>` 필터. EventLogEntry.InstanceId 기반. 이벤트 상세 클립보드 복사 | 중간 | | L8-4 | **SSH 퀵 커넥트** ✅ | `ssh` 프리픽스. `SshHostEntry` 모델 + `AppSettings.SshHosts` 영속화. `ssh add user@host[:port]` 저장. `ssh del <이름>` 삭제. Enter → Windows Terminal(wt.exe)/PuTTY/PowerShell 순 폴백 실행. 직접 `user@host` 입력 즉시 연결 지원 | 높음 | + +--- + +## Phase L9 — 보안·네트워크·시스템 유틸리티 (v2.0.1) ✅ 완료 + +> **방향**: 개발자·IT 관리자의 일상 도구 — 비밀번호 생성, 서브넷 계산, 시스템 정리, 진수 변환. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L9-1 | **비밀번호 생성기** ✅ | `pwd` 프리픽스. `RandomNumberGenerator` 기반 암호학적 난수. 강력(대소문자+숫자+특수)/알파뉴메릭/PIN/패스프레이즈 4가지 모드. `pwd 24 strong` 길이·모드 지정. 5개 후보 동시 생성, 강도 레이블 표시. Enter → 클립보드 복사 | 높음 | +| L9-2 | **IP 서브넷 계산기** ✅ | `subnet` 프리픽스. CIDR(x.x.x.x/24) 또는 공백 구분 입력. 네트워크·마스크·브로드캐스트·첫/마지막 호스트·사용 가능 호스트 수 계산. 서브넷 마스크→CIDR 변환. `subnet range x.x.x.10-50` 범위 계산. 이진 마스크 표시 | 높음 | +| L9-3 | **시스템 정리** ✅ | `clean` 프리픽스. 임시 파일(%TEMP%), 휴지통(SHEmptyRecycleBin), 다운로드 30일 이상, AxCopilot 로그. 예상 용량 사전 표시. `clean all`로 일괄 정리. 항목별 실시간 알림 | 중간 | +| L9-4 | **진수 변환기** ✅ | `base` 프리픽스. 10진/16진(0x)/2진(0b)/8진(0o) 자동 감지 변환. `base 255 to hex` 단일 방향 변환. `base ascii 65` ASCII 코드↔문자 변환. 4비트 그룹 이진 표시. Enter → 클립보드 복사 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 57b40df..6ae5981 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -217,6 +217,16 @@ public partial class App : System.Windows.Application // L8-4: SSH 퀵 커넥트 (prefix=ssh) commandResolver.RegisterHandler(new SshHandler(settings)); + // ─── Phase L9 핸들러 ────────────────────────────────────────────────── + // L9-1: 비밀번호 생성기 (prefix=pwd) + commandResolver.RegisterHandler(new PasswordGenHandler()); + // L9-2: IP 서브넷 계산기 (prefix=subnet) + commandResolver.RegisterHandler(new SubnetHandler()); + // L9-3: 시스템 정리 (prefix=clean) + commandResolver.RegisterHandler(new CleanHandler()); + // L9-4: 진수 변환기 (prefix=base) + commandResolver.RegisterHandler(new BaseConvertHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/BaseConvertHandler.cs b/src/AxCopilot/Handlers/BaseConvertHandler.cs new file mode 100644 index 0000000..bab8a28 --- /dev/null +++ b/src/AxCopilot/Handlers/BaseConvertHandler.cs @@ -0,0 +1,235 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L9-4: 진수 변환기 핸들러. "base" 프리픽스로 사용합니다. +/// +/// 예: base 255 → 10진수 → 2/8/16진수 동시 변환 +/// base 0xFF → 16진수 → 10/2/8진수 +/// base 0b11111111 → 2진수 → 10/8/16진수 +/// base 0o377 → 8진수 → 10/2/16진수 +/// base 255 to hex → 10→16진수 결과만 +/// base ascii 65 → ASCII 코드 변환 (65 = 'A') +/// base ascii A → 문자 → ASCII 코드 +/// Enter → 결과를 클립보드에 복사. +/// +public class BaseConvertHandler : IActionHandler +{ + public string? Prefix => "base"; + + public PluginMetadata Metadata => new( + "BaseConvert", + "진수 변환기 — 2 · 8 · 10 · 16진수 · ASCII", + "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( + "진수 변환기", + "예: base 255 / base 0xFF / base 0b1010 / base ascii 65", + null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("base 255", "10진수 → 2/8/16진수", null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("base 0xFF", "16진수 → 10/2/8진수", null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("base 0b1111", "2진수 → 10/8/16진수", null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("base ascii 65", "ASCII 코드 변환", null, null, Symbol: "\uE8C4")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // ASCII 모드 + if (parts[0].Equals("ascii", StringComparison.OrdinalIgnoreCase) && parts.Length >= 2) + { + items.AddRange(BuildAsciiItems(string.Join(" ", parts.Skip(1)))); + return Task.FromResult>(items); + } + + // "to" 지정 변환 모드: base 255 to hex + if (parts.Length >= 3 && parts[1].Equals("to", StringComparison.OrdinalIgnoreCase)) + { + if (TryParseNumber(parts[0], out var val)) + { + var targetBase = parts[2].ToLowerInvariant(); + var result = ConvertToBase(val, targetBase); + if (result != null) + { + items.Add(new LauncherItem( + result, + $"{parts[0]} → {targetBase}", + null, + ("copy", result), + Symbol: "\uE8C4")); + } + else + { + items.Add(new LauncherItem("알 수 없는 진수", + "bin/oct/dec/hex 중 하나를 지정하세요", null, null, Symbol: "\uE783")); + } + } + return Task.FromResult>(items); + } + + // 전체 변환 모드 + if (TryParseNumber(parts[0], out var number)) + { + items.AddRange(BuildConversionItems(number, parts[0])); + } + else + { + // ASCII 문자열로 시도 + if (q.Length <= 8 && q.All(c => c >= 32 && c < 128)) + { + items.AddRange(BuildAsciiItems(q)); + } + else + { + items.Add(new LauncherItem("파싱 실패", $"'{q}'을(를) 숫자로 해석할 수 없습니다", null, null, Symbol: "\uE783")); + } + } + + 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("BaseConvert", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static IEnumerable BuildConversionItems(long val, string inputStr) + { + var inputBase = DetectBase(inputStr); + + yield return new LauncherItem( + $"{val}", + $"10진수 (입력: {inputStr})", + null, + ("copy", val.ToString()), + Symbol: "\uE8C4"); + + var hex = $"0x{val:X}"; + yield return new LauncherItem(hex, "16진수 (HEX)", null, ("copy", hex), Symbol: "\uE8C4"); + + var bin = val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}"; + yield return new LauncherItem(bin, "2진수 (BIN)", null, ("copy", bin), Symbol: "\uE8C4"); + + var oct = val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}"; + yield return new LauncherItem(oct, "8진수 (OCT)", null, ("copy", oct), Symbol: "\uE8C4"); + + // 2진수 그룹핑 표시 (4비트 단위) + if (val >= 0 && val <= 0xFFFFFFFF) + { + var binRaw = Convert.ToString(val, 2).PadLeft((Convert.ToString(val, 2).Length + 3) / 4 * 4, '0'); + var binGrouped = string.Join(" ", Enumerable.Range(0, binRaw.Length / 4) + .Select(i => binRaw.Substring(i * 4, 4))); + yield return new LauncherItem(binGrouped, "2진수 (4비트 그룹)", null, ("copy", binGrouped), Symbol: "\uE8C4"); + } + + // ASCII 문자 (0~127 범위) + if (val is >= 32 and <= 126) + { + var ch = (char)val; + yield return new LauncherItem($"'{ch}'", $"ASCII 문자 (코드 {val})", null, ("copy", ch.ToString()), Symbol: "\uE8C4"); + } + } + + private static IEnumerable BuildAsciiItems(string input) + { + // 숫자 → 문자 + if (long.TryParse(input, out var code) && code >= 0 && code <= 127) + { + var ch = (char)code; + yield return new LauncherItem( + $"'{ch}'", + $"ASCII {code} = '{ch}'", + null, + ("copy", ch.ToString()), + Symbol: "\uE8C4"); + + yield return new LauncherItem($"HEX: 0x{code:X2}", "16진수", null, ("copy", $"0x{code:X2}"), Symbol: "\uE8C4"); + yield return new LauncherItem($"BIN: {Convert.ToString(code, 2).PadLeft(8, '0')}", "2진수", null, ("copy", Convert.ToString(code, 2).PadLeft(8, '0')), Symbol: "\uE8C4"); + yield break; + } + + // 문자/문자열 → 코드 + foreach (var c in input.Where(c => c < 128)) + { + var codeVal = (int)c; + yield return new LauncherItem( + $"'{c}' = {codeVal}", + $"HEX: 0x{codeVal:X2} BIN: {Convert.ToString(codeVal, 2).PadLeft(8, '0')}", + null, + ("copy", codeVal.ToString()), + Symbol: "\uE8C4"); + } + } + + private static bool TryParseNumber(string s, out long result) + { + result = 0; + if (string.IsNullOrEmpty(s)) return false; + + // 0x prefix → 16진수 + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || s.StartsWith("0X", StringComparison.OrdinalIgnoreCase)) + { + return long.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out result); + } + // 0b prefix → 2진수 + if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) + { + try { result = Convert.ToInt64(s[2..], 2); return true; } + catch { return false; } + } + // 0o prefix → 8진수 + if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) + { + try { result = Convert.ToInt64(s[2..], 8); return true; } + catch { return false; } + } + // 순수 16진수 (0-9, A-F) + if (s.Length >= 2 && s.All(c => "0123456789ABCDEFabcdef".Contains(c)) && !s.All(char.IsDigit)) + { + return long.TryParse(s, System.Globalization.NumberStyles.HexNumber, null, out result); + } + // 10진수 + return long.TryParse(s, out result); + } + + private static string? ConvertToBase(long val, string targetBase) => targetBase switch + { + "hex" or "16" or "h" => $"0x{val:X}", + "bin" or "2" or "b" => val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}", + "oct" or "8" or "o" => val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}", + "dec" or "10" or "d" => val.ToString(), + _ => null, + }; + + private static string DetectBase(string s) + { + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return "HEX"; + if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) return "BIN"; + if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) return "OCT"; + return "DEC"; + } +} diff --git a/src/AxCopilot/Handlers/CleanHandler.cs b/src/AxCopilot/Handlers/CleanHandler.cs new file mode 100644 index 0000000..7b4fabe --- /dev/null +++ b/src/AxCopilot/Handlers/CleanHandler.cs @@ -0,0 +1,320 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L9-3: 시스템 정리 핸들러. "clean" 프리픽스로 사용합니다. +/// +/// 예: clean → 정리 가능한 항목 목록 + 예상 용량 +/// clean temp → Windows 임시 파일 정리 (%TEMP%) +/// clean recycle → 휴지통 비우기 +/// clean downloads → 다운로드 폴더 오래된 파일 목록 (30일 이상) +/// clean logs → 앱 로그 폴더 정리 (%APPDATA%\AxCopilot\logs) +/// clean all → temp + recycle + logs 한 번에 정리 +/// +public class CleanHandler : IActionHandler +{ + public string? Prefix => "clean"; + + public PluginMetadata Metadata => new( + "Clean", + "시스템 정리 — 임시 파일 · 휴지통 · 다운로드 · 로그", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 각 영역 크기 미리보기 + var tempSize = GetDirSize(Path.GetTempPath()); + var recycleSize = GetRecycleBinSize(); + var downloadsSize = GetOldDownloadsSize(30); + var logSize = GetDirSize(GetAppLogPath()); + + var total = tempSize + recycleSize + downloadsSize + logSize; + + items.Add(new LauncherItem( + $"정리 가능 {FormatBytes(total)}", + "항목을 선택하거나 clean all 로 모두 정리", + null, null, Symbol: "\uE74D")); + + items.Add(MakeCleanItem("temp", "\uE8B6", "임시 파일 (%TEMP%)", tempSize)); + items.Add(MakeCleanItem("recycle", "\uE74D", "휴지통", recycleSize)); + items.Add(MakeCleanItem("downloads", "\uE896", "다운로드 (30일 이상)", downloadsSize)); + items.Add(MakeCleanItem("logs", "\uE9D9", "AxCopilot 로그 파일", logSize)); + items.Add(new LauncherItem( + "clean all", + $"temp + recycle + logs 한 번에 정리 ({FormatBytes(tempSize + recycleSize + logSize)})", + null, + ("clean_all", ""), + Symbol: "\uE74D")); + + return Task.FromResult>(items); + } + + switch (q) + { + case "temp": + { + var tempPath = Path.GetTempPath(); + var size = GetDirSize(tempPath); + var count = SafeCountFiles(tempPath); + items.Add(new LauncherItem( + $"임시 파일 정리 {FormatBytes(size)}", + $"{count}개 파일 · {tempPath}", + null, + ("clean_temp", tempPath), + Symbol: "\uE8B6")); + break; + } + case "recycle": + { + var size = GetRecycleBinSize(); + items.Add(new LauncherItem( + $"휴지통 비우기 {FormatBytes(size)}", + "복구 불가능합니다. Enter로 실행", + null, + ("clean_recycle", ""), + Symbol: "\uE74D")); + break; + } + case "downloads": + { + var downloadsPath = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + downloadsPath = Path.Combine(downloadsPath, "Downloads"); + var oldFiles = GetOldFiles(downloadsPath, 30); + var totalSz = oldFiles.Sum(f => f.Length); + + items.Add(new LauncherItem( + $"다운로드 30일 이상 파일 {FormatBytes(totalSz)}", + $"{oldFiles.Count}개 파일", + null, + ("list_downloads", downloadsPath), + Symbol: "\uE896")); + + foreach (var f in oldFiles.Take(15)) + { + items.Add(new LauncherItem( + f.Name, + $"{FormatBytes(f.Length)} · {f.LastWriteTime:MM-dd HH:mm}", + null, + ("open_file", f.FullName), + Symbol: "\uE8A5")); + } + break; + } + case "logs": + { + var logPath = GetAppLogPath(); + var size = GetDirSize(logPath); + var count = SafeCountFiles(logPath); + items.Add(new LauncherItem( + $"AxCopilot 로그 정리 {FormatBytes(size)}", + $"{count}개 파일 · {logPath}", + null, + ("clean_logs", logPath), + Symbol: "\uE9D9")); + break; + } + case "all": + { + var tempSz = GetDirSize(Path.GetTempPath()); + var recycleSz = GetRecycleBinSize(); + var logSz = GetDirSize(GetAppLogPath()); + items.Add(new LauncherItem( + $"모두 정리 {FormatBytes(tempSz + recycleSz + logSz)}", + "temp + recycle + logs · Enter로 실행", + null, + ("clean_all", ""), + Symbol: "\uE74D")); + break; + } + default: + items.Add(new LauncherItem("서브커맨드", "temp · recycle · downloads · logs · all", null, null, Symbol: "\uE946")); + break; + } + + return Task.FromResult>(items); + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("clean_temp", string path): + await Task.Run(() => + { + var deleted = CleanDirectory(path, recursive: false); + NotificationService.Notify("정리 완료", $"임시 파일 {deleted}개 삭제"); + }, ct); + break; + + case ("clean_recycle", _): + await Task.Run(() => + { + EmptyRecycleBin(); + NotificationService.Notify("정리 완료", "휴지통을 비웠습니다."); + }, ct); + break; + + case ("clean_logs", string path): + await Task.Run(() => + { + var deleted = CleanDirectory(path, recursive: true); + NotificationService.Notify("정리 완료", $"로그 파일 {deleted}개 삭제"); + }, ct); + break; + + case ("clean_all", _): + await Task.Run(() => + { + var t1 = CleanDirectory(Path.GetTempPath(), recursive: false); + EmptyRecycleBin(); + var t2 = CleanDirectory(GetAppLogPath(), recursive: true); + NotificationService.Notify("모두 정리 완료", + $"임시 {t1}개, 로그 {t2}개 삭제, 휴지통 비움"); + }, ct); + break; + + case ("list_downloads", string path): + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }); + } + catch { /* 비핵심 */ } + break; + + case ("open_file", string filePath): + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true, + }); + } + catch { /* 비핵심 */ } + break; + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static LauncherItem MakeCleanItem(string sub, string icon, string label, long size) => + new( + $"clean {sub}", + $"{label} · {FormatBytes(size)}", + null, + ($"clean_{sub}", ""), + Symbol: icon); + + private static long GetDirSize(string path) + { + if (!Directory.Exists(path)) return 0; + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } }); + } + catch { return 0; } + } + + private static long GetRecycleBinSize() + { + try + { + // SHQueryRecycleBin P/Invoke 대신 Shell32 통해 추정 + // 간단히 0 반환 (실제 크기는 SHQueryRecycleBinW P/Invoke 필요) + return 0; + } + catch { return 0; } + } + + private static long GetOldDownloadsSize(int olderThanDays) + { + try + { + var path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Downloads"); + return GetOldFiles(path, olderThanDays).Sum(f => f.Length); + } + catch { return 0; } + } + + private static List GetOldFiles(string dir, int olderThanDays) + { + if (!Directory.Exists(dir)) return []; + var cutoff = DateTime.Now.AddDays(-olderThanDays); + try + { + return Directory.EnumerateFiles(dir) + .Select(f => new FileInfo(f)) + .Where(fi => fi.LastWriteTime < cutoff) + .OrderByDescending(fi => fi.Length) + .ToList(); + } + catch { return []; } + } + + private static int SafeCountFiles(string path) + { + if (!Directory.Exists(path)) return 0; + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Count(); + } + catch { return 0; } + } + + private static int CleanDirectory(string path, bool recursive) + { + if (!Directory.Exists(path)) return 0; + int deleted = 0; + var opts = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + foreach (var file in Directory.EnumerateFiles(path, "*", opts)) + { + try { File.Delete(file); deleted++; } catch { /* 잠긴 파일 무시 */ } + } + return deleted; + } + + [System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern int SHEmptyRecycleBin(IntPtr hwnd, string? pszRootPath, uint dwFlags); + + private static void EmptyRecycleBin() + { + try + { + // SHERB_NOCONFIRMATION=0x1, SHERB_NOPROGRESSUI=0x2, SHERB_NOSOUND=0x4 + SHEmptyRecycleBin(IntPtr.Zero, null, 0x07); + } + catch { /* 비핵심 */ } + } + + private static string GetAppLogPath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "logs"); + + private static string FormatBytes(long bytes) => bytes switch + { + >= 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/PasswordGenHandler.cs b/src/AxCopilot/Handlers/PasswordGenHandler.cs new file mode 100644 index 0000000..7dc7a75 --- /dev/null +++ b/src/AxCopilot/Handlers/PasswordGenHandler.cs @@ -0,0 +1,249 @@ +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L9-1: 비밀번호 생성기 핸들러. "pwd" 프리픽스로 사용합니다. +/// +/// 예: pwd → 기본 16자 비밀번호 5개 생성 +/// pwd 24 → 24자 비밀번호 5개 생성 +/// pwd 32 strong → 32자 강력 옵션 (대소문자+숫자+특수) +/// pwd 16 alpha → 알파벳+숫자만 (특수문자 제외) +/// pwd 20 pin → 숫자만 (PIN 코드) +/// pwd passphrase → 단어 조합 기억하기 쉬운 패스프레이즈 +/// Enter → 클립보드에 복사. +/// +public class PasswordGenHandler : IActionHandler +{ + public string? Prefix => "pwd"; + + public PluginMetadata Metadata => new( + "PasswordGen", + "비밀번호 생성기 — 길이 · 복잡도 · 패스프레이즈", + "1.0", + "AX"); + + private const string LowerChars = "abcdefghijklmnopqrstuvwxyz"; + private const string UpperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string DigitChars = "0123456789"; + private const string SpecialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?"; + + // 기억하기 쉬운 단어 목록 (간결한 영단어) + private static readonly string[] WordList = + [ + "apple","bridge","cloud","dawn","eagle","flame","grape","harbor", + "ivory","jungle","kite","lemon","maple","night","ocean","pearl", + "quartz","river","storm","tiger","ultra","violet","water","xenon", + "yellow","zenith","amber","blaze","cedar","delta","ember","frost", + "glass","honey","iron","jade","knot","lunar","mango","nova", + "orbit","prism","quest","range","solar","track","umbra","valor", + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 패스프레이즈 모드 + if (q.StartsWith("passphrase") || q.StartsWith("phrase")) + { + var wordCount = 4; + var parts2 = q.Split(' '); + if (parts2.Length >= 2 && int.TryParse(parts2[1], out var wc)) + wordCount = Math.Clamp(wc, 2, 8); + + items.Add(new LauncherItem( + "패스프레이즈 생성", + $"{wordCount}단어 조합 · 기억하기 쉬운 형식", + null, null, Symbol: "\uE8D4")); + + for (int i = 0; i < 5; i++) + { + var phrase = GeneratePassphrase(wordCount); + var strength = EstimateEntropy(phrase); + items.Add(new LauncherItem( + phrase, + $"엔트로피: ~{strength}bit", + null, + ("copy", phrase), + Symbol: "\uE8D4")); + } + return Task.FromResult>(items); + } + + // 파라미터 파싱: [길이] [모드] + int length = 16; + string mode = "strong"; + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 1 && int.TryParse(parts[0], out var l)) + { + length = Math.Clamp(l, 4, 128); + if (parts.Length >= 2) mode = parts[1]; + } + else if (parts.Length >= 1 && parts[0] is "strong" or "alpha" or "pin" or "simple") + { + mode = parts[0]; + } + + // 문자 집합 결정 + var charset = BuildCharset(mode); + + // 강도 표시 + var modeLabel = mode switch + { + "alpha" => "알파뉴메릭 (대소문자+숫자)", + "pin" => "숫자 PIN", + "simple" => "간단 (소문자+숫자)", + _ => "강력 (대소문자+숫자+특수)", + }; + + items.Add(new LauncherItem( + $"비밀번호 생성 {length}자", + modeLabel, + null, null, Symbol: "\uE8D4")); + + // 5개 후보 생성 + for (int i = 0; i < 5; i++) + { + var pw = GeneratePassword(charset, length, mode); + var strength = GetStrengthLabel(pw); + items.Add(new LauncherItem( + pw, + strength, + null, + ("copy", pw), + Symbol: "\uE8D4")); + } + + // 옵션 안내 + items.Add(new LauncherItem( + "pwd <길이> alpha", + "알파뉴메릭 (특수문자 제외)", + null, null, Symbol: "\uE946")); + items.Add(new LauncherItem( + "pwd <길이> pin", + "숫자만 (PIN 코드)", + null, null, Symbol: "\uE946")); + items.Add(new LauncherItem( + "pwd passphrase", + "단어 조합 패스프레이즈", + null, null, Symbol: "\uE946")); + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string pw)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(pw)); + NotificationService.Notify("비밀번호", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static string BuildCharset(string mode) => mode switch + { + "pin" => DigitChars, + "alpha" => LowerChars + UpperChars + DigitChars, + "simple" => LowerChars + DigitChars, + _ => LowerChars + UpperChars + DigitChars + SpecialChars, + }; + + private static string GeneratePassword(string charset, int length, string mode) + { + // strong 모드: 각 카테고리 최소 1개 보장 + if (mode == "strong" && length >= 4) + { + var mandatory = new[] + { + RandomChar(LowerChars), + RandomChar(UpperChars), + RandomChar(DigitChars), + RandomChar(SpecialChars), + }; + + var remaining = length - mandatory.Length; + var bulk = Enumerable.Range(0, remaining) + .Select(_ => RandomChar(charset)) + .ToList(); + + var all = mandatory.Concat(bulk).ToArray(); + Shuffle(all); + return new string(all); + } + + // alpha/pin/simple + return new string(Enumerable.Range(0, length) + .Select(_ => RandomChar(charset)) + .ToArray()); + } + + private static string GeneratePassphrase(int wordCount) + { + var words = Enumerable.Range(0, wordCount) + .Select(_ => WordList[RandomInt(WordList.Length)]); + var num = RandomInt(9000) + 1000; + var sep = new[] { "-", "_", ".", "!" }[RandomInt(4)]; + return string.Join(sep, words) + sep + num; + } + + private static char RandomChar(string charset) => + charset[RandomInt(charset.Length)]; + + private static int RandomInt(int max) => + (int)(RandomNumberGenerator.GetInt32(int.MaxValue) % max); + + private static void Shuffle(T[] arr) + { + for (int i = arr.Length - 1; i > 0; i--) + { + var j = RandomInt(i + 1); + (arr[i], arr[j]) = (arr[j], arr[i]); + } + } + + private static string GetStrengthLabel(string pw) + { + var hasLower = pw.Any(char.IsLower); + var hasUpper = pw.Any(char.IsUpper); + var hasDigit = pw.Any(char.IsDigit); + var hasSpecial = pw.Any(c => SpecialChars.Contains(c)); + var types = new[] { hasLower, hasUpper, hasDigit, hasSpecial }.Count(b => b); + + var strength = (pw.Length, types) switch + { + ( >= 24, >= 4) => "매우 강함 🔐", + ( >= 16, >= 3) => "강함 🔒", + ( >= 12, >= 2) => "보통 🔑", + _ => "약함 ⚠", + }; + return $"{strength} · {pw.Length}자"; + } + + private static int EstimateEntropy(string pw) + { + // 간략 엔트로피 추정: log2(charset^length) + var hasLower = pw.Any(char.IsLower); + var hasUpper = pw.Any(char.IsUpper); + var hasDigit = pw.Any(char.IsDigit); + var hasSpecial = pw.Any(c => !char.IsLetterOrDigit(c)); + var pool = (hasLower ? 26 : 0) + (hasUpper ? 26 : 0) + + (hasDigit ? 10 : 0) + (hasSpecial ? 32 : 0); + if (pool == 0) pool = 36; + return (int)(pw.Length * Math.Log2(pool)); + } +} diff --git a/src/AxCopilot/Handlers/SubnetHandler.cs b/src/AxCopilot/Handlers/SubnetHandler.cs new file mode 100644 index 0000000..8af33c1 --- /dev/null +++ b/src/AxCopilot/Handlers/SubnetHandler.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L9-2: IP 서브넷 계산기 핸들러. "subnet" 프리픽스로 사용합니다. +/// +/// 예: subnet 192.168.1.0/24 → 네트워크 정보 전체 표시 +/// subnet 10.0.0.5/24 → 해당 IP가 속한 서브넷 분석 +/// subnet 255.255.255.0 → 서브넷 마스크 → CIDR 변환 +/// subnet 192.168.1.0 24 → 슬래시 없이 입력 가능 +/// subnet range 192.168.1.10-50 → IP 범위 정보 +/// Enter → 결과를 클립보드에 복사. +/// +public class SubnetHandler : IActionHandler +{ + public string? Prefix => "subnet"; + + public PluginMetadata Metadata => new( + "Subnet", + "IP 서브넷 계산기 — CIDR · 네트워크 · 호스트 범위", + "1.0", + "AX"); + + // 자주 쓰는 CIDR 참조표 + private static readonly (int Prefix, int Hosts, string Desc)[] CidrRef = + [ + (24, 254, "/24 — 클래스 C (254 호스트)"), + (25, 126, "/25 — 128개 분할 (126 호스트)"), + (26, 62, "/26 — 64개 분할 (62 호스트)"), + (27, 30, "/27 — 32개 분할 (30 호스트)"), + (28, 14, "/28 — 16개 분할 (14 호스트)"), + (29, 6, "/29 — 8개 분할 (6 호스트)"), + (30, 2, "/30 — 포인트-투-포인트 (2 호스트)"), + (23, 510, "/23 — 512개 블록 (510 호스트)"), + (22, 1022, "/22 — 1024개 블록 (1022 호스트)"), + (20, 4094, "/20 — 4096개 블록 (4094 호스트)"), + (16, 65534, "/16 — 클래스 B (65534 호스트)"), + (8, 16777214, "/8 — 클래스 A (16M 호스트)"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + "서브넷 계산기", + "예: subnet 192.168.1.0/24 또는 subnet 10.0.0.5 24", + null, null, Symbol: "\uE968")); + + // CIDR 참조표 일부 + foreach (var (cidrLen, hosts, desc) in CidrRef.Take(6)) + { + items.Add(new LauncherItem( + desc, + $"호스트: {hosts:N0}개", + null, + ("copy", desc), + Symbol: "\uE968")); + } + return Task.FromResult>(items); + } + + // "range" 서브커맨드 + if (q.StartsWith("range ", StringComparison.OrdinalIgnoreCase)) + { + var rangeStr = q[6..].Trim(); + items.AddRange(BuildRangeItems(rangeStr)); + return Task.FromResult>(items); + } + + // 서브넷 마스크 입력 (예: 255.255.255.0) + if (TryParseMask(q, out var cidrFromMask)) + { + items.Add(new LauncherItem( + $"CIDR: /{cidrFromMask}", + $"서브넷 마스크 {q} = /{cidrFromMask}", + null, + ("copy", $"/{cidrFromMask}"), + Symbol: "\uE968")); + return Task.FromResult>(items); + } + + // CIDR 파싱: "IP/prefix" 또는 "IP prefix" + if (!TryParseCidr(q, out var ip, out var prefix)) + { + items.Add(new LauncherItem("형식 오류", "예: 192.168.1.0/24", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.AddRange(BuildSubnetItems(ip, prefix)); + 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("Subnet", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 서브넷 계산 ────────────────────────────────────────────────────────── + + private static IEnumerable BuildSubnetItems(uint ip, int prefix) + { + var mask = prefix == 0 ? 0u : ~0u << (32 - prefix); + var network = ip & mask; + var broadcast = network | ~mask; + var firstHost = prefix < 31 ? network + 1 : network; + var lastHost = prefix < 31 ? broadcast - 1 : broadcast; + var hostCount = prefix >= 31 ? (1u << (32 - prefix)) + : (broadcast - network - 1); + + var summaryText = $""" + 네트워크: {ToIp(network)}/{prefix} + 서브넷마스크: {ToIp(mask)} + 첫 호스트: {ToIp(firstHost)} + 마지막 호스트: {ToIp(lastHost)} + 브로드캐스트: {ToIp(broadcast)} + 사용 가능 호스트: {hostCount:N0}개 + """; + + yield return new LauncherItem( + $"{ToIp(network)}/{prefix}", + $"호스트 {hostCount:N0}개 · 전체 복사: Enter", + null, + ("copy", summaryText), + Symbol: "\uE968"); + + yield return new LauncherItem("서브넷 마스크", ToIp(mask), null, ("copy", ToIp(mask)), Symbol: "\uE968"); + yield return new LauncherItem("네트워크 주소", ToIp(network), null, ("copy", ToIp(network)), Symbol: "\uE968"); + yield return new LauncherItem("브로드캐스트", ToIp(broadcast), null, ("copy", ToIp(broadcast)), Symbol: "\uE968"); + yield return new LauncherItem("첫 호스트", ToIp(firstHost), null, ("copy", ToIp(firstHost)), Symbol: "\uE968"); + yield return new LauncherItem("마지막 호스트", ToIp(lastHost), null, ("copy", ToIp(lastHost)), Symbol: "\uE968"); + yield return new LauncherItem( + "사용 가능 호스트", + $"{hostCount:N0}개", + null, + ("copy", hostCount.ToString()), + Symbol: "\uE968"); + + // 입력 IP가 이 서브넷에 속하는지 확인 + if ((ip & mask) == network && ip != network && ip != broadcast) + { + yield return new LauncherItem( + $"입력 IP {ToIp(ip)}", + "이 서브넷에 속함 ✓", + null, null, Symbol: "\uE73E"); + } + + // 이진 표현 + yield return new LauncherItem( + "이진 마스크", + ToBinary(mask), + null, + ("copy", ToBinary(mask)), + Symbol: "\uE8C4"); + } + + private static IEnumerable BuildRangeItems(string rangeStr) + { + // "192.168.1.10-50" 또는 "192.168.1.10-192.168.1.50" + var parts = rangeStr.Split('-', 2); + if (parts.Length != 2) + { + yield return new LauncherItem("형식 오류", "예: 192.168.1.10-50", null, null, Symbol: "\uE783"); + yield break; + } + + if (!TryParseIp(parts[0].Trim(), out var startIp)) + { + yield return new LauncherItem("IP 형식 오류", parts[0], null, null, Symbol: "\uE783"); + yield break; + } + + uint endIp; + if (uint.TryParse(parts[1].Trim(), out var lastOctet)) + { + endIp = (startIp & 0xFFFFFF00) | lastOctet; + } + else if (!TryParseIp(parts[1].Trim(), out endIp)) + { + yield return new LauncherItem("IP 형식 오류", parts[1], null, null, Symbol: "\uE783"); + yield break; + } + + if (endIp < startIp) + { + yield return new LauncherItem("오류", "끝 IP가 시작 IP보다 작습니다", null, null, Symbol: "\uE783"); + yield break; + } + + var count = endIp - startIp + 1; + yield return new LauncherItem( + $"{ToIp(startIp)} — {ToIp(endIp)}", + $"{count}개 IP", + null, + ("copy", $"{ToIp(startIp)} - {ToIp(endIp)} ({count}개)"), + Symbol: "\uE968"); + + yield return new LauncherItem("시작 IP", ToIp(startIp), null, ("copy", ToIp(startIp)), Symbol: "\uE968"); + yield return new LauncherItem("끝 IP", ToIp(endIp), null, ("copy", ToIp(endIp)), Symbol: "\uE968"); + yield return new LauncherItem("IP 수", $"{count:N0}개", null, ("copy", count.ToString()), Symbol: "\uE968"); + } + + // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── + + private static bool TryParseCidr(string s, out uint ip, out int prefix) + { + ip = 0; prefix = 24; + + // "IP/prefix" 형식 + var slashIdx = s.IndexOf('/'); + if (slashIdx >= 0) + { + if (!TryParseIp(s[..slashIdx].Trim(), out ip)) return false; + if (!int.TryParse(s[(slashIdx + 1)..].Trim(), out prefix)) return false; + prefix = Math.Clamp(prefix, 0, 32); + return true; + } + + // "IP prefix" 형식 (공백 구분) + var spaceIdx = s.LastIndexOf(' '); + if (spaceIdx >= 0 && int.TryParse(s[(spaceIdx + 1)..].Trim(), out var p)) + { + prefix = Math.Clamp(p, 0, 32); + return TryParseIp(s[..spaceIdx].Trim(), out ip); + } + + // IP만 입력 → /24 기본 + return TryParseIp(s.Trim(), out ip); + } + + private static bool TryParseIp(string s, out uint ip) + { + ip = 0; + if (!IPAddress.TryParse(s, out var addr)) return false; + if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false; + var bytes = addr.GetAddressBytes(); + ip = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) + | ((uint)bytes[2] << 8) | (uint)bytes[3]; + return true; + } + + private static bool TryParseMask(string s, out int prefix) + { + prefix = 0; + if (!TryParseIp(s, out var mask)) return false; + if (mask == 0) { prefix = 0; return true; } + + // 유효한 서브넷 마스크인지 확인 (연속된 1 뒤에 0) + var inverted = ~mask; + if ((inverted & (inverted + 1)) != 0) return false; + + prefix = 0; + var tmp = mask; + while ((tmp & 0x80000000) != 0) { prefix++; tmp <<= 1; } + return true; + } + + private static string ToIp(uint ip) => + $"{ip >> 24}.{(ip >> 16) & 0xFF}.{(ip >> 8) & 0xFF}.{ip & 0xFF}"; + + private static string ToBinary(uint val) => + $"{Convert.ToString((int)(val >> 16), 2).PadLeft(16, '0')} {Convert.ToString((int)(val & 0xFFFF), 2).PadLeft(16, '0')}"; +}