[Phase L6] 런처 워크플로우 자동화 확장 완료

MacroEntry/MacroStep 모델 (AppSettings.Models.cs):
- MacroEntry: Id, Name, Description, Steps, CreatedAt
- MacroStep: Type(app/url/folder/notification/cmd), Target, Args, Label, DelayMs
- AppSettings.Macros List<MacroEntry> 추가

Handlers/MacroHandler.cs (170줄 신규):
- prefix=macro, 서브커맨드: new/edit/del/play
- 목록: 단계 미리보기(최대 3단계) 표시
- 재생: Process.Start + 알림 + PowerShell 순서 실행

Views/MacroEditorWindow.xaml (135줄 신규):
- 이름/설명 입력, 열 헤더(유형/대상/표시이름/딜레이)
- 동적 행 ScrollViewer + 하단 단계 추가/저장 버튼

Views/MacroEditorWindow.xaml.cs (230줄 신규):
- StepRowUi 내부 클래스: Grid+TypeButton+TargetBox+LabelBox+DelayBox
- 공유 Popup 타입 선택기(5종): PlacementTarget 동적 설정
- BtnSave: 빈 Target 행 필터링 후 MacroEntry 저장

Handlers/ContextHandler.cs (185줄 신규):
- prefix=ctx, GetForegroundWindow P/Invoke 프로세스 감지
- 5개 컨텍스트 규칙(웹/코드/오피스/탐색기/커뮤니케이션) → 상황별 제안
- Enter 시 Views.LauncherWindow.SetInputText(prefix) 호출

ScheduleEntry 조건 필드 (AppSettings.Models.cs):
- ConditionProcess: 프로세스 이름 (비어있으면 조건 없음)
- ConditionProcessMustRun: true=실행중, false=비실행중

Services/SchedulerService.cs:
- ShouldFire() 확장: Process.GetProcessesByName() 조건 체크

Views/ScheduleEditorWindow.xaml / .cs:
- 조건 섹션 UI: 프로세스명 TextBox + 실행중/비실행중 세그먼트
- LoadFromEntry/BtnSave_Click 조건 필드 연동

App.xaml.cs:
- Phase L6-2 MacroHandler(settings) 등록
- Phase L6-3 ContextHandler() 등록

docs/LAUNCHER_ROADMAP.md:
- Phase L6 섹션 추가 (L6-1~4 전체 )

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 13:24:41 +09:00
parent e92800165d
commit dab633edd5
11 changed files with 1101 additions and 4 deletions

View File

