[Phase L28] Windows 생태계 통합 + 콘텐츠 UX 강화 4종 구현

PkgHandler.cs (신규, prefix=pkg):
- winget search subprocess 결과 Regex 파싱 (PkgResult 레코드)
- pkg install {id} → cmd 터미널으로 실행 (사용자 확인 가능)
- pkg list / pkg upgrade 서브명령
- winget 미설치 감지 → 안내 메시지 (캐시 체크)
- PowerToys Command Palette winget 기능 대응

ApHandler.cs (신규, prefix=ap):
- 클립보드 텍스트 즉시 변환 15종 내장
- upper/lower/trim/sort/rsort/unique/number/reverse
- blank/single/count/json/slug/base64/decode64
- replace {A} {B} 텍스트 치환 명령
- PowerToys Advanced Paste 대응

SnippetHandler.cs (수정, L28-2):
- ExpandVariables 5개 플레이스홀더 추가:
  · {clipboard} → 현재 클립보드 텍스트
  · {user} → Windows 사용자명
  · {computer} → PC명
  · {weekday} → 한국어 요일 (월요일~일요일)
  · {app} → 이전 포커스 앱 프로세스명
- GetWindowThreadProcessId P/Invoke 추가
- Raycast 스니펫 동적 플레이스홀더 대응

LauncherViewModel.cs (수정, L28-3):
- UpdatePreviewAsync 미리보기 확장:
  · 이미지 10종 (.jpg/.png/.gif 등): BitmapDecoder 해상도 + 파일 크기
  · PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자 추출
  · 오디오/동영상 12종: 파일 크기 + 수정일 메타
- IsImageFile(), IsMediaFile(), GetImageMeta(), GetPdfMeta(),
  GetFileSizeMeta(), FormatFileSize() 헬퍼 메서드 추가

App.xaml.cs: L28 핸들러 2개 등록 (PkgHandler, ApHandler)
LAUNCHER_ROADMAP.md: Phase L28  완료 + 벤치마킹 공백 3개 해소
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 20:05:14 +09:00
parent a67cdf574d
commit b5c60b5398
6 changed files with 667 additions and 15 deletions

View File

