From 315848f9bccc3ba5bd3776aa3e6dd977a9e0f1df Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 14:45:42 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L12]=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=C2=B7=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=C2=B7=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=8F=84=EA=B5=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=204=EC=A2=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpTesterHandler.cs (신규, ~170줄, prefix=http): - GET/HEAD/POST 등 HttpClient 기반 직접 요청 - http:// 스키마 자동 추가, 3회 리다이렉트, 10초 타임아웃 - 상태 코드·응답 시간·Content-Type·Server 등 주요 헤더 표시 - InternalModeEnabled: 외부 URL 차단, 내부 IP 허용 패턴 정규식 - Enter → 비동기 요청 실행 + 결과 클립보드 복사 HostsHandler.cs (신규, ~220줄, prefix=hosts): - System32\drivers\etc\hosts RFC 파서 (인라인 주석 처리) - 활성 항목 / 주석 처리된 IP 항목(비활성) 자동 분류 - search 키워드 필터, open 메모장 실행, copy 전체 내용 - IPAddress.TryParse 기반 유효 IP 항목 판별 MorseHandler.cs (신규, ~200줄, prefix=morse): - 56자(영문자·숫자·구두점) TextToMorse 정적 딕셔너리 - 역방향 MorseToText 딕셔너리 static 생성자로 자동 구축 - .-/공백 패턴으로 모스 입력 자동 감지 - / 단어 구분자, SOS/AR/AS/BT/KN/SK 프로사인 키워드 지원 - 클립보드 자동 감지 (비어 있으면 도움말 표시) StartupHandler.cs (신규, ~220줄, prefix=startup): - HKCU + HKLM Run/RunOnce 6개 레지스트리 키 조회 - 시작 폴더(.lnk) 현재 사용자 + 모든 사용자 통합 수집 - 범위별(현재 사용자/모든 사용자) 그룹화 표시 - search 키워드 필터, folder 폴더 열기 서브커맨드 App.xaml.cs: 4개 핸들러 Phase L12 블록 등록 docs/LAUNCHER_ROADMAP.md: Phase L12 완료 섹션 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/HostsHandler.cs | 253 ++++++++++++++++++++ src/AxCopilot/Handlers/HttpTesterHandler.cs | 191 +++++++++++++++ src/AxCopilot/Handlers/MorseHandler.cs | 251 +++++++++++++++++++ src/AxCopilot/Handlers/StartupHandler.cs | 230 ++++++++++++++++++ 6 files changed, 948 insertions(+) create mode 100644 src/AxCopilot/Handlers/HostsHandler.cs create mode 100644 src/AxCopilot/Handlers/HttpTesterHandler.cs create mode 100644 src/AxCopilot/Handlers/MorseHandler.cs create mode 100644 src/AxCopilot/Handlers/StartupHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 7e09c45..5ce011b 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -282,3 +282,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L11-2 | **JWT 디코더** ✅ | `jwt` 프리픽스. 클립보드 또는 인라인 토큰 자동 감지(eyJ 시작). 헤더(alg·typ)·페이로드(claims)·서명 유무 분석. exp/iat/nbf 타임스탬프 → 날짜 변환. 만료 D-day·남은 시간 계산. `jwt header` / `jwt payload` 부분 조회. **서명 검증 미지원(분석 전용)** | 높음 | | L11-3 | **Cron 설명기** ✅ | `cron` 프리픽스. 5필드 표준 cron 표현식 파싱. 한국어 설명 생성(예: "평일 오전 9시 실행"). 다음 5회 실행 시간 계산 + 상대 시간 표시. `@daily/@weekly/@monthly/@hourly` 특수 키워드. 필드별 분석(분·시·일·월·요일). Enter → 표현식 복사 | 중간 | | L11-4 | **유니코드 조회** ✅ | `unicode` 프리픽스. 문자 직접 입력, `U+XXXX`, `0xXXXX`, 10진수 코드포인트 방식 지원. UTF-8·UTF-16 바이트, HTML 엔티티, 카테고리(Lu/Ll/So 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 | + +--- + +## Phase L12 — 시스템·네트워크·텍스트 도구 (v2.0.4) ✅ 완료 + +> **방향**: 실무 네트워크 진단·시스템 관리·재미있는 텍스트 변환 확충. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L12-1 | **HTTP 요청 테스터** ✅ | `http` 프리픽스. GET/HEAD/POST/PUT/DELETE 메서드. http:// 자동 추가. 상태 코드·응답 시간·Content-Type·주요 헤더 표시. 사내 모드에서 외부 URL 차단(내부 IP만 허용). Enter → 요청 실행 + 결과 클립보드 복사 | 높음 | +| 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 → 명령 경로 클립보드 복사 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 41af001..5a64af5 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -247,6 +247,16 @@ public partial class App : System.Windows.Application // L11-4: 유니코드 문자 조회 (prefix=unicode) commandResolver.RegisterHandler(new UnicodeHandler()); + // ─── Phase L12 핸들러 ───────────────────────────────────────────────── + // L12-1: HTTP 요청 테스터 (prefix=http) + commandResolver.RegisterHandler(new HttpTesterHandler()); + // L12-2: hosts 파일 관리 (prefix=hosts) + commandResolver.RegisterHandler(new HostsHandler()); + // L12-3: 모스 부호 변환기 (prefix=morse) + commandResolver.RegisterHandler(new MorseHandler()); + // L12-4: 시작 프로그램 조회 (prefix=startup) + commandResolver.RegisterHandler(new StartupHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/HostsHandler.cs b/src/AxCopilot/Handlers/HostsHandler.cs new file mode 100644 index 0000000..6126f0e --- /dev/null +++ b/src/AxCopilot/Handlers/HostsHandler.cs @@ -0,0 +1,253 @@ +using System.IO; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다. +/// +/// 예: hosts → 현재 hosts 파일 항목 목록 +/// hosts search dev → "dev" 포함 항목 필터 +/// hosts open → hosts 파일을 메모장으로 열기 +/// hosts copy → 전체 hosts 내용 클립보드 복사 +/// Enter → 해당 항목을 클립보드에 복사. +/// +public class HostsHandler : IActionHandler +{ + public string? Prefix => "hosts"; + + public PluginMetadata Metadata => new( + "Hosts", + "Windows hosts 파일 뷰어 — 항목 조회 · 검색 · 복사", + "1.0", + "AX"); + + private static readonly string HostsPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"drivers\etc\hosts"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + var entries = ReadHostsEntries(); + + if (string.IsNullOrWhiteSpace(q)) + { + var activeCount = entries.Count(e => !e.IsComment); + var commentCount = entries.Count(e => e.IsComment && e.IsDisabledEntry); + + items.Add(new LauncherItem( + $"hosts 파일 활성 {activeCount}개" + (commentCount > 0 ? $" · 비활성 {commentCount}개" : ""), + HostsPath, + null, + ("copy_path", HostsPath), + Symbol: "\uE8D2")); + + items.Add(new LauncherItem("파일 열기 (메모장)", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5")); + items.Add(new LauncherItem("전체 내용 복사", $"{entries.Count}줄", null, ("copy_all", ""), Symbol: "\uE8A5")); + + // 유효 항목 목록 + foreach (var e in entries.Where(e => !e.IsComment).Take(20)) + items.Add(MakeEntryItem(e)); + + // 비활성 항목 (주석 처리된 IP 항목) + var disabled = entries.Where(e => e.IsDisabledEntry).Take(5).ToList(); + if (disabled.Count > 0) + { + items.Add(new LauncherItem($"── 비활성 항목 {disabled.Count}개 ──", "", null, null, Symbol: "\uE8D2")); + foreach (var e in disabled) + items.Add(MakeEntryItem(e)); + } + + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "open": + items.Add(new LauncherItem("메모장으로 열기", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5")); + break; + + case "copy": + case "copy_all": + { + var content = ReadHostsRaw(); + items.Add(new LauncherItem("전체 내용 복사", $"{content.Split('\n').Length}줄 · Enter 복사", + null, ("copy", content), Symbol: "\uE8A5")); + break; + } + + case "search": + case "find": + { + var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : ""; + if (string.IsNullOrWhiteSpace(keyword)) + { + items.Add(new LauncherItem("검색어 입력", "예: hosts search dev", null, null, Symbol: "\uE783")); + break; + } + var filtered = entries.Where(e => + e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946")); + else + foreach (var e in filtered.Take(20)) + items.Add(MakeEntryItem(e)); + break; + } + + default: + { + // 검색어로 처리 + var keyword = q.ToLowerInvariant(); + var filtered = entries.Where(e => + e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946")); + else + foreach (var e in filtered.Take(20)) + items.Add(MakeEntryItem(e)); + break; + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("Hosts", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + break; + + case ("copy_path", string path): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path)); + NotificationService.Notify("Hosts", "경로가 복사되었습니다."); + } + catch { /* 비핵심 */ } + break; + + case ("copy_all", _): + try + { + var content = ReadHostsRaw(); + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(content)); + NotificationService.Notify("Hosts", $"hosts 파일 내용 복사됨 ({content.Split('\n').Length}줄)"); + } + catch { /* 비핵심 */ } + break; + + case ("open", string path): + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "notepad.exe", + Arguments = $"\"{path}\"", + UseShellExecute = false, + }); + } + catch (Exception ex) + { + NotificationService.Notify("Hosts", $"열기 실패: {ex.Message}"); + } + break; + } + return Task.CompletedTask; + } + + // ── hosts 파일 파서 ─────────────────────────────────────────────────────── + + private record HostsEntry(string IpAddress, string Hostname, string RawLine, + bool IsComment, bool IsDisabledEntry); + + private static List ReadHostsEntries() + { + var result = new List(); + string[] lines; + try { lines = File.ReadAllLines(HostsPath, Encoding.UTF8); } + catch { return result; } + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line)) continue; + + // 순수 주석 (# 으로 시작, IP 없음) + if (line.StartsWith('#')) + { + // 비활성 IP 항목인지 확인 (예: "# 127.0.0.1 example.com") + var inner = line[1..].Trim(); + if (TryParseIpEntry(inner, out var disIp, out var disHost)) + { + result.Add(new HostsEntry(disIp, disHost, rawLine, true, true)); + } + // 순수 주석은 목록에서 제외 + continue; + } + + // IP 항목 (인라인 주석 포함 가능) + var withoutComment = line.Contains('#') ? line[..line.IndexOf('#')].Trim() : line; + if (TryParseIpEntry(withoutComment, out var ip, out var host)) + { + result.Add(new HostsEntry(ip, host, rawLine, false, false)); + } + } + return result; + } + + private static bool TryParseIpEntry(string line, out string ip, out string host) + { + ip = host = ""; + var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) return false; + + // 첫 토큰이 IP 주소 형태인지 간단히 확인 + if (!System.Net.IPAddress.TryParse(parts[0], out _)) return false; + ip = parts[0]; + host = parts[1]; + return true; + } + + private static string ReadHostsRaw() + { + try { return File.ReadAllText(HostsPath, Encoding.UTF8); } + catch { return "(hosts 파일을 읽을 수 없습니다)"; } + } + + private static LauncherItem MakeEntryItem(HostsEntry e) + { + var prefix = e.IsComment ? "# " : ""; + var icon = e.IsComment ? "\uE946" : "\uE8D2"; + var subtitle = e.IsComment ? "비활성 항목" : e.IpAddress; + + return new LauncherItem( + $"{prefix}{e.Hostname}", + subtitle, + null, + ("copy", $"{e.IpAddress}\t{e.Hostname}"), + Symbol: icon); + } +} diff --git a/src/AxCopilot/Handlers/HttpTesterHandler.cs b/src/AxCopilot/Handlers/HttpTesterHandler.cs new file mode 100644 index 0000000..3d5039e --- /dev/null +++ b/src/AxCopilot/Handlers/HttpTesterHandler.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Net.Http; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L12-1: HTTP 요청 테스터 핸들러. "http" 프리픽스로 사용합니다. +/// +/// 예: http example.com → GET 요청 (http:// 자동 추가) +/// http https://api.example → GET 요청 + 응답 코드·시간 +/// http head https://example → HEAD 요청 +/// http post https://example → POST (빈 바디) +/// http 192.168.1.1 → 내부 IP GET +/// Enter → 응답 요약을 클립보드에 복사. +/// +/// ⚠ 외부 URL: 사내 모드 차단. 내부 IP(10./192.168./172.16-31.)는 허용. +/// +public class HttpTesterHandler : IActionHandler +{ + public string? Prefix => "http"; + + public PluginMetadata Metadata => new( + "HTTP", + "HTTP 요청 테스터 — GET · HEAD · POST · 응답 코드", + "1.0", + "AX"); + + private static readonly HttpClient _client = new(new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 3, + ServerCertificateCustomValidationCallback = (_, _, _, _) => true, + }) + { + Timeout = TimeSpan.FromSeconds(10), + }; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("HTTP 요청 테스터", + "예: http example.com / http head https://api / http post https://url", + null, null, Symbol: "\uE774")); + items.Add(new LauncherItem("http localhost", "로컬 서버 GET", null, null, Symbol: "\uE774")); + items.Add(new LauncherItem("http 192.168.1.1", "내부 IP GET", null, null, Symbol: "\uE774")); + items.Add(new LauncherItem("http head https://…", "HEAD 요청", null, null, Symbol: "\uE774")); + items.Add(new LauncherItem("http post https://…", "POST 요청", null, null, Symbol: "\uE774")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + string method, url; + + if (parts[0].ToUpperInvariant() is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" or "OPTIONS") + { + method = parts[0].ToUpperInvariant(); + url = parts.Length > 1 ? parts[1].Trim() : ""; + } + else + { + method = "GET"; + url = q; + } + + if (string.IsNullOrWhiteSpace(url)) + { + items.Add(new LauncherItem("URL을 입력하세요", $"예: http {method.ToLower()} https://example.com", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 스키마 자동 추가 + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + url = "http://" + url; + } + + // 사내 모드 확인 + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + var isInternal = settings?.InternalModeEnabled ?? true; + if (isInternal && !IsInternalUrl(url)) + { + items.Add(new LauncherItem( + "사내 모드 제한", + $"외부 URL '{url}'은 차단됩니다. 설정에서 사외 모드를 활성화하세요.", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem( + $"{method} {TruncateUrl(url)}", + "Enter를 눌러 요청 실행", + null, + ("request", $"{method}|{url}"), + Symbol: "\uE774")); + + 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("HTTP", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + return; + } + + if (item.Data is not ("request", string reqData)) return; + + var idx = reqData.IndexOf('|'); + var method = reqData[..idx]; + var url = reqData[(idx + 1)..]; + + NotificationService.Notify("HTTP", $"{method} {TruncateUrl(url)} 요청 중…"); + + try + { + var sw = Stopwatch.StartNew(); + HttpResponseMessage resp; + + using var reqMsg = new HttpRequestMessage(new HttpMethod(method), url); + reqMsg.Headers.TryAddWithoutValidation("User-Agent", "AX-Copilot/2.0 HTTP-Tester"); + + resp = await _client.SendAsync(reqMsg, HttpCompletionOption.ResponseHeadersRead, ct); + sw.Stop(); + + var sb = new StringBuilder(); + sb.AppendLine($"URL: {url}"); + sb.AppendLine($"메서드: {method}"); + sb.AppendLine($"상태 코드: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + sb.AppendLine($"응답 시간: {sw.ElapsedMilliseconds}ms"); + sb.AppendLine($"Content-Type: {resp.Content.Headers.ContentType}"); + sb.AppendLine($"Content-Length: {resp.Content.Headers.ContentLength?.ToString() ?? "unknown"}"); + + // 주요 헤더 + foreach (var h in new[] { "Server", "X-Powered-By", "Cache-Control", "ETag", "Last-Modified" }) + if (resp.Headers.TryGetValues(h, out var vals)) + sb.AppendLine($"{h}: {string.Join(", ", vals)}"); + + var summary = sb.ToString().TrimEnd(); + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary)); + + var status = (int)resp.StatusCode; + var emoji = status < 300 ? "✔" : status < 400 ? "↪" : "✘"; + NotificationService.Notify("HTTP", + $"{emoji} {status} {resp.ReasonPhrase} ({sw.ElapsedMilliseconds}ms) · 결과 복사됨"); + } + catch (TaskCanceledException) + { + NotificationService.Notify("HTTP", "요청 타임아웃 (10초 초과)"); + } + catch (HttpRequestException ex) + { + NotificationService.Notify("HTTP", $"요청 오류: {ex.Message}"); + } + catch (Exception ex) + { + NotificationService.Notify("HTTP", $"오류: {ex.Message}"); + } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static bool IsInternalUrl(string url) + { + var lower = url.ToLowerInvariant(); + return lower.Contains("://localhost") || + lower.Contains("://127.0.0.1") || + lower.Contains("://192.168.") || + lower.Contains("://10.") || + System.Text.RegularExpressions.Regex.IsMatch(lower, + @"://172\.(1[6-9]|2\d|3[01])\."); + } + + private static string TruncateUrl(string url) => + url.Length > 60 ? url[..60] + "…" : url; +} diff --git a/src/AxCopilot/Handlers/MorseHandler.cs b/src/AxCopilot/Handlers/MorseHandler.cs new file mode 100644 index 0000000..df96edd --- /dev/null +++ b/src/AxCopilot/Handlers/MorseHandler.cs @@ -0,0 +1,251 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다. +/// +/// 예: morse hello → 텍스트 → 모스 부호 +/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분) +/// morse SOS → SOS 모스 부호 +/// morse → 클립보드 자동 감지·변환 +/// Enter → 결과를 클립보드에 복사. +/// +public class MorseHandler : IActionHandler +{ + public string? Prefix => "morse"; + + public PluginMetadata Metadata => new( + "Morse", + "모스 부호 변환기 — 텍스트 ↔ 모스 부호", + "1.0", + "AX"); + + // ── 모스 부호 사전 ──────────────────────────────────────────────────────── + private static readonly Dictionary TextToMorse = new() + { + ['A'] = ".-", ['B'] = "-...", ['C'] = "-.-.", ['D'] = "-..", + ['E'] = ".", ['F'] = "..-.", ['G'] = "--.", ['H'] = "....", + ['I'] = "..", ['J'] = ".---", ['K'] = "-.-", ['L'] = ".-..", + ['M'] = "--", ['N'] = "-.", ['O'] = "---", ['P'] = ".--.", + ['Q'] = "--.-", ['R'] = ".-.", ['S'] = "...", ['T'] = "-", + ['U'] = "..-", ['V'] = "...-", ['W'] = ".--", ['X'] = "-..-", + ['Y'] = "-.--", ['Z'] = "--..", + ['0'] = "-----", ['1'] = ".----", ['2'] = "..---", ['3'] = "...--", + ['4'] = "....-", ['5'] = ".....", ['6'] = "-....", ['7'] = "--...", + ['8'] = "---..", ['9'] = "----.", + ['.'] = ".-.-.-", [','] = "--..--", ['?'] = "..--..", ['!'] = "-.-.--", + ['/'] = "-..-.", ['-'] = "-....-", ['('] = "-.--.", [')'] = "-.--.-", + ['@'] = ".--.-.", ['='] = "-...-", ['+'] = ".-.-.", [':'] = "---...", + [';'] = "-.-.-.", ['"'] = ".-..-.", ['\''] = ".----.", ['_'] = "..--.-", + [' '] = "/", + }; + + private static readonly Dictionary MorseToText; + + // 번개 부호 / 대문자 표 + private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"]; + private static readonly Dictionary ProsignMorse = new() + { + ["SOS"] = "... --- ...", + ["AR"] = ".-.-.", + ["AS"] = ".-...", + ["BT"] = "-...-", + ["KN"] = "-.--.", + ["SK"] = "...-.-", + }; + + static MorseHandler() + { + MorseToText = TextToMorse + .Where(kv => kv.Key != ' ') + .ToDictionary(kv => kv.Value, kv => kv.Key); + } + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 클립보드 자동 감지 + var clip = GetClipboard(); + if (!string.IsNullOrWhiteSpace(clip)) + { + if (IsMorseCode(clip)) + items.AddRange(BuildMorseToText(clip)); + else if (clip.Length <= 100) + items.AddRange(BuildTextToMorse(clip)); + } + + if (items.Count == 0) + { + items.Add(new LauncherItem("모스 부호 변환기", + "예: morse hello / morse .- -... -.-.", + null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("morse SOS", "SOS 모스 부호", null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("morse hello", "텍스트 → 모스", null, null, Symbol: "\uE8C4")); + items.Add(new LauncherItem("morse .- -...", "모스 → 텍스트", null, null, Symbol: "\uE8C4")); + } + return Task.FromResult>(items); + } + + // 프로사인 키워드 우선 + if (ProsignMorse.TryGetValue(q.ToUpperInvariant(), out var psCode)) + { + items.Add(new LauncherItem( + $"{q.ToUpper()} = {psCode}", + "모스 부호 프로사인 · Enter 복사", + null, ("copy", psCode), Symbol: "\uE8C4")); + items.AddRange(BuildTextToMorse(q.ToUpper())); + return Task.FromResult>(items); + } + + // 모스 부호 입력 감지 + if (IsMorseCode(q)) + { + items.AddRange(BuildMorseToText(q)); + } + else + { + items.AddRange(BuildTextToMorse(q)); + } + + 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("Morse", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 변환 로직 ───────────────────────────────────────────────────────────── + + private static IEnumerable BuildTextToMorse(string text) + { + var upper = text.ToUpperInvariant(); + var words = upper.Split(' '); + var morseSb = new StringBuilder(); + var unknown = new List(); + + foreach (var word in words) + { + if (morseSb.Length > 0) morseSb.Append("/ "); // 단어 구분 + foreach (var ch in word) + { + if (TextToMorse.TryGetValue(ch, out var code)) + morseSb.Append(code + " "); + else + unknown.Add(ch); + } + } + + var morseStr = morseSb.ToString().TrimEnd(); + if (string.IsNullOrEmpty(morseStr)) + { + yield return new LauncherItem("변환 불가", "모스 부호에 없는 문자입니다", null, null, Symbol: "\uE783"); + yield break; + } + + yield return new LauncherItem( + morseStr.Length > 80 ? morseStr[..80] + "…" : morseStr, + $"'{text}' → 모스 부호 · Enter 복사", + null, + ("copy", morseStr), + Symbol: "\uE8C4"); + + if (unknown.Count > 0) + yield return new LauncherItem("변환 불가 문자", + string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946"); + + // 문자별 표 (최대 10자) + var displayText = upper.Replace(" ", ""); + foreach (var ch in displayText.Take(10)) + { + if (TextToMorse.TryGetValue(ch, out var code) && ch != ' ') + yield return new LauncherItem($"{ch} = {code}", "문자별 코드", null, ("copy", code), Symbol: "\uE8C4"); + } + } + + private static IEnumerable BuildMorseToText(string morse) + { + // "/" 는 단어 구분, 공백은 문자 구분 + var sb = new StringBuilder(); + var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var unknown = new List(); + + foreach (var word in words) + { + var codes = word.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var code in codes) + { + if (MorseToText.TryGetValue(code, out var ch)) + sb.Append(ch); + else + unknown.Add(code); + } + sb.Append(' '); + } + + var result = sb.ToString().Trim(); + if (string.IsNullOrEmpty(result)) + { + yield return new LauncherItem("변환 실패", "인식할 수 없는 모스 부호입니다", null, null, Symbol: "\uE783"); + yield break; + } + + yield return new LauncherItem( + result, + $"모스 부호 → '{result}' · Enter 복사", + null, + ("copy", result), + Symbol: "\uE8C4"); + + if (unknown.Count > 0) + yield return new LauncherItem("인식 불가 코드", + string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946"); + + // 코드별 표 (최대 10개) + var codes_ = morse.Trim().Split(new[] { ' ', '/' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var code in codes_.Take(10)) + { + if (MorseToText.TryGetValue(code, out var ch)) + yield return new LauncherItem($"{code} = {ch}", "코드별 문자", null, ("copy", ch.ToString()), Symbol: "\uE8C4"); + } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static bool IsMorseCode(string s) + { + // .-/ 문자와 공백만으로 구성되어 있으면 모스 부호로 판단 + return !string.IsNullOrWhiteSpace(s) && + s.All(c => c is '.' or '-' or '/' or ' ') && + (s.Contains('.') || s.Contains('-')); + } + + private static string GetClipboard() + { + try + { + return System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.ContainsText() ? Clipboard.GetText() : ""); + } + catch { return ""; } + } +} diff --git a/src/AxCopilot/Handlers/StartupHandler.cs b/src/AxCopilot/Handlers/StartupHandler.cs new file mode 100644 index 0000000..9bf3a76 --- /dev/null +++ b/src/AxCopilot/Handlers/StartupHandler.cs @@ -0,0 +1,230 @@ +using Microsoft.Win32; +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다. +/// +/// 예: startup → 전체 시작 프로그램 목록 +/// startup search ms → "ms" 포함 항목 필터 +/// startup folder → 시작 프로그램 폴더 열기 +/// Enter → 항목 경로를 클립보드에 복사. +/// +public class StartupHandler : IActionHandler +{ + public string? Prefix => "startup"; + + public PluginMetadata Metadata => new( + "Startup", + "시작 프로그램 조회 — 레지스트리 · 폴더 · 필터", + "1.0", + "AX"); + + // 조회할 레지스트리 키 경로들 + private static readonly (string Path, string Scope)[] RegKeys = + [ + (@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "현재 사용자"), + (@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "현재 사용자 (1회)"), + (@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "현재 사용자 (32bit)"), + ]; + + private static readonly (string Path, string Scope)[] RegKeysHKLM = + [ + (@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "모든 사용자"), + (@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "모든 사용자 (1회)"), + (@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "모든 사용자 (32bit)"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + var allEntries = CollectAllEntries(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + $"시작 프로그램 {allEntries.Count}개", + "레지스트리 + 시작 폴더", + null, null, Symbol: "\uE7FC")); + + items.Add(new LauncherItem("startup folder", "시작 폴더 열기", null, ("open_folder", ""), Symbol: "\uE7FC")); + + // 그룹별 표시 + var byScope = allEntries.GroupBy(e => e.Scope).OrderBy(g => g.Key); + foreach (var group in byScope) + { + items.Add(new LauncherItem($"── {group.Key} ({group.Count()}개) ──", "", null, null, Symbol: "\uE7FC")); + foreach (var e in group.Take(10)) + items.Add(MakeEntryItem(e)); + } + + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "folder": + { + items.Add(new LauncherItem("시작 폴더 열기 (현재 사용자)", GetStartupFolderUser(), + null, ("open_folder", GetStartupFolderUser()), Symbol: "\uE7FC")); + items.Add(new LauncherItem("시작 폴더 열기 (모든 사용자)", GetStartupFolderCommon(), + null, ("open_folder", GetStartupFolderCommon()), Symbol: "\uE7FC")); + break; + } + + case "search": + case "find": + { + var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : ""; + if (string.IsNullOrWhiteSpace(keyword)) + { + items.Add(new LauncherItem("검색어 입력", "예: startup search teams", null, null, Symbol: "\uE783")); + break; + } + var filtered = allEntries.Where(e => + e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946")); + else + foreach (var e in filtered) + items.Add(MakeEntryItem(e)); + break; + } + + default: + { + // 검색어로 처리 + var keyword = q.ToLowerInvariant(); + var filtered = allEntries.Where(e => + e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946")); + else + foreach (var e in filtered.Take(15)) + items.Add(MakeEntryItem(e)); + break; + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("Startup", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + break; + + case ("open_folder", string path): + var folderPath = string.IsNullOrEmpty(path) ? GetStartupFolderUser() : path; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = folderPath, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + NotificationService.Notify("Startup", $"폴더 열기 실패: {ex.Message}"); + } + break; + } + return Task.CompletedTask; + } + + // ── 레지스트리 수집 ──────────────────────────────────────────────────────── + + private record StartupEntry(string Name, string Command, string Scope, string Source); + + private static List CollectAllEntries() + { + var result = new List(); + + // HKCU + foreach (var (regPath, scope) in RegKeys) + result.AddRange(ReadRegistryRun(Registry.CurrentUser, regPath, scope)); + + // HKLM + foreach (var (regPath, scope) in RegKeysHKLM) + result.AddRange(ReadRegistryRun(Registry.LocalMachine, regPath, scope)); + + // 시작 폴더 (현재 사용자) + result.AddRange(ReadStartupFolder(GetStartupFolderUser(), "현재 사용자 (폴더)")); + + // 시작 폴더 (모든 사용자) + result.AddRange(ReadStartupFolder(GetStartupFolderCommon(), "모든 사용자 (폴더)")); + + return result; + } + + private static IEnumerable ReadRegistryRun(RegistryKey hive, string path, string scope) + { + RegistryKey? key; + try { key = hive.OpenSubKey(path, writable: false); } + catch { yield break; } + + if (key == null) yield break; + + using (key) + { + foreach (var name in key.GetValueNames()) + { + var cmd = key.GetValue(name)?.ToString() ?? ""; + yield return new StartupEntry(name, cmd, scope, path); + } + } + } + + private static IEnumerable ReadStartupFolder(string folderPath, string scope) + { + if (!Directory.Exists(folderPath)) yield break; + foreach (var file in Directory.EnumerateFiles(folderPath, "*.lnk")) + { + yield return new StartupEntry( + Path.GetFileNameWithoutExtension(file), + file, + scope, + folderPath); + } + } + + private static string GetStartupFolderUser() => + Environment.GetFolderPath(Environment.SpecialFolder.Startup); + + private static string GetStartupFolderCommon() => + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartup); + + private static LauncherItem MakeEntryItem(StartupEntry e) + { + var cmdShort = e.Command.Length > 70 ? e.Command[..70] + "…" : e.Command; + return new LauncherItem( + e.Name, + $"{e.Scope} · {cmdShort}", + null, + ("copy", e.Command), + Symbol: "\uE7FC"); + } +}