AX Commander 비교본 런처 기능 대량 이식

변경 목적: 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개를 확인했습니다.
This commit is contained in:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,367 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-3: IP 주소 유틸리티 핸들러. "ip" 프리픽스로 사용합니다.
///
/// 예: ip → 로컬 IP 주소 목록
/// ip my → 전체 어댑터 IP 목록
/// ip 192.168.1.100 → IP 분류·타입·이진 표현
/// ip 10.0.0.0/8 → CIDR 네트워크 정보
/// ip 192.168.1.0/24 → 네트워크 주소·브로드캐스트·호스트 범위·수
/// ip range 192.168.1.1 192.168.1.100 → 범위 내 IP 수
/// ip bin 192.168.1.1 → 이진 표현
/// ip hex 192.168.1.1 → 16진수 표현
/// ip int 192.168.1.1 → 정수(uint32) 표현
/// ip from 3232235777 → 정수 → IP 주소
/// Enter → 값 복사.
/// </summary>
public class IpInfoHandler : IActionHandler
{
public string? Prefix => "ip";
public PluginMetadata Metadata => new(
"IP",
"IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환",
"1.0",
"AX");
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("IP 주소 유틸리티",
"ip my / ip 192.168.1.1 / ip 10.0.0.0/8 / ip bin/hex/int <IP>",
null, null, Symbol: "\uE968"));
BuildLocalIpItems(items, brief: true);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// ip my
if (sub is "my" or "local" or "내" or "로컬")
{
items.Add(new LauncherItem("로컬 네트워크 어댑터 IP 목록", "", null, null, Symbol: "\uE968"));
BuildLocalIpItems(items, brief: false);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip range <start> <end>
if (sub == "range" && parts.Length >= 3)
{
if (IPAddress.TryParse(parts[1], out var start) &&
IPAddress.TryParse(parts[2], out var end) &&
start.AddressFamily == AddressFamily.InterNetwork &&
end.AddressFamily == AddressFamily.InterNetwork)
{
var s = IpToUint(start);
var e = IpToUint(end);
if (s > e) (s, e) = (e, s);
var count = e - s + 1;
items.Add(new LauncherItem($"{UintToIp(s)} ~ {UintToIp(e)}",
$"IP 수: {count:N0}개", null, null, Symbol: "\uE968"));
items.Add(CopyItem("시작 IP", UintToIp(s)));
items.Add(CopyItem("끝 IP", UintToIp(e)));
items.Add(CopyItem("IP 개수", count.ToString("N0")));
}
else
{
items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip from <uint>
if (sub == "from" && parts.Length >= 2)
{
if (uint.TryParse(parts[1], out var uval))
{
var ip = UintToIp(uval);
items.Add(new LauncherItem($"{uval} → {ip}", "정수 → IPv4 변환", null, ("copy", ip), Symbol: "\uE968"));
items.Add(CopyItem("IP 주소", ip));
items.Add(CopyItem("이진", ToBinary(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
}
else items.Add(ErrorItem("올바른 32비트 정수를 입력하세요"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ip bin/hex/int <IP>
if (sub is "bin" or "binary" or "이진" or "hex" or "int" or "integer")
{
if (parts.Length >= 2 && IPAddress.TryParse(parts[1], out var ip4) &&
ip4.AddressFamily == AddressFamily.InterNetwork)
{
BuildConversionItems(items, ip4);
}
else items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// CIDR: 10.0.0.0/8
if (parts[0].Contains('/'))
{
BuildCidrItems(items, parts[0]);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 단순 IP 주소
if (IPAddress.TryParse(parts[0], out var addr))
{
if (addr.AddressFamily == AddressFamily.InterNetwork)
BuildIpInfoItems(items, addr);
else if (addr.AddressFamily == AddressFamily.InterNetworkV6)
BuildIpv6Items(items, addr);
else
items.Add(ErrorItem("IPv4 또는 IPv6 주소를 입력하세요"));
}
else
{
items.Add(new LauncherItem($"인식할 수 없는 입력: '{parts[0]}'",
"ip 192.168.1.1 / ip 10.0.0.0/8 / ip my / ip bin 1.2.3.4",
null, null, Symbol: "\uE783"));
}
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("IP", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static void BuildLocalIpItems(List<LauncherItem> items, bool brief)
{
try
{
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.ToList();
if (ifaces.Count == 0)
{
items.Add(new LauncherItem("활성 네트워크 어댑터 없음", "", null, null, Symbol: "\uE968"));
return;
}
foreach (var nic in ifaces)
{
var ipProps = nic.GetIPProperties();
var ipv4s = ipProps.UnicastAddresses
.Where(u => u.Address.AddressFamily == AddressFamily.InterNetwork)
.ToList();
if (ipv4s.Count == 0) continue;
if (!brief)
items.Add(new LauncherItem($"── {nic.Name} ──",
$"{nic.Description} ({nic.Speed / 1_000_000} Mbps)", null, null, Symbol: "\uE968"));
foreach (var uni in ipv4s)
{
var mask = uni.IPv4Mask?.ToString() ?? "";
var cidr = MaskToCidr(uni.IPv4Mask);
var label = brief ? uni.Address.ToString() : $"{uni.Address}/{cidr}";
items.Add(new LauncherItem(label,
brief ? nic.Name : $"마스크: {mask} CIDR: /{cidr} ({ClassifyIp(uni.Address)})",
null, ("copy", uni.Address.ToString()), Symbol: "\uE968"));
}
if (!brief)
{
var gateways = ipProps.GatewayAddresses
.Where(g => g.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(g => g.Address.ToString()).ToList();
if (gateways.Count > 0)
items.Add(new LauncherItem("게이트웨이",
string.Join(", ", gateways), null, ("copy", gateways[0]), Symbol: "\uE968"));
}
}
}
catch (Exception ex)
{
items.Add(ErrorItem($"네트워크 정보 조회 오류: {ex.Message}"));
}
}
private static void BuildIpInfoItems(List<LauncherItem> items, IPAddress ip)
{
var type = ClassifyIp(ip);
var uval = IpToUint(ip);
items.Add(new LauncherItem($"{ip} ({type})",
$"클래스: {GetClass(uval)} · Enter 복사", null, ("copy", ip.ToString()), Symbol: "\uE968"));
items.Add(CopyItem("IP 주소", ip.ToString()));
items.Add(CopyItem("분류", type));
items.Add(CopyItem("클래스", GetClass(uval)));
items.Add(CopyItem("이진", ToBinary(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
items.Add(CopyItem("정수", uval.ToString()));
BuildConversionItems(items, ip);
}
private static void BuildConversionItems(List<LauncherItem> items, IPAddress ip)
{
var uval = IpToUint(ip);
var bin = ToBinary(uval);
items.Add(new LauncherItem("── 변환 ──", "", null, null, Symbol: "\uE968"));
items.Add(CopyItem("이진 (32bit)", bin));
items.Add(CopyItem("이진 (점구분)", ToBinaryDotted(uval)));
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
items.Add(CopyItem("정수 (uint32)", uval.ToString()));
}
private static void BuildIpv6Items(List<LauncherItem> items, IPAddress ip)
{
items.Add(new LauncherItem($"{ip}", "IPv6 주소", null, ("copy", ip.ToString()), Symbol: "\uE968"));
items.Add(CopyItem("전체 표기", ip.ToString()));
var expanded = ExpandIpv6(ip);
if (expanded != ip.ToString())
items.Add(CopyItem("확장 표기", expanded));
}
private static void BuildCidrItems(List<LauncherItem> items, string cidr)
{
var slash = cidr.IndexOf('/');
if (slash < 0) { items.Add(ErrorItem("올바른 CIDR 형식: 10.0.0.0/8")); return; }
var ipStr = cidr[..slash];
var lenStr = cidr[(slash + 1)..];
if (!IPAddress.TryParse(ipStr, out var addr) || addr.AddressFamily != AddressFamily.InterNetwork ||
!int.TryParse(lenStr, out var prefix) || prefix < 0 || prefix > 32)
{
items.Add(ErrorItem("올바른 IPv4 CIDR을 입력하세요 (예: 192.168.1.0/24)"));
return;
}
var mask = CidrToMask(prefix);
var maskIp = UintToIp(mask);
var wildcard = ~mask;
var network = IpToUint(addr) & mask;
var broadcast = network | wildcard;
var hostCount = prefix < 31 ? (broadcast - network - 1) : (broadcast - network + 1);
var firstHost = prefix < 31 ? network + 1 : network;
var lastHost = prefix < 31 ? broadcast - 1 : broadcast;
items.Add(new LauncherItem($"{cidr} → {IpToHostCount(prefix)}",
$"네트워크: {UintToIp(network)} 브로드캐스트: {UintToIp(broadcast)}", null, null, Symbol: "\uE968"));
items.Add(CopyItem("네트워크 주소", UintToIp(network)));
items.Add(CopyItem("브로드캐스트 주소", UintToIp(broadcast)));
items.Add(CopyItem("서브넷 마스크", maskIp.ToString()));
items.Add(CopyItem("와일드카드 마스크", UintToIp(wildcard)));
items.Add(CopyItem("첫 번째 호스트", UintToIp(firstHost)));
items.Add(CopyItem("마지막 호스트", UintToIp(lastHost)));
items.Add(CopyItem("호스트 수", $"{hostCount:N0}"));
items.Add(CopyItem("CIDR 표기", $"{UintToIp(network)}/{prefix}"));
items.Add(CopyItem("이진 마스크", ToBinaryDotted(mask)));
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string ClassifyIp(IPAddress ip)
{
var u = IpToUint(ip);
if (u == 0x7F000001u) return "루프백 (localhost)";
if ((u & 0xFF000000u) == 0x7F000000u) return "루프백";
if ((u & 0xFF000000u) == 0x0A000000u) return "사설 (Class A: 10.x.x.x)";
if ((u & 0xFFF00000u) == 0xAC100000u) return "사설 (Class B: 172.16-31.x.x)";
if ((u & 0xFFFF0000u) == 0xC0A80000u) return "사설 (Class C: 192.168.x.x)";
if ((u & 0xFFFF0000u) == 0xA9FE0000u) return "링크-로컬 (APIPA: 169.254.x.x)";
if ((u & 0xF0000000u) == 0xE0000000u) return "멀티캐스트 (224.x.x.x)";
if (u == 0xFFFFFFFFu) return "브로드캐스트";
if (u == 0u) return "0.0.0.0 (비지정)";
return "공인 IP";
}
private static string GetClass(uint u)
{
var b = (u >> 24) & 0xFF;
return b switch
{
<= 127 => "Class A",
<= 191 => "Class B",
<= 223 => "Class C",
<= 239 => "Class D (멀티캐스트)",
_ => "Class E (예약)"
};
}
private static uint IpToUint(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
if (bytes.Length != 4) return 0;
return (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]);
}
private static string UintToIp(uint u) =>
$"{(u >> 24) & 0xFF}.{(u >> 16) & 0xFF}.{(u >> 8) & 0xFF}.{u & 0xFF}";
private static string ToBinary(uint u) => Convert.ToString((long)u, 2).PadLeft(32, '0');
private static string ToBinaryDotted(uint u)
{
var b = ToBinary(u);
return $"{b[..8]}.{b[8..16]}.{b[16..24]}.{b[24..]}";
}
private static uint CidrToMask(int prefix) =>
prefix == 0 ? 0u : (0xFFFFFFFFu << (32 - prefix));
private static int MaskToCidr(IPAddress? mask)
{
if (mask == null) return 0;
var u = IpToUint(mask);
int c = 0;
while ((u & 0x80000000u) != 0) { c++; u <<= 1; }
return c;
}
private static string IpToHostCount(int prefix) =>
prefix switch
{
32 => "1 IP (단일 호스트)",
31 => "2 IP (P2P 링크)",
30 => "4 IP (2 호스트)",
_ => $"{(1L << (32 - prefix)):N0} IP ({(1L << (32 - prefix)) - 2:N0} 호스트)"
};
private static string ExpandIpv6(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
var groups = new string[8];
for (int i = 0; i < 8; i++)
groups[i] = $"{bytes[i * 2]:X2}{bytes[i * 2 + 1]:X2}";
return string.Join(":", groups).ToLowerInvariant();
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE968");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}