using System.Diagnostics; using System.Linq; using System.Windows; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 예약 작업을 백그라운드에서 점검하고 조건이 맞으면 액션을 실행합니다. /// 활성 일정이 없을 때는 타이머를 돌리지 않아 유휴 CPU 사용을 줄입니다. /// public sealed class SchedulerService : IDisposable { private readonly SettingsService _settings; private Timer? _timer; private bool _disposed; public SchedulerService(SettingsService settings) { _settings = settings; } public void Start() { if (_disposed || _timer != null) return; _timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); LogService.Info("SchedulerService 시작"); } public void Stop() { 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; Stop(); } private void OnTick(object? _) { try { if (!HasEnabledSchedules()) { Stop(); return; } var now = DateTime.Now; var schedules = _settings.Settings.Schedules; var dirty = false; foreach (var entry in schedules.ToList()) { if (!ShouldFire(entry, now)) continue; LogService.Info($"스케줄 실행: '{entry.Name}' ({entry.TriggerType} {entry.TriggerTime})"); ExecuteAction(entry); entry.LastRun = now; dirty = true; if (entry.TriggerType == "once") entry.Enabled = false; } if (dirty) { _settings.Save(); Refresh(); } } catch (Exception ex) { LogService.Error($"SchedulerService 오류: {ex.Message}"); } } 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; var targetDt = now.Date + triggerTime; if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false; if (entry.TriggerType != "once" && entry.LastRun.HasValue && entry.LastRun.Value.Date == now.Date) { 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 }; if (!typeMatch) return false; if (!string.IsNullOrWhiteSpace(entry.ConditionProcess)) { var procName = entry.ConditionProcess.Trim() .Replace(".exe", "", StringComparison.OrdinalIgnoreCase); var isRunning = Process.GetProcessesByName(procName).Length > 0; if (entry.ConditionProcessMustRun && !isRunning) return false; if (!entry.ConditionProcessMustRun && isRunning) return false; } return true; } private static void ExecuteAction(ScheduleEntry entry) { try { switch (entry.ActionType) { case "app": if (!string.IsNullOrWhiteSpace(entry.ActionTarget)) { Process.Start(new ProcessStartInfo { FileName = entry.ActionTarget, Arguments = entry.ActionArgs ?? "", UseShellExecute = true }); } break; case "notification": var msg = string.IsNullOrWhiteSpace(entry.ActionTarget) ? entry.Name : entry.ActionTarget; Application.Current?.Dispatcher.Invoke(() => NotificationService.Notify($"[일정] {entry.Name}", msg)); break; } } catch (Exception ex) { LogService.Warn($"스케줄 액션 실행 실패 '{entry.Name}': {ex.Message}"); } } public static DateTime? ComputeNextRun(ScheduleEntry entry) { if (!entry.Enabled) return null; if (!TimeSpan.TryParse(entry.TriggerTime, out var t)) return null; var now = DateTime.Now; return entry.TriggerType switch { "once" when !entry.LastRun.HasValue && entry.TriggerDate != null && DateTime.TryParse(entry.TriggerDate, out var d) && (now.Date + t) >= now => d.Date + t, "daily" => NextOccurrence(now, t, _ => true), "weekdays" => NextOccurrence(now, t, dt => dt.DayOfWeek >= DayOfWeek.Monday && dt.DayOfWeek <= DayOfWeek.Friday), "weekly" when entry.WeekDays.Count > 0 => NextOccurrence(now, t, dt => entry.WeekDays.Contains((int)dt.DayOfWeek)), _ => null }; } private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func dayFilter) { 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" => "매일", "weekdays" => "주중(월~금)", "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])); }