Some checks failed
Release Gate / gate (push) Has been cancelled
앱 시작 시 파일 대화상자 통합이 꺼져 있어도 시스템 전역 WinEvent 훅을 항상 걸던 구조를 수정해, 설정이 켜져 있을 때만 FileDialogWatcher가 시작되도록 바꿨다. 설정 저장 시 watcher와 스케줄러 상태를 즉시 다시 계산하도록 App 초기화 경로도 함께 보강했다. SchedulerService는 활성 일정이 하나도 없으면 타이머를 만들지 않고, 실행 중 일정이 모두 비활성화되면 스스로 정지하도록 Refresh 기반 구조로 정리했다. 이 변경으로 런처와 AX Agent 창이 닫힌 유휴 상태에서도 발생하던 불필요한 30초 주기 깨우기와 전역 이벤트 콜백을 줄였다. README와 DEVELOPMENT 문서에 2026-04-06 17:18 (KST) 기준 이력을 반영했고, 표준 검증 빌드(dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\) 결과 경고 0, 오류 0을 확인했다.
237 lines
6.8 KiB
C#
237 lines
6.8 KiB
C#
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// 예약 작업을 백그라운드에서 점검하고 조건이 맞으면 액션을 실행합니다.
|
|
/// 활성 일정이 없을 때는 타이머를 돌리지 않아 유휴 CPU 사용을 줄입니다.
|
|
/// </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()
|
|
{
|
|
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<DateTime, bool> 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<int> days) =>
|
|
days.Count == 0
|
|
? "매주(요일 미지정)"
|
|
: "매주 " + string.Join(", ", days.OrderBy(d => d).Select(d => DayShort[d]));
|
|
}
|