@@ -0,0 +1,193 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다.
///
/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다.
/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천
/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천
/// </summary>
public class ContextHandler : IActionHandler
{
public string? Prefix => "ctx";
public PluginMetadata Metadata => new(
"Context",
"컨텍스트 명령 제안 — ctx",
"1.0",
"AX");
// ─── P/Invoke ─────────────────────────────────────────────────────────
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint dwProcessId);
// ─── 컨텍스트 규칙 ─────────────────────────────────────────────────────
// 프로세스 이름 → (앱 표시명, 추천 명령 목록)
private static readonly Dictionary<string[], (string AppName, ContextSuggestion[] Suggestions)> ContextMap
= new(new StringArrayEqualityComparer())
{
{
new[] { "chrome", "msedge", "firefox", "brave", "opera" },
("웹 브라우저", new[]
{
new ContextSuggestion("북마크 검색", "북마크를 검색합니다", "bm", Symbols.Favorite),
new ContextSuggestion("웹 검색", "기본 검색엔진으로 검색합니다", "?", "\uE721"),
new ContextSuggestion("URL 열기", "URL을 런처에서 직접 실행합니다", "url", "\uE71B"),
new ContextSuggestion("클립보드 내용 검색", "클립보드 이력을 검색합니다", "#", "\uE8C8"),
})
},
{
new[] { "code", "devenv", "rider", "idea64", "pycharm64", "webstorm64", "clion64" },
("코드 편집기", new[]
{
new ContextSuggestion("파일 검색", "인덱싱된 파일을 빠르게 엽니다", "", "\uE8A5"),
new ContextSuggestion("스니펫 입력", "텍스트 스니펫을 확장합니다", ";", "\uE8D2"),
new ContextSuggestion("클립보드 이력", "복사한 내용을 검색합니다", "#", "\uE8C8"),
new ContextSuggestion("파일 미리보기", "F3으로 파일 내용을 미리봅니다", "", "\uE7C3"),
new ContextSuggestion("QuickLook 편집","파일 인라인 편집 (Ctrl+E)", "", "\uE70F"),
})
},
{
new[] { "excel", "powerpnt", "winword", "onenote", "outlook", "hwp", "hwpx" },
("오피스", new[]
{
new ContextSuggestion("클립보드 이력", "복사한 셀·텍스트를 재사용합니다", "#", "\uE8C8"),
new ContextSuggestion("계산기", "수식을 빠르게 계산합니다", "=", "\uE8EF"),
new ContextSuggestion("날짜 계산", "날짜·기간을 계산합니다", "=today", "\uE787"),
new ContextSuggestion("파일 검색", "문서 파일을 빠르게 찾습니다", "", "\uE8A5"),
new ContextSuggestion("스니펫", "자주 쓰는 문구를 입력합니다", ";", "\uE8D2"),
})
},
{
new[] { "explorer" },
("파일 탐색기", new[]
{
new ContextSuggestion("파일 태그 검색", "태그로 파일을 찾습니다", "tag", "\uE932"),
new ContextSuggestion("배치 이름변경", "여러 파일을 한번에 이름변경합니다", "batchren", "\uE8AC"),
new ContextSuggestion("파일 미리보기", "선택 파일을 미리봅니다 (F3)", "", "\uE7C3"),
new ContextSuggestion("폴더 즐겨찾기", "즐겨찾기 폴더를 엽니다", "fav", Symbols.Favorite),
})
},
{
new[] { "slack", "teams", "zoom", "msteams" },
("커뮤니케이션", new[]
{
new ContextSuggestion("클립보드 이력", "공유할 내용을 클립보드에서 선택", "#", "\uE8C8"),
new ContextSuggestion("스크린 캡처", "화면을 캡처해 공유합니다", "cap", "\uE722"),
new ContextSuggestion("스니펫", "자주 쓰는 답변 텍스트를 입력합니다",";", "\uE8D2"),
new ContextSuggestion("번역", "텍스트를 번역합니다", "! 번역", "\uF2B7"),
})
},
};
// ─── 기본 제안 (앱 미인식) ────────────────────────────────────────────
private static readonly ContextSuggestion[] DefaultSuggestions =
{
new("파일/앱 검색", "이름으로 파일·앱을 빠르게 검색", "", "\uE721"),
new("클립보드 이력", "최근 복사한 내용 검색", "#", "\uE8C8"),
new("계산기", "수식 계산", "=", "\uE8EF"),
new("스니펫", "텍스트 스니펫 확장", ";", "\uE8D2"),
new("스케줄러", "자동화 스케줄 목록", "sched","\uE916"),
};
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var (procName, appDisplayName, suggestions) = GetContextInfo();
var items = new List<LauncherItem>();
var headerSub = string.IsNullOrEmpty(procName)
? "현재 포그라운드 앱을 인식할 수 없습니다"
: $"현재 앱: {appDisplayName} ({procName}) · 상황별 명령 제안";
items.Add(new LauncherItem(
"컨텍스트 제안",
headerSub,
null, null,
Symbol: "\uE945"));
var q = query.Trim().ToLowerInvariant();
foreach (var s in suggestions)
{
if (!string.IsNullOrEmpty(q) &&
!s.Title.Contains(q, StringComparison.OrdinalIgnoreCase) &&
!s.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase))
continue;
var subtitle = string.IsNullOrEmpty(s.Prefix)
? s.Subtitle
: $"{s.Subtitle} · 프리픽스: [{s.Prefix}]";
items.Add(new LauncherItem(
s.Title, subtitle, null, s.Prefix, Symbol: s.Symbol));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 제안 항목 실행 → 런처 입력창에 프리픽스 삽입
// LauncherWindow가 SetInputText를 지원하므로 Application 수준에서 접근
if (item.Data is string prefix && !string.IsNullOrEmpty(prefix))
{
var launcher = System.Windows.Application.Current?.Windows
.OfType<Views.LauncherWindow>()
.FirstOrDefault();
launcher?.SetInputText(prefix + " ");
}
return Task.CompletedTask;
}
// ─── 내부 유틸 ────────────────────────────────────────────────────────
private static (string ProcName, string AppName, ContextSuggestion[] Suggestions) GetContextInfo()
{
try
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return ("", "", DefaultSuggestions);
GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return ("", "", DefaultSuggestions);
var proc = Process.GetProcessById((int)pid);
var pName = proc.ProcessName.ToLowerInvariant();
foreach (var kv in ContextMap)
{
if (kv.Key.Any(k => pName.Contains(k)))
return (pName, kv.Value.AppName, kv.Value.Suggestions);
}
return (pName, pName, DefaultSuggestions);
}
catch
{
return ("", "", DefaultSuggestions);
}
}
// ─── 제안 레코드 ─────────────────────────────────────────────────────
private record ContextSuggestion(string Title, string Subtitle, string Prefix, string Symbol);
// ─── 키 비교기 ───────────────────────────────────────────────────────
private class StringArrayEqualityComparer : IEqualityComparer<string[]>
{
public bool Equals(string[]? x, string[]? y) =>
x != null && y != null && x.SequenceEqual(y);
public int GetHashCode(string[] obj) =>
obj.Aggregate(17, (h, s) => h * 31 + s.GetHashCode());
}
}

