229 lines
8.4 KiB
C#
229 lines
8.4 KiB
C#
using Microsoft.Win32;
|
|
using AxCopilot.SDK;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Themes;
|
|
|
|
namespace AxCopilot.Handlers;
|
|
|
|
/// <summary>
|
|
/// 앱 제거 핸들러. "uninstall" 프리픽스로 사용합니다.
|
|
/// 예: uninstall → 설치된 앱 전체 목록
|
|
/// uninstall chrome → "chrome" 포함 앱 필터
|
|
/// Enter 시 해당 앱의 제거 프로그램 실행.
|
|
/// </summary>
|
|
public class UninstallHandler : IActionHandler
|
|
{
|
|
public string? Prefix => "uninstall";
|
|
|
|
public PluginMetadata Metadata => new(
|
|
"Uninstall",
|
|
"앱 제거 — uninstall 뒤에 앱 이름 입력",
|
|
"1.0",
|
|
"AX");
|
|
|
|
// 레지스트리 언인스톨 키 경로
|
|
private static readonly string[] _regPaths =
|
|
{
|
|
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
};
|
|
|
|
// 캐시: 30초
|
|
private static (DateTime At, List<InstalledApp> Apps)? _cache;
|
|
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
|
|
|
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
|
{
|
|
var q = query.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(q))
|
|
{
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
|
[
|
|
new LauncherItem(
|
|
"제거할 앱 이름을 입력하세요",
|
|
"예: uninstall chrome · uninstall office",
|
|
null, null, Symbol: Symbols.Uninstall)
|
|
]);
|
|
}
|
|
|
|
var apps = GetInstalledApps();
|
|
var filtered = apps
|
|
.Where(a => a.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
|
(a.Publisher?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
|
|
.OrderBy(a => a.Name)
|
|
.Take(12)
|
|
.ToList();
|
|
|
|
if (!filtered.Any())
|
|
{
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
|
[
|
|
new LauncherItem(
|
|
"검색 결과 없음",
|
|
$"'{q}'에 해당하는 설치된 앱이 없습니다",
|
|
null, null, Symbol: Symbols.Info)
|
|
]);
|
|
}
|
|
|
|
var items = filtered.Select(a => new LauncherItem(
|
|
a.Name,
|
|
BuildSubtitle(a),
|
|
null,
|
|
a,
|
|
Symbol: Symbols.Uninstall)).ToList();
|
|
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
|
{
|
|
if (item.Data is not InstalledApp app) return Task.CompletedTask;
|
|
|
|
if (string.IsNullOrWhiteSpace(app.UninstallString))
|
|
{
|
|
LogService.Warn($"제거 문자열 없음: {app.Name}");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
try
|
|
{
|
|
// UninstallString이 "msiexec /x {GUID}" 또는 경로+args 형태
|
|
var uninstall = app.UninstallString.Trim();
|
|
|
|
if (uninstall.StartsWith("msiexec", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// msiexec 직접 실행
|
|
var args = uninstall.Substring("msiexec".Length).Trim();
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
|
|
"msiexec.exe", args) { UseShellExecute = true });
|
|
}
|
|
else
|
|
{
|
|
// 따옴표로 둘러싸인 경로 + 나머지 인수 분리
|
|
string exe, arguments;
|
|
if (uninstall.StartsWith('"'))
|
|
{
|
|
var close = uninstall.IndexOf('"', 1);
|
|
exe = close > 0 ? uninstall[1..close] : uninstall;
|
|
arguments = close > 0 ? uninstall[(close + 1)..].Trim() : "";
|
|
}
|
|
else
|
|
{
|
|
var spaceIdx = uninstall.IndexOf(' ');
|
|
if (spaceIdx > 0)
|
|
{
|
|
exe = uninstall[..spaceIdx];
|
|
arguments = uninstall[(spaceIdx + 1)..];
|
|
}
|
|
else
|
|
{
|
|
exe = uninstall;
|
|
arguments = "";
|
|
}
|
|
}
|
|
|
|
var psi = new System.Diagnostics.ProcessStartInfo(exe, arguments)
|
|
{ UseShellExecute = true };
|
|
System.Diagnostics.Process.Start(psi);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"제거 실행 실패 [{app.Name}]: {ex.Message}");
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
|
|
private static string BuildSubtitle(InstalledApp a)
|
|
{
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(a.Publisher)) parts.Add(a.Publisher!);
|
|
if (!string.IsNullOrWhiteSpace(a.Version)) parts.Add($"v{a.Version}");
|
|
if (!string.IsNullOrWhiteSpace(a.InstallDate)) parts.Add(a.InstallDate!);
|
|
parts.Add("Enter로 제거");
|
|
return string.Join(" · ", parts);
|
|
}
|
|
|
|
private static List<InstalledApp> GetInstalledApps()
|
|
{
|
|
if (_cache.HasValue && (DateTime.Now - _cache.Value.At) < CacheTtl)
|
|
return _cache.Value.Apps;
|
|
|
|
var result = new List<InstalledApp>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var root in new[] { Registry.LocalMachine, Registry.CurrentUser })
|
|
{
|
|
foreach (var regPath in _regPaths)
|
|
{
|
|
try
|
|
{
|
|
using var key = root.OpenSubKey(regPath);
|
|
if (key == null) continue;
|
|
|
|
foreach (var subName in key.GetSubKeyNames())
|
|
{
|
|
try
|
|
{
|
|
using var sub = key.OpenSubKey(subName);
|
|
if (sub == null) continue;
|
|
|
|
var name = sub.GetValue("DisplayName") as string;
|
|
var uninstall = sub.GetValue("UninstallString") as string;
|
|
|
|
// 이름·제거 문자열 없으면 스킵, 시스템 업데이트/패치 스킵
|
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
|
if (string.IsNullOrWhiteSpace(uninstall)) continue;
|
|
if (name.StartsWith("KB", StringComparison.OrdinalIgnoreCase) &&
|
|
name.Length < 12) continue;
|
|
if (!seen.Add(name)) continue;
|
|
|
|
var systemComponent = sub.GetValue("SystemComponent");
|
|
if (systemComponent is int sc && sc == 1) continue;
|
|
|
|
result.Add(new InstalledApp
|
|
{
|
|
Name = name,
|
|
UninstallString = uninstall,
|
|
Publisher = sub.GetValue("Publisher") as string,
|
|
Version = sub.GetValue("DisplayVersion") as string,
|
|
InstallDate = FormatDate(sub.GetValue("InstallDate") as string),
|
|
});
|
|
}
|
|
catch (Exception) { /* 개별 키 읽기 실패 무시 */ }
|
|
}
|
|
}
|
|
catch (Exception) { /* 레지스트리 접근 실패 무시 */ }
|
|
}
|
|
}
|
|
|
|
result.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
|
_cache = (DateTime.Now, result);
|
|
return result;
|
|
}
|
|
|
|
private static string? FormatDate(string? raw)
|
|
{
|
|
// InstallDate 형식: "20230615" → "2023-06-15"
|
|
if (raw?.Length == 8 &&
|
|
int.TryParse(raw, out _))
|
|
{
|
|
return $"{raw[..4]}-{raw[4..6]}-{raw[6..8]}";
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
internal sealed class InstalledApp
|
|
{
|
|
public string Name { get; init; } = "";
|
|
public string? UninstallString { get; init; }
|
|
public string? Publisher { get; init; }
|
|
public string? Version { get; init; }
|
|
public string? InstallDate { get; init; }
|
|
}
|