Files
AX-Copilot-Codex/src/AxCopilot/Handlers/SshHandler.cs
lacvet 0336904258 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개를 확인했습니다.
2026-04-05 00:59:45 +09:00

342 lines
12 KiB
C#

using System.Diagnostics;
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다.
///
/// 예: ssh → 저장된 SSH 호스트 목록
/// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터
/// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22)
/// ssh add name user@host:22 → 이름 지정하여 추가
/// ssh del <이름> → 호스트 삭제
/// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결.
/// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도).
/// </summary>
public class SshHandler : IActionHandler
{
private readonly SettingsService _settings;
public SshHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "ssh";
public PluginMetadata Metadata => new(
"SSH",
"SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var hosts = _settings.Settings.SshHosts;
if (string.IsNullOrWhiteSpace(q))
{
if (hosts.Count == 0)
{
items.Add(new LauncherItem(
"SSH 호스트 없음",
"ssh add user@host 또는 ssh add 이름 user@host:22",
null, null, Symbol: "\uE968"));
}
else
{
foreach (var h in hosts)
items.Add(MakeHostItem(h));
}
items.Add(new LauncherItem(
"ssh add user@host",
"새 호스트 추가",
null, null, Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// ── add ─────────────────────────────────────────────────────────
if (sub == "add")
{
SshHostEntry? entry = null;
if (parts.Length == 2)
{
// ssh add user@host[:port]
entry = ParseUserHost(parts[1]);
if (entry != null) entry.Name = entry.Host;
}
else if (parts.Length == 3)
{
// ssh add <name> user@host[:port]
entry = ParseUserHost(parts[2]);
if (entry != null) entry.Name = parts[1];
}
if (entry != null)
{
items.Add(new LauncherItem(
$"추가: {entry.Name}",
$"{entry.User}@{entry.Host}:{entry.Port}",
null,
("add", entry),
Symbol: "\uE710"));
}
else
{
items.Add(new LauncherItem(
"형식: ssh add user@host[:port]",
"또는: ssh add 이름 user@host:22",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del ─────────────────────────────────────────────────────────
if (sub == "del" || sub == "delete" || sub == "rm")
{
var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "";
var toDelete = hosts
.Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)
|| h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase))
.ToList();
if (toDelete.Count == 0)
{
items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783"));
}
else
{
foreach (var h in toDelete)
items.Add(new LauncherItem(
$"삭제: {h.Name}",
$"{h.User}@{h.Host}:{h.Port}",
null,
("del", h.Id),
Symbol: "\uE74D"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 검색 (이름 / 호스트 / 사용자 / 메모) ─────────────────────────
var filtered = hosts.Where(h =>
h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.Host.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.User.Contains(q, StringComparison.OrdinalIgnoreCase)
|| h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count > 0)
{
foreach (var h in filtered)
items.Add(MakeHostItem(h));
}
else
{
// 직접 접속 시도 (user@host 형식)
var entry = ParseUserHost(q);
if (entry != null)
{
items.Add(new LauncherItem(
$"연결: {entry.User}@{entry.Host}:{entry.Port}",
"Enter → Windows Terminal로 연결",
null,
("connect", entry),
Symbol: "\uE968"));
items.Add(new LauncherItem(
$"저장 후 연결",
$"이름: {entry.Host}",
null,
("add_connect", entry),
Symbol: "\uE710"));
}
else
{
items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
case ("connect", SshHostEntry h):
ConnectSsh(h);
break;
case ("add", SshHostEntry h):
_settings.Settings.SshHosts.RemoveAll(e =>
e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase)
&& e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase)
&& e.Port == h.Port);
_settings.Settings.SshHosts.Add(h);
_settings.Save();
NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다.");
break;
case ("add_connect", SshHostEntry h):
if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host;
_settings.Settings.SshHosts.Add(h);
_settings.Save();
ConnectSsh(h);
break;
case ("del", string id):
var removed = _settings.Settings.SshHosts
.RemoveAll(e => e.Id == id);
if (removed > 0)
{
_settings.Save();
NotificationService.Notify("SSH", "호스트를 삭제했습니다.");
}
break;
// 호스트 항목 직접 Enter
case SshHostEntry host:
ConnectSsh(host);
break;
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeHostItem(SshHostEntry h)
{
var portStr = h.Port != 22 ? $":{h.Port}" : "";
return new LauncherItem(
h.Name,
$"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}",
null,
h,
Symbol: "\uE968");
}
/// <summary>Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.</summary>
private static void ConnectSsh(SshHostEntry h)
{
try
{
// Windows Terminal (wt.exe) 우선
var wtPath = FindExecutable("wt.exe");
if (!string.IsNullOrEmpty(wtPath))
{
var portArgs = h.Port != 22 ? $" -p {h.Port}" : "";
var userHost = string.IsNullOrEmpty(h.User)
? h.Host : $"{h.User}@{h.Host}";
Process.Start(new ProcessStartInfo
{
FileName = wtPath,
Arguments = $"ssh {userHost}{portArgs}",
UseShellExecute = true,
});
return;
}
// PuTTY 대체
var puttyPath = FindExecutable("putty.exe");
if (!string.IsNullOrEmpty(puttyPath))
{
var userHost = string.IsNullOrEmpty(h.User)
? h.Host : $"{h.User}@{h.Host}";
Process.Start(new ProcessStartInfo
{
FileName = puttyPath,
Arguments = $"-ssh {userHost} -P {h.Port}",
UseShellExecute = true,
});
return;
}
// PowerShell 폴백
var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : "";
var cmd = string.IsNullOrEmpty(h.User)
? $"ssh {h.Host}{portArgs2}"
: $"ssh {h.User}@{h.Host}{portArgs2}";
Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoExit -Command \"{cmd}\"",
UseShellExecute = true,
});
}
catch (Exception ex)
{
NotificationService.Notify("SSH 오류", ex.Message);
}
}
/// <summary>PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.</summary>
private static string? FindExecutable(string exe)
{
// PATH 검색
var envPath = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var full = System.IO.Path.Combine(dir.Trim(), exe);
if (System.IO.File.Exists(full)) return full;
}
// 일반 설치 경로
var candidates = new[]
{
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft\\WindowsApps", exe),
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe),
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe),
};
return candidates.FirstOrDefault(System.IO.File.Exists);
}
/// <summary>user@host:port 형식을 파싱합니다.</summary>
private static SshHostEntry? ParseUserHost(string s)
{
if (string.IsNullOrEmpty(s)) return null;
string user = "";
string host = s;
int port = 22;
// user@host 파싱
var atIdx = s.IndexOf('@');
if (atIdx >= 0)
{
user = s[..atIdx];
host = s[(atIdx + 1)..];
}
// host:port 파싱
var colonIdx = host.LastIndexOf(':');
if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p))
{
port = p;
host = host[..colonIdx];
}
if (string.IsNullOrEmpty(host)) return null;
return new SshHostEntry
{
Id = Guid.NewGuid().ToString(),
Name = host,
Host = host,
Port = port,
User = user,
};
}
}