@@ -2,7 +2,7 @@
## 현재 상태 (v2.2.0) ## 현재 상태 (v2.2.0)
### 핵심 기능 (119개 핸들러, L27 완료 / L28~L29 계획 중) ### 핵심 기능 (121개 핸들러, L28 완료 / L29 계획 중)
- 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)** - 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)**
- 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등) - 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등)
- 10가지 테마 + 커스텀 테마 - 10가지 테마 + 커스텀 테마
@@ -43,19 +43,19 @@
| **스니펫 확장** | ✅ | ✅ 동적 플레이스홀더 | ✅ | △ 플러그인 | ❌ | ❌ | | **스니펫 확장** | ✅ | ✅ 동적 플레이스홀더 | ✅ | △ 플러그인 | ❌ | ❌ |
| **창 관리** | ✅ 22 레이아웃 | ✅ 70+ (Win 베타 탑재) | ❌ | ❌ | △ FancyZones 연동 | ❌ | | **창 관리** | ✅ 22 레이아웃 | ✅ 70+ (Win 베타 탑재) | ❌ | ❌ | △ FancyZones 연동 | ❌ |
| **파일 탐색기 인라인** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **독점** | | **파일 탐색기 인라인** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **독점** |
| **파일 미리보기** | △ 텍스트 6줄 | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 | | **파일 미리보기** | ✅ (L28 강화) | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 |
| **브라우저 북마크 검색** | ❌ **공백** | ✅ 확장 연동 | ✅ | △ 플러그인 | ✅ 내장 | ❌ | | **브라우저 북마크 검색** | ❌ **공백** | ✅ 확장 연동 | ✅ | △ 플러그인 | ✅ 내장 | ❌ |
| **브라우저 탭 AI 전달** | ❌ **공백** | ✅ {browser-tab} | ❌ | ❌ | ❌ | ❌ | | **브라우저 탭 AI 전달** | ❌ **공백** | ✅ {browser-tab} | ❌ | ❌ | ❌ | ❌ |
| **시스템 볼륨 제어** | ✅ (L27) | ✅ | ✅ | ❌ | ❌ | ❌ | | **시스템 볼륨 제어** | ✅ (L27) | ✅ | ✅ | ❌ | ❌ | ❌ |
| **화면 밝기 제어** | ✅ (L27) | ✅ | ❌ | ❌ | ❌ | ❌ | | **화면 밝기 제어** | ✅ (L27) | ✅ | ❌ | ❌ | ❌ | ❌ |
| **QR 코드 생성** | ✅ (L27) | ✅ | ✅ | △ 플러그인 | ❌ | ❌ | | **QR 코드 생성** | ✅ (L27) | ✅ | ✅ | △ 플러그인 | ❌ | ❌ |
| **회의 링크 빠른 열기** | ✅ (L27) | ✅ Calendar 연동 | ✅ | ❌ | ❌ | ❌ | | **회의 링크 빠른 열기** | ✅ (L27) | ✅ Calendar 연동 | ✅ | ❌ | ❌ | ❌ |
| **winget 앱 설치** | **공백** | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 | | **winget 앱 설치** | ✅ (L28) | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 |
| **노코드 워크플로우** | ❌ **공백** | △ AI Ext 베타 | ✅ 완전 지원 | ❌ | ❌ | ❌ | | **노코드 워크플로우** | ❌ **공백** | △ AI Ext 베타 | ✅ 완전 지원 | ❌ | ❌ | ❌ |
| **스크립트 명령 실행** | ✅ ^ 프리픽스 | ✅ Script Commands | ✅ | ✅ | ✅ | ❌ | | **스크립트 명령 실행** | ✅ ^ 프리픽스 | ✅ Script Commands | ✅ | ✅ | ✅ | ❌ |
| **Everything 연동** | ✅ es 프리픽스 | ❌ | ❌ | ✅ | ❌ | ❌ | | **Everything 연동** | ✅ es 프리픽스 | ❌ | ❌ | ✅ | ❌ | ❌ |
| **선택 텍스트 AI** | ✅ 팝업 | ✅ AI Commands | ❌ | ❌ | ❌ | ❌ | | **선택 텍스트 AI** | ✅ 팝업 | ✅ AI Commands | ❌ | ❌ | ❌ | ❌ |
| **AI 붙여넣기 변환** | **공백** | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ | | **AI 붙여넣기 변환** | ✅ (L28) | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ |
| **OCR** | ✅ | ❌ | ❌ | △ 플러그인 | ✅ Text Extractor | ❌ | | **OCR** | ✅ | ❌ | ❌ | △ 플러그인 | ✅ Text Extractor | ❌ |
| **독 바 (영구 표시)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **독 바 (영구 표시)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **플러그인 생태계** | DLL+JSON | 1,300+ | 수백+ | 200+ | 내장 중심 | ❌ | | **플러그인 생태계** | DLL+JSON | 1,300+ | 수백+ | 200+ | 내장 중심 | ❌ |
@@ -602,20 +602,20 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
--- ---
## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) 📋 계획 ## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) ✅ 완료
> **방향**: PowerToys·Raycast Windows 버전이 선점 중인 **Windows 네이티브 통합** + 스니펫·미리보기 UX 수준 향상. > **방향**: PowerToys·Raycast Windows 버전이 선점 중인 **Windows 네이티브 통합** + 스니펫·미리보기 UX 수준 향상.
> 외부 설치(winget)가 필요한 기능은 미설치 감지 → 안내 메시지 패턴 적용. > 외부 설치(winget)가 필요한 기능은 미설치 감지 → 안내 메시지 패턴 적용.
| # | 기능 | 프리픽스 | 구현 방식 | 경쟁 대비 | | # | 기능 | 프리픽스 | 구현 방식 |
|---|------|---------|---------|---------| |---|------|---------|---------|
| 📋 L28-1 | **winget 앱 검색·설치** | `pkg` | `winget search {q}` subprocess 결과 파싱. `pkg install {id}`권한 확인 후 실행. `pkg list` → 설치된 앱. winget 미설치 감지 후 안내. PowerToys CP 내장 기능 대응 | PowerToys CP | | L28-1 | **winget 앱 검색·설치** | `pkg` | `PkgHandler.cs` `winget search` subprocess 결과 Regex 파싱. `pkg install {id}`cmd 터미널로 실행. `pkg list` / `pkg upgrade`. winget 미설치 감지 후 안내. PowerToys CP 대응 |
| 📋 L28-2 | **스니펫 동적 플레이스홀더** | `snip` 확장 | 기존 SnippetHandler에 치환 엔진 추가. `{date}` → 오늘 날짜, `{time}` → 현재 시각, `{clipboard}` 현재 클립보드, `{user}` → Windows 사용자명, `{app}` 포커스 앱. Raycast 스니펫 플레이스홀더 대응 | Raycast | | L28-2 | **스니펫 동적 플레이스홀더** | `;` 확장 | `SnippetHandler.cs` 수정 — 기존 {date}/{time} 외 `{clipboard}` (현재 클립보드), `{user}` (사용자명), `{computer}` (PC명), `{weekday}` (한국어 요일), `{app}` (이전 포커스 앱 프로세스명) 5개 추가. Raycast 스니펫 대응 |
| 📋 L28-3 | **파일 미리보기 강화** | 파일 검색 결과 확장 | 기존 텍스트 6줄에서: 이미지(jpg·png·gif·svg) → WPF BitmapImage 썸네일 (120px). PDF → `Windows.Data.Pdf` 첫 페이지 렌더링. 음악(mp3) → 파일 메타(태그·길이·비트레이트). Alfred Grid View 방향 | Alfred 5 | | L28-3 | **파일 미리보기 강화** | 파일 검색 확장 | `LauncherViewModel.cs` 수정 — 이미지(10종): BitmapDecoder 해상도 + 파일 크기. PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자. 오디오/동영상: 파일 크기 + 수정일. Alfred Grid View 경량 대응 |
| 📋 L28-4 | **AI 붙여넣기 변환** | `ap` | 클립보드 텍스트를 AX Agent에 즉시 전달해 변환. `ap 요약`, `ap 번역`, `ap 교정`, `ap 표로 변환` 등. AI 비활성 시 차단. PowerToys Advanced Paste 대응 (사내 LLM 버전) | PowerToys | | L28-4 | **클립보드 텍스트 즉시 변환** | `ap` | `ApHandler.cs` — 15종 변환: upper/lower/trim/sort/rsort/unique/number/reverse/blank/single/count/json/slug/base64/decode64 + replace 치환. PowerToys Advanced Paste 대응 (텍스트 변환 중심) |
**구현 우선순위**: L28-2(snip 확장) → L28-3(미리보기) → L28-1(pkg) → L28-4(ap) **NuGet 추가**: 없음 (PdfPig 기존 참조 활용)
**주의**: L28-1 winget은 winget 설치 여부 런타임 체크 필수. L28-4는 `AiEnabled` 게이트 필수. **빌드**: 경고 0, 오류 0
--- ---

