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

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 14:45:42 +09:00
parent 2df841be0c
commit 315848f9bc
6 changed files with 948 additions and 0 deletions

View File

@@ -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-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-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 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 | | 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 → 명령 경로 클립보드 복사 | 중간 |

View File

@@ -247,6 +247,16 @@ public partial class App : System.Windows.Application
// L11-4: 유니코드 문자 조회 (prefix=unicode) // L11-4: 유니코드 문자 조회 (prefix=unicode)
commandResolver.RegisterHandler(new UnicodeHandler()); 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); var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll(); pluginHost.LoadAll();

View File

@@ -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;
/// <summary>
/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다.
///
/// 예: hosts → 현재 hosts 파일 항목 목록
/// hosts search dev → "dev" 포함 항목 필터
/// hosts open → hosts 파일을 메모장으로 열기
/// hosts copy → 전체 hosts 내용 클립보드 복사
/// Enter → 해당 항목을 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("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<HostsEntry> ReadHostsEntries()
{
var result = new List<HostsEntry>();
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);
}
}

View File

@@ -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;
/// <summary>
/// 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.)는 허용.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
$"{method} {TruncateUrl(url)}",
"Enter를 눌러 요청 실행",
null,
("request", $"{method}|{url}"),
Symbol: "\uE774"));
return Task.FromResult<IEnumerable<LauncherItem>>(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;
}

View File

@@ -0,0 +1,251 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다.
///
/// 예: morse hello → 텍스트 → 모스 부호
/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분)
/// morse SOS → SOS 모스 부호
/// morse → 클립보드 자동 감지·변환
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class MorseHandler : IActionHandler
{
public string? Prefix => "morse";
public PluginMetadata Metadata => new(
"Morse",
"모스 부호 변환기 — 텍스트 ↔ 모스 부호",
"1.0",
"AX");
// ── 모스 부호 사전 ────────────────────────────────────────────────────────
private static readonly Dictionary<char, string> 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<string, char> MorseToText;
// 번개 부호 / 대문자 표
private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"];
private static readonly Dictionary<string, string> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 모스 부호 입력 감지
if (IsMorseCode(q))
{
items.AddRange(BuildMorseToText(q));
}
else
{
items.AddRange(BuildTextToMorse(q));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Morse", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 변환 로직 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildTextToMorse(string text)
{
var upper = text.ToUpperInvariant();
var words = upper.Split(' ');
var morseSb = new StringBuilder();
var unknown = new List<char>();
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<LauncherItem> BuildMorseToText(string morse)
{
// "/" 는 단어 구분, 공백은 문자 구분
var sb = new StringBuilder();
var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
var unknown = new List<string>();
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 ""; }
}
}

View File

@@ -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;
/// <summary>
/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다.
///
/// 예: startup → 전체 시작 프로그램 목록
/// startup search ms → "ms" 포함 항목 필터
/// startup folder → 시작 프로그램 폴더 열기
/// Enter → 항목 경로를 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("copy", string text):
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("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<StartupEntry> CollectAllEntries()
{
var result = new List<StartupEntry>();
// 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<StartupEntry> 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<StartupEntry> 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");
}
}