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,238 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다.
///
/// 예: pkg → 사용법 안내
/// pkg vscode → winget search vscode
/// pkg install {id} → winget install {id}
/// pkg list → 설치된 앱 목록
/// pkg upgrade → 업그레이드 가능 목록
/// winget 미설치 시 안내 메시지 표시.
/// </summary>
public partial class PkgHandler : IActionHandler
{
public string? Prefix => "pkg";
public PluginMetadata Metadata => new(
"앱 패키지",
"winget 앱 검색·설치·업그레이드",
"1.0",
"AX");
private static bool? _wingetAvailable;
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// winget 설치 여부 체크 (캐시)
_wingetAvailable ??= await CheckWingetAsync();
if (_wingetAvailable == false)
{
items.Add(new LauncherItem(
"winget이 설치되어 있지 않습니다",
"Windows Package Manager는 Windows 10 1709+ 에서 사용 가능합니다",
null, null, Symbol: Symbols.Warning));
return items;
}
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("winget 앱 패키지 관리",
"pkg {검색어} · pkg install {id} · pkg list · pkg upgrade",
null, null, Symbol: "\uECAA"));
return items;
}
// ── list 명령 ─────────────────────────────────────────────────────────
if (q.Equals("list", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem("설치된 앱 목록 조회 중...",
"winget list 실행", null, ("list", ""), Symbol: "\uECAA"));
// 실행 시 터미널에서 보여주기
return items;
}
// ── upgrade 명령 ──────────────────────────────────────────────────────
if (q.Equals("upgrade", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem("업그레이드 가능 앱 확인",
"Enter: winget upgrade 실행", null, ("upgrade", ""), Symbol: "\uE777"));
return items;
}
// ── install 명령 ──────────────────────────────────────────────────────
if (q.StartsWith("install ", StringComparison.OrdinalIgnoreCase))
{
var id = q[8..].Trim();
if (!string.IsNullOrWhiteSpace(id))
{
items.Add(new LauncherItem(
$"앱 설치: {id}",
$"Enter: winget install --id {id}",
null, ("install", id), Symbol: "\uE896"));
}
else
{
items.Add(new LauncherItem("사용법: pkg install {앱ID}",
"예: pkg install Microsoft.VisualStudioCode",
null, null, Symbol: Symbols.Info));
}
return items;
}
// ── 검색 ──────────────────────────────────────────────────────────────
try
{
var results = await SearchAsync(q, ct);
if (results.Count == 0)
{
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
"다른 검색어를 시도하세요", null, null, Symbol: Symbols.Search));
}
else
{
items.Add(new LauncherItem($"검색 결과: {results.Count}개",
"Enter: winget install --id {ID}", null, null, Symbol: Symbols.Search));
foreach (var r in results.Take(10))
{
items.Add(new LauncherItem(
$"{r.Name} [{r.Version}]",
$"{r.Id} · {r.Source}",
null, ("install", r.Id), Symbol: "\uECAA"));
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
items.Add(new LauncherItem("검색 오류", ex.Message, null, null, Symbol: Symbols.Error));
}
return items;
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("install", string id) && !string.IsNullOrWhiteSpace(id))
{
RunWingetInTerminal($"install --id \"{id}\" --accept-source-agreements --accept-package-agreements");
NotificationService.Notify("pkg", $"설치 시작: {id}");
}
else if (item.Data is ("list", _))
{
RunWingetInTerminal("list");
}
else if (item.Data is ("upgrade", _))
{
RunWingetInTerminal("upgrade --include-unknown");
}
return Task.CompletedTask;
}
// ─── winget 검색 ──────────────────────────────────────────────────────────
private record PkgResult(string Name, string Id, string Version, string Source);
private static async Task<List<PkgResult>> SearchAsync(string query, CancellationToken ct)
{
var output = await RunWingetAsync($"search \"{query}\" --accept-source-agreements", ct);
return ParseWingetOutput(output);
}
[GeneratedRegex(@"^(.+?)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(\S+)\s*$")]
private static partial Regex WingetLineRegex();
private static List<PkgResult> ParseWingetOutput(string output)
{
var results = new List<PkgResult>();
var lines = output.Split('\n');
bool pastHeader = false;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd();
// 헤더 구분선 (---) 이후부터 데이터
if (line.StartsWith("---") || line.StartsWith("───"))
{
pastHeader = true;
continue;
}
if (!pastHeader || string.IsNullOrWhiteSpace(line)) continue;
var match = WingetLineRegex().Match(line);
if (match.Success)
{
results.Add(new PkgResult(
match.Groups[1].Value.Trim(),
match.Groups[2].Value.Trim(),
match.Groups[3].Value.Trim(),
match.Groups[4].Value.Trim()));
}
}
return results;
}
// ─── winget 실행 ──────────────────────────────────────────────────────────
private static async Task<bool> CheckWingetAsync()
{
try
{
var output = await RunWingetAsync("--version", CancellationToken.None);
return output.TrimStart().StartsWith('v');
}
catch { return false; }
}
private static async Task<string> RunWingetAsync(string args, CancellationToken ct)
{
using var proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "winget",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8
}
};
proc.Start();
var output = await proc.StandardOutput.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return output;
}
private static void RunWingetInTerminal(string args)
{
try
{
// 사용자에게 진행 상황이 보이도록 터미널 창으로 실행
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/k winget {args}",
UseShellExecute = true
});
}
catch (Exception ex)
{
LogService.Warn($"winget 실행 실패: {ex.Message}");
}
}
}