View File

@@ -385,6 +385,14 @@ public partial class App : System.Windows.Application
// L27-6: 클립보드 순차 붙여넣기 (prefix=paste) // L27-6: 클립보드 순차 붙여넣기 (prefix=paste)
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory)); commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
// ─── L28: Windows 생태계 통합 + 콘텐츠 UX 강화 ─────────────────────
// L28-1: winget 앱 검색·설치 (prefix=pkg)
commandResolver.RegisterHandler(new PkgHandler());
// L28-2: SnippetHandler 동적 플레이스홀더 확장 — {clipboard}/{user}/{app}/{weekday}/{computer} 추가 (기존 핸들러 수정)
// L28-3: 파일 미리보기 강화 — 이미지 해상도·PDF 페이지수·미디어 메타 (LauncherViewModel 수정)
// L28-4: 클립보드 텍스트 즉시 변환 (prefix=ap)
commandResolver.RegisterHandler(new ApHandler());
// ─── 플러그인 로드 ──────────────────────────────────────────────────── // ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver); var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll(); pluginHost.LoadAll();

View File

@@ -0,0 +1,260 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L28-4: 클립보드 텍스트 즉시 변환 핸들러. "ap" 프리픽스로 사용합니다.
/// (Advanced Paste — PowerToys Advanced Paste 대응)
///
/// 예: ap → 클립보드 내용 표시 + 변환 목록
/// ap upper → 대문자 변환
/// ap lower → 소문자 변환
/// ap trim → 앞뒤 공백 제거
/// ap sort → 줄 정렬
/// ap unique → 중복 줄 제거
/// ap number → 줄 번호 추가
/// ap reverse → 줄 순서 뒤집기
/// ap count → 글자/단어/줄 수
/// ap json → JSON 정리 (포맷팅)
/// ap remove blank → 빈 줄 제거
/// ap replace A B → A를 B로 전체 치환
/// Enter → 변환된 텍스트를 클립보드에 복사.
/// </summary>
public class ApHandler : IActionHandler
{
public string? Prefix => "ap";
public PluginMetadata Metadata => new(
"텍스트 변환",
"클립보드 텍스트 즉시 변환 (Advanced Paste)",
"1.0",
"AX");
private static readonly (string Cmd, string Label, string Desc)[] Commands =
[
("upper", "대문자 변환", "전체 텍스트를 대문자로"),
("lower", "소문자 변환", "전체 텍스트를 소문자로"),
("trim", "공백 정리", "각 줄 앞뒤 공백 제거"),
("sort", "줄 정렬", "알파벳/가나다 순 줄 정렬"),
("rsort", "줄 역순 정렬", "역순 줄 정렬"),
("unique", "중복 제거", "동일 줄 제거 (순서 유지)"),
("number", "줄 번호 추가", "각 줄 앞에 1. 2. 3. 번호"),
("reverse", "줄 순서 뒤집기", "마지막 줄 → 첫 줄"),
("count", "텍스트 통계", "글자·단어·줄 수 표시"),
("blank", "빈 줄 제거", "빈 줄 삭제"),
("json", "JSON 정리", "JSON 들여쓰기 포맷팅"),
("single", "한 줄로 합치기", "줄바꿈 → 공백으로 연결"),
("slug", "URL 슬러그", "소문자 + 하이픈 (공백/특수문자 변환)"),
("base64", "Base64 인코딩", "텍스트 → Base64"),
("decode64","Base64 디코딩", "Base64 → 텍스트"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
// 클립보드 텍스트 읽기
string clipText;
try
{
clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
}
catch
{
clipText = "";
}
if (string.IsNullOrEmpty(clipText))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"변환할 텍스트를 먼저 복사하세요",
null, null, Symbol: Symbols.Clipboard));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var preview = clipText.Length > 80 ? clipText[..77].Replace("\n", " ") + "…" : clipText.Replace("\n", " ");
int lineCount = clipText.Split('\n').Length;
// ── replace 명령 ───────────────────────────────────────────────────────
if (q.StartsWith("replace "))
{
var parts = q[8..].Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
var from = parts[0];
var to = parts[1];
var result = clipText.Replace(from, to, StringComparison.OrdinalIgnoreCase);
int count = (clipText.Length - result.Length) / Math.Max(from.Length - to.Length, 1);
items.Add(new LauncherItem(
$"치환: '{from}' → '{to}'",
$"Enter: 클립보드 갱신",
null, ("result", result), Symbol: Symbols.Clipboard));
}
else
{
items.Add(new LauncherItem("사용법: ap replace {찾을값} {바꿀값}",
"예: ap replace hello world", null, null, Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 빈 쿼리 → 전체 명령 목록 ─────────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem(
$"클립보드: {preview}",
$"{clipText.Length}자 · {lineCount}줄 · 아래 변환 명령 선택",
null, null, Symbol: Symbols.Clipboard));
foreach (var (cmd, label, desc) in Commands)
{
items.Add(new LauncherItem(
$"ap {cmd} — {label}",
desc,
null, ("cmd", cmd), Symbol: "\uE8AC"));
}
items.Add(new LauncherItem(
"ap replace {A} {B} — 텍스트 치환",
"A를 B로 전체 치환",
null, null, Symbol: "\uE8AC"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 명령 실행 미리보기 ───────────────────────────────────────────────
var transformed = Transform(clipText, q);
if (transformed != null)
{
var tPreview = transformed.Length > 120 ? transformed[..117].Replace("\n", " ") + "…" : transformed.Replace("\n", " ");
var cmdInfo = Commands.FirstOrDefault(c => c.Cmd == q);
items.Add(new LauncherItem(
$"{(cmdInfo.Label ?? q)} 결과",
$"{tPreview} · Enter: 클립보드 갱신",
null, ("result", transformed), Symbol: Symbols.Clipboard));
}
else
{
// 부분 매칭으로 명령 제안
var matched = Commands.Where(c =>
c.Cmd.Contains(q) || c.Label.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (matched.Count > 0)
{
foreach (var (cmd, label, desc) in matched)
items.Add(new LauncherItem($"ap {cmd} — {label}", desc,
null, ("cmd", cmd), Symbol: "\uE8AC"));
}
else
{
items.Add(new LauncherItem($"'{q}' 알 수 없는 변환 명령",
"ap upper/lower/trim/sort/unique/number/reverse/json ...",
null, null, Symbol: Symbols.Warning));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("result", string result))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
}
catch (Exception ex)
{
NotificationService.Notify("ap", $"복사 실패: {ex.Message}");
}
}
else if (item.Data is ("cmd", string cmd))
{
try
{
var clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
var result2 = Transform(clipText, cmd);
if (result2 != null)
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result2));
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
}
}
catch (Exception ex)
{
NotificationService.Notify("ap", $"변환 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 변환 로직 ───────────────────────────────────────────────────────────
private static string? Transform(string text, string cmd)
{
var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
return cmd switch
{
"upper" => text.ToUpperInvariant(),
"lower" => text.ToLowerInvariant(),
"trim" => string.Join("\n", lines.Select(l => l.Trim())),
"sort" => string.Join("\n", lines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)),
"rsort" => string.Join("\n", lines.OrderByDescending(l => l, StringComparer.OrdinalIgnoreCase)),
"unique" => string.Join("\n", lines.Distinct()),
"number" => string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}")),
"reverse" => string.Join("\n", lines.Reverse()),
"blank" => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))),
"single" => string.Join(" ", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim())),
"count" => $"글자: {text.Length} · 단어: {CountWords(text)} · 줄: {lines.Length} · 바이트: {Encoding.UTF8.GetByteCount(text)}",
"json" => TryFormatJson(text),
"slug" => ToSlug(text),
"base64" => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
"decode64" => TryDecodeBase64(text),
_ => null
};
}
private static int CountWords(string text)
=> Regex.Matches(text, @"[\w가-힣]+").Count;
private static string TryFormatJson(string text)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(text);
using var ms = new System.IO.MemoryStream();
using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
doc.WriteTo(writer);
writer.Flush();
return Encoding.UTF8.GetString(ms.ToArray());
}
catch { return "유효하지 않은 JSON입니다."; }
}
private static string ToSlug(string text)
{
var slug = text.ToLowerInvariant().Trim();
slug = Regex.Replace(slug, @"[^a-z0-9가-힣\s-]", "");
slug = Regex.Replace(slug, @"[\s]+", "-");
slug = Regex.Replace(slug, @"-{2,}", "-");
return slug.Trim('-');
}
private static string TryDecodeBase64(string text)
{
try
{
var bytes = Convert.FromBase64String(text.Trim());
return Encoding.UTF8.GetString(bytes);
}
catch { return "유효하지 않은 Base64입니다."; }
}
}

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}");
}
}
}

