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
198 lines
7.1 KiB
C#
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]));
|
|
}
|