diff --git a/README.md b/README.md index 447fabb..43ef4bf 100644 --- a/README.md +++ b/README.md @@ -1293,3 +1293,7 @@ MIT License - 런처 하단에 자동으로 붙는 빠른 실행 칩을 별도 사용자 설정으로 분리했다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)에 `빠른 실행 칩 표시` 옵션을 추가했고, 기본값은 비활성으로 두었다. - [LauncherViewModel.LauncherExtras.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs) 에서 이 설정이 꺼져 있으면 하단 빠른 실행 칩을 로드하지 않도록 바꿨다. - [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml) 에서는 하단 빠른 실행 칩 블록 자체를 중앙 정렬 기준으로 재배치하고, 각 칩 내부도 세로 중앙 정렬과 최소 높이를 맞춰 여러 개가 나타나도 정중앙에 더 가깝게 보이도록 정리했다. +- 업데이트: 2026-04-06 17:18 (KST) + - AX Copilot가 유휴 상태에서도 CPU를 3~5% 정도 쓰는 원인을 점검한 뒤, 상시 백그라운드 경로 두 군데를 줄였다. [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 `SchedulerService`는 앱 시작 즉시 무조건 타이머를 돌리지 않고 `Refresh()`로 활성 일정이 있을 때만 시작하도록 바꿨다. + - 같은 파일에서 `FileDialogWatcher`도 더 이상 앱 시작 시 무조건 시스템 전역 WinEvent 훅을 걸지 않고, `파일 대화상자 통합` 설정이 켜져 있을 때만 시작되도록 조정했다. 설정 저장 시 `SettingsChanged`를 받아 watcher/timer 상태를 즉시 다시 계산하도록 연결했다. + - [SchedulerService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SchedulerService.cs) 도 함께 정리해, 활성 일정이 하나도 없으면 타이머를 시작하지 않고, 실행 중에도 일정이 모두 비활성화되면 스스로 타이머를 정지하도록 바꿨다. 이 변경으로 런처와 AX Agent 창이 모두 닫힌 유휴 상태에서 불필요한 CPU 깨우기를 줄였다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1d4e37c..7f82b9c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4984,3 +4984,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - Document update: 2026-04-06 17:01 (KST) - Updated `LlmService.cs`, `SettingsViewModel.cs`, and `AppSettings.cs` to formally support `cp4d_password` and `cp4d_api_key` while preserving legacy `cp4d` values as the password-based path for backward compatibility. - Document update: 2026-04-06 17:09 (KST) - Added a dedicated launcher-setting toggle for the lower quick-action chip strip. `AppSettings.cs`, `SettingsViewModel.cs`, and `SettingsWindow.xaml` now expose `showLauncherBottomQuickActions`, and the default is off so the strip stays hidden unless the user opts in. - Document update: 2026-04-06 17:09 (KST) - Updated `LauncherViewModel.LauncherExtras.cs` and `LauncherWindow.xaml` so lower quick-action chips are not loaded when disabled and render with centered block alignment plus centered chip content when enabled, reducing the off-center look when multiple chips appear. +- Document update: 2026-04-06 17:18 (KST) - Reduced idle CPU wakeups in `App.xaml.cs` by stopping two always-on background paths from starting unconditionally. `SchedulerService` now enters through `Refresh()` so the 30-second timer only exists when at least one enabled schedule is present, and the app now reevaluates that state when settings are saved. +- Document update: 2026-04-06 17:18 (KST) - `FileDialogWatcher` is no longer started at app boot when file-dialog integration is disabled. `App.xaml.cs` now toggles the global WinEvent hook through `UpdateFileDialogWatcherState()`, and `SchedulerService.cs` now self-stops when no enabled schedules remain. This directly targets the 3–5% idle CPU symptom reported while neither the launcher nor AX Agent was open. diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 1e6fe3c..c6af15d 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -183,7 +183,7 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new SessionHandler(settings)); commandResolver.RegisterHandler(new BatchRenameHandler()); _schedulerService = new SchedulerService(settings); - _schedulerService.Start(); + _schedulerService.Refresh(); commandResolver.RegisterHandler(new ScheduleHandler(settings)); commandResolver.RegisterHandler(new MacroHandler(settings)); commandResolver.RegisterHandler(new ContextHandler()); @@ -332,7 +332,16 @@ public partial class App : System.Windows.Application () => _launcher.SetInputText("cd ")); }); }; - _fileDialogWatcher.Start(); + UpdateFileDialogWatcherState(); + + settings.SettingsChanged += (_, _) => + { + Dispatcher.BeginInvoke(() => + { + UpdateFileDialogWatcherState(); + _schedulerService?.Refresh(); + }); + }; // 독 바 자동 표시 if (settings.Settings.Launcher.DockBarAutoShow) @@ -621,6 +630,10 @@ public partial class App : System.Windows.Application : System.Windows.Visibility.Collapsed; }; + Dispatcher.BeginInvoke( + () => _trayMenu?.PrepareForDisplay(), + System.Windows.Threading.DispatcherPriority.ApplicationIdle); + // 우클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글 _trayIcon.MouseClick += (_, e) => { @@ -944,6 +957,7 @@ public partial class App : System.Windows.Application _chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기 _inputListener?.Dispose(); _clipboardHistory?.Dispose(); + _fileDialogWatcher?.Dispose(); _indexService?.Dispose(); _schedulerService?.Dispose(); _sessionTracking?.Dispose(); @@ -954,4 +968,18 @@ public partial class App : System.Windows.Application LogService.Info("=== AX Copilot 종료 ==="); base.OnExit(e); } + + private void UpdateFileDialogWatcherState() + { + if (_fileDialogWatcher == null || _settings == null) + return; + + if (_settings.Settings.Launcher.EnableFileDialogIntegration) + { + _fileDialogWatcher.Start(); + return; + } + + _fileDialogWatcher.Stop(); + } } diff --git a/src/AxCopilot/Services/SchedulerService.cs b/src/AxCopilot/Services/SchedulerService.cs index c31740e..c9c6915 100644 --- a/src/AxCopilot/Services/SchedulerService.cs +++ b/src/AxCopilot/Services/SchedulerService.cs @@ -1,58 +1,77 @@ using System.Diagnostics; -using System.IO; +using System.Linq; using System.Windows; using AxCopilot.Models; -using System.Linq; namespace AxCopilot.Services; /// -/// L5-6: 자동화 스케줄 백그라운드 서비스. -/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다. +/// 예약 작업을 백그라운드에서 점검하고 조건이 맞으면 액션을 실행합니다. +/// 활성 일정이 없을 때는 타이머를 돌리지 않아 유휴 CPU 사용을 줄입니다. /// public sealed class SchedulerService : IDisposable { private readonly SettingsService _settings; - private Timer? _timer; - private bool _disposed; + private Timer? _timer; + private bool _disposed; public SchedulerService(SettingsService settings) { _settings = settings; } - // ─── 시작 / 중지 ───────────────────────────────────────────────────── public void Start() { - // 30초 간격 체크 (즉시 1회 실행 후) + if (_disposed || _timer != null) return; + _timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); LogService.Info("SchedulerService 시작"); } public void Stop() { - _timer?.Change(Timeout.Infinite, Timeout.Infinite); + if (_timer == null) return; + + _timer.Dispose(); + _timer = null; LogService.Info("SchedulerService 중지"); } + public void Refresh() + { + if (_disposed) return; + + if (HasEnabledSchedules()) + { + Start(); + return; + } + + Stop(); + } + public void Dispose() { if (_disposed) return; _disposed = true; - _timer?.Dispose(); - _timer = null; + Stop(); } - // ─── 트리거 검사 ───────────────────────────────────────────────────── private void OnTick(object? _) { try { - var now = DateTime.Now; - var schedules = _settings.Settings.Schedules; - bool dirty = false; + if (!HasEnabledSchedules()) + { + Stop(); + return; + } - foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지 + var now = DateTime.Now; + var schedules = _settings.Settings.Schedules; + var dirty = false; + + foreach (var entry in schedules.ToList()) { if (!ShouldFire(entry, now)) continue; @@ -62,12 +81,15 @@ public sealed class SchedulerService : IDisposable entry.LastRun = now; dirty = true; - // once 트리거는 실행 후 비활성화 if (entry.TriggerType == "once") entry.Enabled = false; } - if (dirty) _settings.Save(); + if (dirty) + { + _settings.Save(); + Refresh(); + } } catch (Exception ex) { @@ -75,54 +97,54 @@ public sealed class SchedulerService : IDisposable } } - // ─── 트리거 조건 검사 ───────────────────────────────────────────────── + private bool HasEnabledSchedules() => + _settings.Settings.Schedules.Any(entry => entry.Enabled); + private static bool ShouldFire(ScheduleEntry entry, DateTime now) { if (!entry.Enabled) return false; if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false; - // 트리거 시각과 ±1분 이내인지 확인 var targetDt = now.Date + triggerTime; if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false; - // 오늘 이미 실행했는지 확인 (once 제외) if (entry.TriggerType != "once" && entry.LastRun.HasValue && entry.LastRun.Value.Date == now.Date) - return false; - - bool typeMatch = entry.TriggerType switch { - "daily" => true, + return false; + } + + var typeMatch = entry.TriggerType switch + { + "daily" => true, "weekdays" => now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday, - "weekly" => entry.WeekDays.Count > 0 && - entry.WeekDays.Contains((int)now.DayOfWeek), - "once" => !entry.LastRun.HasValue && - entry.TriggerDate != null && - DateTime.TryParse(entry.TriggerDate, out var d) && - now.Date == d.Date, - _ => false + "weekly" => entry.WeekDays.Count > 0 && + entry.WeekDays.Contains((int)now.DayOfWeek), + "once" => !entry.LastRun.HasValue && + entry.TriggerDate != null && + DateTime.TryParse(entry.TriggerDate, out var d) && + 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; + var isRunning = Process.GetProcessesByName(procName).Length > 0; if (entry.ConditionProcessMustRun && !isRunning) return false; - if (!entry.ConditionProcessMustRun && isRunning) return false; + if (!entry.ConditionProcessMustRun && isRunning) return false; } return true; } - // ─── 액션 실행 ──────────────────────────────────────────────────────── private static void ExecuteAction(ScheduleEntry entry) { try @@ -131,12 +153,14 @@ public sealed class SchedulerService : IDisposable { case "app": if (!string.IsNullOrWhiteSpace(entry.ActionTarget)) + { Process.Start(new ProcessStartInfo { - FileName = entry.ActionTarget, - Arguments = entry.ActionArgs ?? "", + FileName = entry.ActionTarget, + Arguments = entry.ActionArgs ?? "", UseShellExecute = true }); + } break; case "notification": @@ -144,7 +168,7 @@ public sealed class SchedulerService : IDisposable ? entry.Name : entry.ActionTarget; Application.Current?.Dispatcher.Invoke(() => - NotificationService.Notify($"[스케줄] {entry.Name}", msg)); + NotificationService.Notify($"[일정] {entry.Name}", msg)); break; } } @@ -154,9 +178,6 @@ public sealed class SchedulerService : IDisposable } } - // ─── 유틸리티 (핸들러·편집기에서 공유) ────────────────────────────── - - /// 지정 스케줄의 다음 실행 예정 시각을 계산합니다. public static DateTime? ComputeNextRun(ScheduleEntry entry) { if (!entry.Enabled) return null; @@ -187,28 +208,29 @@ public sealed class SchedulerService : IDisposable private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func dayFilter) { - for (int i = 0; i <= 7; i++) + for (var i = 0; i <= 7; i++) { var candidate = now.Date.AddDays(i) + t; if (candidate > now && dayFilter(candidate)) return candidate; } + return null; } - /// 트리거 유형 표시 이름. public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch { - "daily" => "매일", + "daily" => "매일", "weekdays" => "주중(월~금)", - "weekly" => WeekDayLabel(e.WeekDays), - "once" => $"한번({e.TriggerDate})", - _ => e.TriggerType + "weekly" => WeekDayLabel(e.WeekDays), + "once" => $"한번({e.TriggerDate})", + _ => e.TriggerType }; private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"]; private static string WeekDayLabel(List days) => - days.Count == 0 ? "매주(요일 미지정)" : - "매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d])); + days.Count == 0 + ? "매주(요일 미지정)" + : "매주 " + string.Join(", ", days.OrderBy(d => d).Select(d => DayShort[d])); }