AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
214
src/AxCopilot/Services/SchedulerService.cs
Normal file
214
src/AxCopilot/Services/SchedulerService.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using AxCopilot.Models;
|
||||
using System.Linq;
|
||||
|
||||
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;
|
||||
|
||||
bool 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;
|
||||
|
||||
// ─── L6-4: 프로세스 조건 검사 ─────────────────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(entry.ConditionProcess))
|
||||
{
|
||||
var procName = entry.ConditionProcess.Trim()
|
||||
.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool 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}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
|
||||
|
||||
/// <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]));
|
||||
}
|
||||
Reference in New Issue
Block a user