From dab633edd52b6ecb54a8382ed09add675a1c4673 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 13:24:41 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L6]=20=EB=9F=B0=EC=B2=98=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=20=ED=99=95=EC=9E=A5=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 추가 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 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 4 + src/AxCopilot/Handlers/ContextHandler.cs | 193 +++++++++++ src/AxCopilot/Handlers/MacroHandler.cs | 231 +++++++++++++ src/AxCopilot/Models/AppSettings.Models.cs | 66 ++++ src/AxCopilot/Models/AppSettings.cs | 6 + src/AxCopilot/Services/SchedulerService.cs | 19 +- src/AxCopilot/Views/MacroEditorWindow.xaml | 179 ++++++++++ src/AxCopilot/Views/MacroEditorWindow.xaml.cs | 320 ++++++++++++++++++ src/AxCopilot/Views/ScheduleEditorWindow.xaml | 36 ++ .../Views/ScheduleEditorWindow.xaml.cs | 38 ++- 11 files changed, 1101 insertions(+), 4 deletions(-) create mode 100644 src/AxCopilot/Handlers/ContextHandler.cs create mode 100644 src/AxCopilot/Handlers/MacroHandler.cs create mode 100644 src/AxCopilot/Views/MacroEditorWindow.xaml create mode 100644 src/AxCopilot/Views/MacroEditorWindow.xaml.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 42bb0ff..f561aa2 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -142,6 +142,19 @@ L5-1 (핫키) → L5-2 (OCR) → L5-3 (편집) → L5-5 (배치 이름변경) → L5-4 (세션 스냅) → L5-6 (스케줄러) ``` +--- + +## Phase L6 — 워크플로우 자동화 확장 (v1.7.5~) ✅ 완료 + +> **방향**: 런처를 "행동 허브"로 완성. 매크로 시퀀서, 컨텍스트 인식, 조건부 스케줄로 반복 작업을 완전 자동화. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L6-1 | **클립보드 파이프라인** ✅ | `pipe` 프리픽스(기존 구현). upper/lower/trim/sort/unique/b64e/b64d/urle/urld 등 체이닝 | 기존 | +| L6-2 | **런처 매크로 시퀀서** ✅ | `macro` 프리픽스. `MacroEntry/MacroStep` 모델 + `AppSettings.Macros` 저장. `MacroHandler`: 목록·new·edit·del·Enter 실행. `MacroEditorWindow`: 유형(앱/URL/폴더/알림/PS) + 대상 + 표시이름 + 딜레이(ms) 인라인 행 편집. 재생: 순서대로 Process.Start/알림 실행 | 중간 | +| L6-3 | **컨텍스트 감지 자동완성** ✅ | `ctx` 프리픽스. `GetForegroundWindow` P/Invoke로 현재 앱 감지. 5개 컨텍스트(웹 브라우저/코드 편집기/오피스/파일 탐색기/커뮤니케이션) → 상황별 런처 명령 제안. Enter 시 해당 프리픽스 런처 입력창에 자동 삽입 | 중간 | +| L6-4 | **조건부 스케줄** ✅ | `ScheduleEntry.ConditionProcess`(프로세스명) + `ConditionProcessMustRun`(실행중/비실행중) 필드 추가. `SchedulerService.ShouldFire()` 확장: `Process.GetProcessesByName()` 조건 체크. `ScheduleEditorWindow`에 조건 섹션 UI 추가 | 낮음 | + ### L5-1 항목별 전용 핫키 — 구현 설계 ```csharp diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 39ad071..477dde7 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -192,6 +192,10 @@ public partial class App : System.Windows.Application schedulerService.Start(); _schedulerService = schedulerService; commandResolver.RegisterHandler(new ScheduleHandler(settings)); + // Phase L6-2: 런처 매크로 (prefix=macro) + commandResolver.RegisterHandler(new MacroHandler(settings)); + // Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx) + commandResolver.RegisterHandler(new ContextHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/ContextHandler.cs b/src/AxCopilot/Handlers/ContextHandler.cs new file mode 100644 index 0000000..3ed3a87 --- /dev/null +++ b/src/AxCopilot/Handlers/ContextHandler.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다. +/// +/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다. +/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천 +/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천 +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var (procName, appDisplayName, suggestions) = GetContextInfo(); + + var items = new List(); + + 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>(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() + .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 + { + 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()); + } +} diff --git a/src/AxCopilot/Handlers/MacroHandler.cs b/src/AxCopilot/Handlers/MacroHandler.cs new file mode 100644 index 0000000..eec4d8f --- /dev/null +++ b/src/AxCopilot/Handlers/MacroHandler.cs @@ -0,0 +1,231 @@ +using System.Diagnostics; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다. +/// +/// 예: macro → 매크로 목록 +/// macro 이름 → 이름으로 필터 +/// macro new → 새 매크로 편집기 열기 +/// macro edit 이름 → 기존 매크로 편집 +/// macro del 이름 → 매크로 삭제 +/// macro play 이름 → 즉시 실행 +/// Enter로 선택한 매크로를 실행합니다. +/// +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> 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>(new[] + { + new LauncherItem("새 매크로 만들기", + "편집기에서 단계별 실행 시퀀스를 설정합니다", + null, "__new__", Symbol: "\uE710") + }); + } + + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기", + null, $"__edit__{parts[1]}", Symbol: "\uE70F") + }); + } + + if ((cmd == "del" || cmd == "delete") && parts.Length > 1) + { + return Task.FromResult>(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>(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(); + + 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>(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}단계 실행됨"); + } +} diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index 7fc3f20..c870c12 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -363,6 +363,72 @@ public class ScheduleEntry [JsonPropertyName("lastRun")] public DateTime? LastRun { get; set; } + + // ─── 조건 (L6-4) ───────────────────────────────────────────────────── + + /// + /// 실행 조건: 특정 프로세스가 실행 중이어야(또는 아니어야) 트리거를 발화합니다. + /// 비어 있으면 조건 없음. + /// + [JsonPropertyName("conditionProcess")] + public string ConditionProcess { get; set; } = ""; + + /// + /// true=해당 프로세스가 실행 중일 때만 발화. + /// false=해당 프로세스가 실행 중이지 않을 때만 발화. + /// + [JsonPropertyName("conditionProcessMustRun")] + public bool ConditionProcessMustRun { get; set; } = true; +} + +// ─── 런처 매크로 (L6-2) ───────────────────────────────────────────────────────── + +/// +/// 런처 명령 시퀀스를 저장하는 매크로 항목. +/// macro 핸들러로 생성·편집·실행합니다. +/// +public class MacroEntry +{ + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("steps")] + public List Steps { get; set; } = new(); + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +/// +/// 매크로 단일 단계. +/// +public class MacroStep +{ + /// 실행 유형. app | url | folder | notification | cmd + [JsonPropertyName("type")] + public string Type { get; set; } = "app"; + + /// 앱 경로 / URL / 폴더 경로 / 알림 메시지 / PowerShell 명령 + [JsonPropertyName("target")] + public string Target { get; set; } = ""; + + /// 추가 인자 (app 유형 전용) + [JsonPropertyName("args")] + public string Args { get; set; } = ""; + + /// 표시 이름 + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// 이 단계 실행 전 대기 시간(ms). 기본값 500. + [JsonPropertyName("delayMs")] + public int DelayMs { get; set; } = 500; } // ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 057b482..cc1ddd7 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -124,6 +124,12 @@ public class AppSettings [JsonPropertyName("schedules")] public List Schedules { get; set; } = new(); + /// + /// 런처 매크로 목록. macro 핸들러로 생성·편집·실행합니다. + /// + [JsonPropertyName("macros")] + public List Macros { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); } diff --git a/src/AxCopilot/Services/SchedulerService.cs b/src/AxCopilot/Services/SchedulerService.cs index 29e57ce..c31740e 100644 --- a/src/AxCopilot/Services/SchedulerService.cs +++ b/src/AxCopilot/Services/SchedulerService.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Windows; using AxCopilot.Models; +using System.Linq; namespace AxCopilot.Services; @@ -90,7 +91,7 @@ public sealed class SchedulerService : IDisposable entry.LastRun.Value.Date == now.Date) return false; - return entry.TriggerType switch + bool typeMatch = entry.TriggerType switch { "daily" => true, "weekdays" => now.DayOfWeek >= DayOfWeek.Monday && @@ -103,6 +104,22 @@ public sealed class SchedulerService : IDisposable now.Date == d.Date, _ => false }; + + if (!typeMatch) return false; + + // ─── L6-4: 프로세스 조건 검사 ───────────────────────────────────── + if (!string.IsNullOrWhiteSpace(entry.ConditionProcess)) + { + var procName = entry.ConditionProcess.Trim() + .Replace(".exe", "", StringComparison.OrdinalIgnoreCase); + + bool isRunning = Process.GetProcessesByName(procName).Length > 0; + + if (entry.ConditionProcessMustRun && !isRunning) return false; + if (!entry.ConditionProcessMustRun && isRunning) return false; + } + + return true; } // ─── 액션 실행 ──────────────────────────────────────────────────────── diff --git a/src/AxCopilot/Views/MacroEditorWindow.xaml b/src/AxCopilot/Views/MacroEditorWindow.xaml new file mode 100644 index 0000000..a94727a --- /dev/null +++ b/src/AxCopilot/Views/MacroEditorWindow.xaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/MacroEditorWindow.xaml.cs b/src/AxCopilot/Views/MacroEditorWindow.xaml.cs new file mode 100644 index 0000000..98c30ad --- /dev/null +++ b/src/AxCopilot/Views/MacroEditorWindow.xaml.cs @@ -0,0 +1,320 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class MacroEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly MacroEntry? _editing; + + // 유형 목록 + private static readonly string[] StepTypes = { "app", "url", "folder", "notification", "cmd" }; + private static readonly string[] StepTypeLabels = { "앱", "URL", "폴더", "알림", "PowerShell" }; + + // 각 행의 컨트롤 참조 + private readonly List _rows = new(); + + // 공유 타입 팝업 + private readonly Popup _typePopup = new() + { + StaysOpen = false, + AllowsTransparency = true, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom + }; + private StepRowUi? _typeTargetRow; + + public MacroEditorWindow(MacroEntry? entry, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _editing = entry; + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + BuildTypePopup(); + + if (_editing != null) + { + NameBox.Text = _editing.Name; + DescBox.Text = _editing.Description; + foreach (var step in _editing.Steps) + AddRow(step); + } + + if (_rows.Count == 0) + AddRow(null); // 기본 빈 행 + } + + // ─── 팝업 빌드 ────────────────────────────────────────────────────────── + private void BuildTypePopup() + { + var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var hover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Gray; + + var panel = new StackPanel { Background = bg, MinWidth = 100 }; + + for (int i = 0; i < StepTypes.Length; i++) + { + var idx = i; + var label = StepTypeLabels[i]; + + var item = new Border + { + Padding = new Thickness(12, 6, 12, 6), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + Tag = idx + }; + item.MouseEnter += (_, _) => item.Background = hover; + item.MouseLeave += (_, _) => item.Background = Brushes.Transparent; + item.MouseLeftButtonUp += (_, _) => + { + if (_typeTargetRow != null) + SetRowType(_typeTargetRow, idx); + _typePopup.IsOpen = false; + }; + item.Child = new TextBlock + { + Text = label, + FontSize = 12, + Foreground = fg + }; + panel.Children.Add(item); + } + + var outerBorder = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Child = panel, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 12, + ShadowDepth = 3, + Opacity = 0.3, + Color = Colors.Black, + Direction = 270 + } + }; + + _typePopup.Child = outerBorder; + } + + // ─── 행 추가 ──────────────────────────────────────────────────────────── + private void AddRow(MacroStep? step) + { + var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var primFg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + + var row = new StepRowUi(); + + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + + // Col 0: 유형 버튼 + var typeLbl = new TextBlock + { + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center, + Foreground = primFg, + Text = StepTypeLabels[0] + }; + var typeBtn = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + Child = typeLbl, + Margin = new Thickness(0, 0, 4, 0) + }; + typeBtn.MouseLeftButtonUp += (_, _) => + { + _typeTargetRow = row; + _typePopup.PlacementTarget = typeBtn; + _typePopup.IsOpen = true; + }; + row.TypeButton = typeBtn; + row.TypeLabel = typeLbl; + Grid.SetColumn(typeBtn, 0); + + // Col 1: 대상 + var targetBox = new TextBox + { + FontSize = 11, + Foreground = primFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = step?.Target ?? "" + }; + row.TargetBox = targetBox; + Grid.SetColumn(targetBox, 1); + + // Col 2: 표시 이름 + var labelBox = new TextBox + { + FontSize = 11, + Foreground = secFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = step?.Label ?? "" + }; + row.LabelBox = labelBox; + Grid.SetColumn(labelBox, 2); + + // Col 3: 딜레이 + var delayBox = new TextBox + { + FontSize = 11, + Foreground = primFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = (step?.DelayMs ?? 500).ToString() + }; + row.DelayBox = delayBox; + Grid.SetColumn(delayBox, 3); + + // Col 4: 삭제 + var delBtn = new Border + { + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(4), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "\uE711", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secFg, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + delBtn.MouseEnter += (_, _) => delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xC0, 0x50, 0x50)); + delBtn.MouseLeave += (_, _) => delBtn.Background = Brushes.Transparent; + delBtn.MouseLeftButtonUp += (_, _) => RemoveRow(row); + Grid.SetColumn(delBtn, 4); + + grid.Children.Add(typeBtn); + grid.Children.Add(targetBox); + grid.Children.Add(labelBox); + grid.Children.Add(delayBox); + grid.Children.Add(delBtn); + + row.Grid = grid; + + // 유형 초기화 + int typeIdx = step != null ? Array.IndexOf(StepTypes, step.Type.ToLowerInvariant()) : 0; + if (typeIdx < 0) typeIdx = 0; + SetRowType(row, typeIdx); + + _rows.Add(row); + StepsPanel.Children.Add(grid); + } + + private void SetRowType(StepRowUi row, int typeIdx) + { + row.TypeIndex = typeIdx; + row.TypeLabel.Text = StepTypeLabels[typeIdx]; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + row.TypeLabel.Foreground = accent; + } + + private void RemoveRow(StepRowUi row) + { + StepsPanel.Children.Remove(row.Grid); + _rows.Remove(row); + } + + // ─── 버튼 이벤트 ───────────────────────────────────────────────────────── + private void BtnAddStep_Click(object sender, MouseButtonEventArgs e) => AddRow(null); + + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show("매크로 이름을 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var steps = _rows + .Where(r => !string.IsNullOrWhiteSpace(r.TargetBox.Text)) + .Select(r => new MacroStep + { + Type = StepTypes[r.TypeIndex], + Target = r.TargetBox.Text.Trim(), + Label = r.LabelBox.Text.Trim(), + DelayMs = int.TryParse(r.DelayBox.Text, out var d) ? Math.Max(0, d) : 500 + }) + .ToList(); + + var entry = _editing ?? new MacroEntry(); + entry.Name = name; + entry.Description = DescBox.Text.Trim(); + entry.Steps = steps; + + if (_editing == null) + _settings.Settings.Macros.Add(entry); + + _settings.Save(); + NotificationService.Notify("AX Copilot", $"매크로 '{entry.Name}' 저장됨"); + Close(); + } + + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + // ─── 내부 행 참조 클래스 ────────────────────────────────────────────────── + private class StepRowUi + { + public Grid Grid = null!; + public Border TypeButton = null!; + public TextBlock TypeLabel = null!; + public TextBox TargetBox = null!; + public TextBox LabelBox = null!; + public TextBox DelayBox = null!; + public int TypeIndex; + } +} diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml b/src/AxCopilot/Views/ScheduleEditorWindow.xaml index 175774b..74c20cd 100644 --- a/src/AxCopilot/Views/ScheduleEditorWindow.xaml +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml @@ -236,6 +236,42 @@ AcceptsReturn="False"/> + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs index a0c80af..13d3523 100644 --- a/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs @@ -14,9 +14,10 @@ public partial class ScheduleEditorWindow : Window private readonly SettingsService _settings; private readonly ScheduleEntry? _editing; // null = 새 스케줄 - private string _triggerType = "daily"; - private string _actionType = "app"; - private bool _enabled = true; + private string _triggerType = "daily"; + private string _actionType = "app"; + private bool _enabled = true; + private bool _conditionMustRun = true; // 요일 버튼 → Border 참조 private Border[] _dayBtns = null!; @@ -57,6 +58,7 @@ public partial class ScheduleEditorWindow : Window SetActionUi(_actionType); UpdateToggleUi(_enabled); + SetConditionModeUi(_conditionMustRun); } private void LoadFromEntry(ScheduleEntry e) @@ -88,6 +90,11 @@ public partial class ScheduleEditorWindow : Window { NotifMsgBox.Text = e.ActionTarget; } + + // 조건 복원 + ConditionProcessBox.Text = e.ConditionProcess ?? ""; + _conditionMustRun = e.ConditionProcessMustRun; + SetConditionModeUi(_conditionMustRun); } // ─── 트리거 유형 ───────────────────────────────────────────────────────── @@ -206,6 +213,27 @@ public partial class ScheduleEditorWindow : Window AppPathBox.Text = dlg.FileName; } + // ─── 조건 모드 (L6-4) ─────────────────────────────────────────────────── + private void ConditionMode_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetConditionModeUi(tag == "run"); + } + + private void SetConditionModeUi(bool mustRun) + { + _conditionMustRun = mustRun; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + BtnCondRun.Background = mustRun ? accent : dimBg; + BtnCondNotRun.Background = !mustRun ? accent : dimBg; + TxtCondRun.Foreground = mustRun ? Brushes.White : secFg; + TxtCondNotRun.Foreground = !mustRun ? Brushes.White : secFg; + } + // ─── 활성화 토글 ───────────────────────────────────────────────────────── private void EnabledToggle_Click(object sender, MouseButtonEventArgs e) { @@ -283,6 +311,10 @@ public partial class ScheduleEditorWindow : Window : NotifMsgBox.Text.Trim(); entry.ActionArgs = _actionType == "app" ? AppArgsBox.Text.Trim() : ""; + // 조건 저장 (L6-4) + entry.ConditionProcess = ConditionProcessBox.Text.Trim(); + entry.ConditionProcessMustRun = _conditionMustRun; + var schedules = _settings.Settings.Schedules; if (_editing == null)