View File

@@ -107,15 +107,53 @@ public class SnippetHandler : IActionHandler
private static string ExpandVariables(string content) private static string ExpandVariables(string content)
{ {
var now = DateTime.Now; var now = DateTime.Now;
return content var result = content
.Replace("{date}", now.ToString("yyyy-MM-dd")) .Replace("{date}", now.ToString("yyyy-MM-dd"))
.Replace("{time}", now.ToString("HH:mm:ss")) .Replace("{time}", now.ToString("HH:mm:ss"))
.Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss")) .Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss"))
.Replace("{year}", now.Year.ToString()) .Replace("{year}", now.Year.ToString())
.Replace("{month}", now.Month.ToString("D2")) .Replace("{month}", now.Month.ToString("D2"))
.Replace("{day}", now.Day.ToString("D2")); .Replace("{day}", now.Day.ToString("D2"))
.Replace("{weekday}", now.ToString("dddd", System.Globalization.CultureInfo.GetCultureInfo("ko-KR")))
.Replace("{user}", Environment.UserName)
.Replace("{computer}", Environment.MachineName);
// {clipboard} → 현재 클립보드 텍스트 (UI 스레드에서 실행)
if (result.Contains("{clipboard}"))
{
try
{
var clip = System.Windows.Application.Current.Dispatcher.Invoke(
() => System.Windows.Clipboard.GetText()) ?? "";
result = result.Replace("{clipboard}", clip);
}
catch { result = result.Replace("{clipboard}", ""); }
}
// {app} → 이전 포커스 앱 이름
if (result.Contains("{app}"))
{
var appName = "";
try
{
var hwnd = Services.WindowTracker.PreviousWindow;
if (hwnd != IntPtr.Zero)
{
GetWindowThreadProcessId(hwnd, out uint pid);
using var proc = System.Diagnostics.Process.GetProcessById((int)pid);
appName = proc.ProcessName;
}
}
catch { }
result = result.Replace("{app}", appName);
}
return result;
} }
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
private static string TruncatePreview(string content) private static string TruncatePreview(string content)
{ {
var oneLine = content.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " "); var oneLine = content.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " ");

