Files
AX-Copilot/src/AxCopilot/Services/SchedulerService.cs
lacvet e92800165d [Phase L5-6] 자동화 스케줄러 구현 완료
ScheduleEntry 모델 (AppSettings.Models.cs):
- Id(8자 GUID), Name, Enabled, TriggerType(daily/weekdays/weekly/once)
- TriggerTime(HH:mm), WeekDays(List<int>), TriggerDate(nullable), LastRun(nullable)
- ActionType(app/notification), ActionTarget, ActionArgs

AppSettings.cs:
- [JsonPropertyName("schedules")] public List<ScheduleEntry> Schedules 추가

Services/SchedulerService.cs (197줄 신규):
- System.Threading.Timer 30초 간격 백그라운드 체크
- ShouldFire(): ±1분 윈도우, LastRun.Date == today 중복 방지, once 자동 비활성화
- ExecuteAction(): 앱 실행(ProcessStartInfo) / 알림(NotificationService.Notify)
- ComputeNextRun(): 다음 실행 예정 시각 계산 (핸들러·편집기 공유)
- TriggerLabel(): 트리거 유형 표시명 반환

Handlers/ScheduleHandler.cs (171줄 신규):
- prefix="sched", 서브커맨드: new / edit 이름 / del 이름
- 목록: 트리거 라벨·시각·액션 아이콘·다음 실행 시각 표시
- Enter: 활성/비활성 토글 + 저장

Views/ScheduleEditorWindow.xaml (307줄 신규):
- 트리거 유형 4-세그먼트(매일/주중/매주/한번), 요일 7버튼, 날짜 입력
- 액션 2-세그먼트(앱 실행/알림), 앱 경로+찾아보기+인자, 알림 메시지
- 활성화 ToggleSwitch, 저장/취소 하단바

Views/ScheduleEditorWindow.xaml.cs (230줄 신규):
- OnLoaded에서 기존 항목 로드 (편집) 또는 기본값 초기화
- SetTriggerUi(): 세그먼트 색상·WeekDaysPanel/DatePanel 표시 전환
- WeekDay_Click/SetDaySelected(): 요일 다중 토글
- SetActionUi(): 앱경로 패널 / 알림 패널 전환
- BtnSave_Click(): HH:mm 파싱 + 날짜 검증 + ScheduleEntry 생성·수정 저장

App.xaml.cs:
- _schedulerService 필드 + Phase L5-6 등록 블록 추가
- schedulerService.Start() 호출

docs/LAUNCHER_ROADMAP.md:
- L5-6  완료 표시 + 구현 내용 상세 기록

빌드: 경고 0, 오류 0
2026-04-04 13:07:12 +09:00

198 lines
7.1 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Windows;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// L5-6: 자동화 스케줄 백그라운드 서비스.
/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다.
/// </summary>
public sealed class SchedulerService : IDisposable
{
private readonly SettingsService _settings;
private Timer? _timer;
private bool _disposed;
public SchedulerService(SettingsService settings)
{
_settings = settings;
}
// ─── 시작 / 중지 ─────────────────────────────────────────────────────
public void Start()
{
// 30초 간격 체크 (즉시 1회 실행 후)
_timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
LogService.Info("SchedulerService 시작");
}
public void Stop()
{
_timer?.Change(Timeout.Infinite, Timeout.Infinite);
LogService.Info("SchedulerService 중지");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_timer?.Dispose();
_timer = null;
}
// ─── 트리거 검사 ─────────────────────────────────────────────────────
private void OnTick(object? _)
{
try
{
var now = DateTime.Now;
var schedules = _settings.Settings.Schedules;
bool dirty = false;
foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지
{
if (!ShouldFire(entry, now)) continue;
LogService.Info($"스케줄 실행: '{entry.Name}' ({entry.TriggerType} {entry.TriggerTime})");
ExecuteAction(entry);
entry.LastRun = now;
dirty = true;
// once 트리거는 실행 후 비활성화
if (entry.TriggerType == "once")
entry.Enabled = false;
}
if (dirty) _settings.Save();
}
catch (Exception ex)
{
LogService.Error($"SchedulerService 오류: {ex.Message}");
}
}
// ─── 트리거 조건 검사 ─────────────────────────────────────────────────
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;
return 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
};
}
// ─── 액션 실행 ────────────────────────────────────────────────────────
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}");
}
}
// ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
/// <summary>지정 스케줄의 다음 실행 예정 시각을 계산합니다.</summary>
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<DateTime, bool> dayFilter)
{
for (int i = 0; i <= 7; i++)
{
var candidate = now.Date.AddDays(i) + t;
if (candidate > now && dayFilter(candidate))
return candidate;
}
return null;
}
/// <summary>트리거 유형 표시 이름.</summary>
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<int> days) =>
days.Count == 0 ? "매주(요일 미지정)" :
"매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d]));
}