View File

@@ -0,0 +1,231 @@
using System.Diagnostics;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다.
///
/// 예: macro → 매크로 목록
/// macro 이름 → 이름으로 필터
/// macro new → 새 매크로 편집기 열기
/// macro edit 이름 → 기존 매크로 편집
/// macro del 이름 → 매크로 삭제
/// macro play 이름 → 즉시 실행
/// Enter로 선택한 매크로를 실행합니다.
/// </summary>
public class MacroHandler : IActionHandler
{
private readonly SettingsService _settings;
public MacroHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "macro";
public PluginMetadata Metadata => new(
"Macro",
"런처 매크로 — macro",
"1.0",
"AX");
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
if (cmd == "new")
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("새 매크로 만들기",
"편집기에서 단계별 실행 시퀀스를 설정합니다",
null, "__new__", Symbol: "\uE710")
});
}
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기",
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
});
}
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 삭제",
"Enter로 삭제 확인",
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
});
}
if (cmd == "play" && parts.Length > 1)
{
var entry = _settings.Settings.Macros
.FirstOrDefault(m => m.Name.Equals(parts[1], StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"[{entry.Name}] 매크로 실행",
$"{entry.Steps.Count}단계 · Enter로 즉시 실행",
null, entry, Symbol: "\uE768")
});
}
}
// 목록
var macros = _settings.Settings.Macros;
var filter = q.ToLowerInvariant();
var items = new List<LauncherItem>();
foreach (var m in macros)
{
if (!string.IsNullOrEmpty(filter) &&
!m.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!m.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var preview = m.Steps.Count == 0
? "단계 없음"
: string.Join(" → ", m.Steps.Take(3).Select(s => string.IsNullOrWhiteSpace(s.Label) ? s.Target : s.Label))
+ (m.Steps.Count > 3 ? $" … +{m.Steps.Count - 3}" : "");
items.Add(new LauncherItem(
m.Name,
$"{m.Steps.Count}단계 · {preview}",
null, m, Symbol: "\uE768"));
}
if (items.Count == 0 && string.IsNullOrEmpty(filter))
{
items.Add(new LauncherItem(
"등록된 매크로 없음",
"'macro new'로 명령 시퀀스를 추가하세요",
null, null, Symbol: Symbols.Info));
}
items.Add(new LauncherItem(
"새 매크로 만들기",
"macro new · 앱·URL·폴더·알림을 순서대로 실행",
null, "__new__", Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s)
{
if (s == "__new__")
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(null, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__edit__"))
{
var name = s["__edit__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(entry, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__del__"))
{
var name = s["__del__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
_settings.Settings.Macros.Remove(entry);
_settings.Save();
NotificationService.Notify("AX Copilot", $"매크로 '{name}' 삭제됨");
}
return Task.CompletedTask;
}
}
// 매크로 항목 Enter → 실행
if (item.Data is MacroEntry macro)
{
_ = RunMacroAsync(macro, ct);
}
return Task.CompletedTask;
}
// ─── 매크로 재생 ──────────────────────────────────────────────────────
internal static async Task RunMacroAsync(MacroEntry macro, CancellationToken ct)
{
int executed = 0;
foreach (var step in macro.Steps)
{
if (ct.IsCancellationRequested) break;
if (step.DelayMs > 0)
await Task.Delay(step.DelayMs, ct).ConfigureAwait(false);
try
{
switch (step.Type.ToLowerInvariant())
{
case "app":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo
{
FileName = step.Target,
Arguments = step.Args ?? "",
UseShellExecute = true
});
break;
case "url":
case "folder":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo(step.Target)
{ UseShellExecute = true });
break;
case "notification":
var msg = string.IsNullOrWhiteSpace(step.Label) ? step.Target : step.Label;
NotificationService.Notify($"[매크로] {macro.Name}", msg);
break;
case "cmd":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"{step.Target}\"")
{ UseShellExecute = false, CreateNoWindow = true });
break;
}
executed++;
}
catch (Exception ex)
{
LogService.Warn($"매크로 단계 실행 실패 '{step.Label}': {ex.Message}");
}
}
NotificationService.Notify("매크로 완료",
$"[{macro.Name}] {executed}/{macro.Steps.Count}단계 실행됨");
}
}