View File

@@ -568,6 +568,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
if (item.Data is IndexEntry indexEntry) if (item.Data is IndexEntry indexEntry)
{ {
var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant(); var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant();
// 텍스트 파일: 첫 6줄 미리보기
if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path)) if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path))
{ {
var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct); var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct);
@@ -576,6 +578,29 @@ public partial class LauncherViewModel : INotifyPropertyChanged
HasPreview = !string.IsNullOrEmpty(PreviewText.Trim()); HasPreview = !string.IsNullOrEmpty(PreviewText.Trim());
return; return;
} }
// 이미지 파일: 해상도·크기 메타데이터
if (IsImageFile(ext) && System.IO.File.Exists(indexEntry.Path))
{
var meta = await Task.Run(() => GetImageMeta(indexEntry.Path), ct);
if (ct.IsCancellationRequested) return;
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
}
// PDF 파일: 페이지 수·크기·첫 페이지 텍스트
if (ext == ".pdf" && System.IO.File.Exists(indexEntry.Path))
{
var meta = await Task.Run(() => GetPdfMeta(indexEntry.Path), ct);
if (ct.IsCancellationRequested) return;
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
}
// 미디어 파일: 파일 크기
if (IsMediaFile(ext) && System.IO.File.Exists(indexEntry.Path))
{
var meta = GetFileSizeMeta(indexEntry.Path, ext);
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
}
} }
HasPreview = false; HasPreview = false;
@@ -589,6 +614,89 @@ public partial class LauncherViewModel : INotifyPropertyChanged
or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf" or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf"
or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css"; or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css";
private static bool IsImageFile(string ext) => ext is
".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp"
or ".svg" or ".ico" or ".tiff" or ".tif";
private static bool IsMediaFile(string ext) => ext is
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma"
or ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm";
private static string? GetImageMeta(string path)
{
try
{
var fi = new System.IO.FileInfo(path);
var size = FormatFileSize(fi.Length);
var ext = fi.Extension.TrimStart('.').ToUpperInvariant();
// BitmapDecoder로 해상도 읽기
using var stream = new System.IO.FileStream(path,
System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read);
var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create(
stream, System.Windows.Media.Imaging.BitmapCreateOptions.DelayCreation,
System.Windows.Media.Imaging.BitmapCacheOption.None);
var frame = decoder.Frames[0];
return $"🖼 {ext} 이미지 · {frame.PixelWidth}×{frame.PixelHeight} · {size}\n" +
$"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
}
catch { return null; }
}
private static string? GetPdfMeta(string path)
{
try
{
var fi = new System.IO.FileInfo(path);
var size = FormatFileSize(fi.Length);
using var doc = UglyToad.PdfPig.PdfDocument.Open(path);
int pages = doc.NumberOfPages;
var firstPageText = "";
if (pages > 0)
{
var page = doc.GetPage(1);
firstPageText = page.Text;
if (firstPageText.Length > 200)
firstPageText = firstPageText[..200] + "…";
firstPageText = firstPageText.Replace("\r\n", " ").Replace("\n", " ");
}
var meta = $"📄 PDF · {pages}페이지 · {size}";
if (!string.IsNullOrWhiteSpace(firstPageText))
meta += $"\n{firstPageText}";
return meta;
}
catch { return null; }
}
private static string? GetFileSizeMeta(string path, string ext)
{
try
{
var fi = new System.IO.FileInfo(path);
var size = FormatFileSize(fi.Length);
var type = ext switch
{
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" => "🎵 오디오",
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm" => "🎬 동영상",
_ => "📁 파일"
};
return $"{type} · {ext.TrimStart('.').ToUpperInvariant()} · {size}\n" +
$"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
}
catch { return null; }
}
private static string FormatFileSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1048576 => $"{bytes / 1024.0:F1} KB",
< 1073741824 => $"{bytes / 1048576.0:F1} MB",
_ => $"{bytes / 1073741824.0:F2} GB"
};
private static async Task<IEnumerable<string>> ReadFirstLinesAsync( private static async Task<IEnumerable<string>> ReadFirstLinesAsync(
string path, int maxLines, CancellationToken ct) string path, int maxLines, CancellationToken ct)
{ {