변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
254 lines
9.5 KiB
C#
254 lines
9.5 KiB
C#
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);
|
|
}
|
|
}
|