基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。 实现本地任务模型、触发器规则、JSON 状态持久化、OmniNotify HTTP 推送、执行日志记录、动态变量替换以及基础 Cron 预览能力。 补充 .gitignore,排除构建产物和本地 IDE 文件。 BREAKING CHANGE: 首次提交,建立项目初始结构
450 lines
14 KiB
C#
450 lines
14 KiB
C#
using Microsoft.Win32;
|
||
using System.Collections.ObjectModel;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Net.Http;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Windows.Threading;
|
||
|
||
namespace OmniScheduler;
|
||
|
||
public sealed class StateStore
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
WriteIndented = true,
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
|
||
private readonly string _statePath;
|
||
|
||
public StateStore()
|
||
{
|
||
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniScheduler");
|
||
Directory.CreateDirectory(dir);
|
||
_statePath = Path.Combine(dir, "state.json");
|
||
}
|
||
|
||
public SchedulerState Load()
|
||
{
|
||
if (!File.Exists(_statePath))
|
||
{
|
||
return Seed();
|
||
}
|
||
|
||
try
|
||
{
|
||
var state = JsonSerializer.Deserialize<SchedulerState>(File.ReadAllText(_statePath), JsonOptions);
|
||
return state ?? Seed();
|
||
}
|
||
catch
|
||
{
|
||
return Seed();
|
||
}
|
||
}
|
||
|
||
public void Save(SchedulerState state)
|
||
{
|
||
TrimLogs(state);
|
||
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions), Encoding.UTF8);
|
||
}
|
||
|
||
private static SchedulerState Seed()
|
||
{
|
||
var task = new ScheduledTask
|
||
{
|
||
Name = "OmniNotify 连通性测试",
|
||
Description = "可克隆此任务作为新消息模板。",
|
||
Channel = "default",
|
||
Title = "OmniScheduler 测试",
|
||
Body = "来自 {TaskName} 的测试消息,触发时间 {CurrentTime}。"
|
||
};
|
||
task.Triggers.Add(new TaskTrigger { Kind = TriggerKind.Interval, IntervalValue = 30, IntervalUnit = IntervalUnit.Minutes });
|
||
return new SchedulerState { Tasks = new ObservableCollection<ScheduledTask> { task } };
|
||
}
|
||
|
||
private static void TrimLogs(SchedulerState state)
|
||
{
|
||
var cutoff = DateTime.Now.AddDays(-state.Settings.LogRetentionDays);
|
||
var kept = state.Logs
|
||
.Where(l => l.Timestamp >= cutoff)
|
||
.OrderByDescending(l => l.Timestamp)
|
||
.Take(state.Settings.MaxLogRecords)
|
||
.OrderByDescending(l => l.Timestamp)
|
||
.ToList();
|
||
|
||
state.Logs.Clear();
|
||
foreach (var log in kept)
|
||
{
|
||
state.Logs.Add(log);
|
||
}
|
||
}
|
||
}
|
||
|
||
public sealed class NotifyClient
|
||
{
|
||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
|
||
|
||
public async Task<ExecutionLog> SendAsync(ScheduledTask task, string triggerType, string targetUrl, CancellationToken cancellationToken = default)
|
||
{
|
||
var payload = new
|
||
{
|
||
channel = Expand(task.Channel, task, triggerType),
|
||
title = Expand(task.Title, task, triggerType),
|
||
body = Expand(task.Body, task, triggerType)
|
||
};
|
||
var json = JsonSerializer.Serialize(payload);
|
||
var sw = Stopwatch.StartNew();
|
||
var log = new ExecutionLog
|
||
{
|
||
Timestamp = DateTime.Now,
|
||
TaskName = task.Name,
|
||
TriggerType = triggerType,
|
||
TargetUrl = targetUrl,
|
||
RequestJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })
|
||
};
|
||
|
||
try
|
||
{
|
||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||
using var response = await _http.PostAsync(targetUrl, content, cancellationToken);
|
||
log.StatusCode = (int)response.StatusCode;
|
||
log.Response = await response.Content.ReadAsStringAsync(cancellationToken);
|
||
log.Level = response.IsSuccessStatusCode ? "INFO" : "ERROR";
|
||
log.Message = response.IsSuccessStatusCode ? "发送成功" : BuildErrorMessage(log.Response, response.StatusCode.ToString());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
log.Level = "ERROR";
|
||
log.Response = ex.Message;
|
||
log.Message = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
sw.Stop();
|
||
log.ElapsedMs = sw.ElapsedMilliseconds;
|
||
}
|
||
|
||
return log;
|
||
}
|
||
|
||
private static string Expand(string value, ScheduledTask task, string triggerType)
|
||
=> value
|
||
.Replace("{CurrentTime}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), StringComparison.OrdinalIgnoreCase)
|
||
.Replace("{TaskName}", task.Name, StringComparison.OrdinalIgnoreCase)
|
||
.Replace("{TriggerType}", triggerType, StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static string BuildErrorMessage(string response, string fallback)
|
||
=> response.Contains("IllegalChannel", StringComparison.OrdinalIgnoreCase)
|
||
? "ERROR: IllegalChannel,请检查频道名配置"
|
||
: $"发送失败: {fallback}";
|
||
}
|
||
|
||
public sealed class SchedulerService
|
||
{
|
||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||
private readonly SchedulerState _state;
|
||
private readonly NotifyClient _client;
|
||
private readonly Action<ExecutionLog> _logSink;
|
||
private bool _paused;
|
||
|
||
public SchedulerService(SchedulerState state, NotifyClient client, Action<ExecutionLog> logSink)
|
||
{
|
||
_state = state;
|
||
_client = client;
|
||
_logSink = logSink;
|
||
_timer.Tick += async (_, _) => await TickAsync();
|
||
}
|
||
|
||
public bool IsPaused
|
||
{
|
||
get => _paused;
|
||
set => _paused = value;
|
||
}
|
||
|
||
public void Start()
|
||
{
|
||
RecalculateNextRuns();
|
||
_timer.Start();
|
||
}
|
||
|
||
public void RecalculateNextRuns()
|
||
{
|
||
foreach (var task in _state.Tasks)
|
||
{
|
||
task.NextRunAt = task.IsEnabled ? FindNext(task, DateTime.Now) : null;
|
||
task.Notify(nameof(ScheduledTask.TriggerSummary));
|
||
}
|
||
}
|
||
|
||
public async Task FireNowAsync(ScheduledTask task)
|
||
{
|
||
await ExecuteAsync(task, "Manual");
|
||
task.NextRunAt = FindNext(task, DateTime.Now);
|
||
}
|
||
|
||
private async Task TickAsync()
|
||
{
|
||
if (_paused)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var now = DateTime.Now;
|
||
var due = _state.Tasks
|
||
.Where(t => t.IsEnabled && t.NextRunAt.HasValue && t.NextRunAt.Value <= now)
|
||
.ToList();
|
||
|
||
await Task.WhenAll(due.Select(async task =>
|
||
{
|
||
var scheduled = task.NextRunAt!.Value;
|
||
if (now - scheduled > TimeSpan.FromSeconds(30) && task.MisfireStrategy == MisfireStrategy.DoNothing)
|
||
{
|
||
task.NextRunAt = FindNext(task, now);
|
||
return;
|
||
}
|
||
|
||
await ExecuteAsync(task, PickTriggerSummary(task, scheduled));
|
||
task.NextRunAt = FindNext(task, now.AddSeconds(1));
|
||
}));
|
||
}
|
||
|
||
private async Task ExecuteAsync(ScheduledTask task, string triggerType)
|
||
{
|
||
task.LastRunAt = DateTime.Now;
|
||
var log = await _client.SendAsync(task, triggerType, _state.Settings.OmniNotifyUrl);
|
||
task.LastStatus = log.Level == "INFO" ? "Success" : "Failed";
|
||
_logSink(log);
|
||
}
|
||
|
||
private static string PickTriggerSummary(ScheduledTask task, DateTime dueAt)
|
||
{
|
||
var trigger = task.Triggers
|
||
.OrderBy(t => Math.Abs(((NextRunCalculator.NextRun(t, DateTime.Now.AddDays(-1)) ?? dueAt) - dueAt).TotalSeconds))
|
||
.FirstOrDefault();
|
||
return trigger?.Kind.ToString() ?? "Scheduled";
|
||
}
|
||
|
||
private static DateTime? FindNext(ScheduledTask task, DateTime from)
|
||
{
|
||
return task.Triggers
|
||
.Select(t => NextRunCalculator.NextRun(t, from))
|
||
.Where(d => d.HasValue)
|
||
.Min();
|
||
}
|
||
}
|
||
|
||
public static class NextRunCalculator
|
||
{
|
||
public static DateTime? NextRun(TaskTrigger trigger, DateTime from)
|
||
{
|
||
if (trigger.EndsAt.HasValue && from > trigger.EndsAt.Value)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var earliest = trigger.StartsAt.HasValue && trigger.StartsAt.Value > from ? trigger.StartsAt.Value : from;
|
||
DateTime? next = trigger.Kind switch
|
||
{
|
||
TriggerKind.OneTime => trigger.OneTimeAt > from ? trigger.OneTimeAt : null,
|
||
TriggerKind.Interval => NextInterval(trigger, earliest),
|
||
TriggerKind.Daily => NextDaily(trigger.TimeOfDay, earliest),
|
||
TriggerKind.Weekly => NextWeekly(trigger, earliest),
|
||
TriggerKind.Monthly => NextMonthly(trigger, earliest),
|
||
TriggerKind.Cron => NextCron(trigger.CronExpression, earliest),
|
||
_ => null
|
||
};
|
||
|
||
return trigger.EndsAt.HasValue && next > trigger.EndsAt.Value ? null : next;
|
||
}
|
||
|
||
public static IReadOnlyList<DateTime> Preview(TaskTrigger trigger, DateTime from, int count = 5)
|
||
{
|
||
var results = new List<DateTime>();
|
||
var cursor = from;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var next = NextRun(trigger, cursor);
|
||
if (!next.HasValue)
|
||
{
|
||
break;
|
||
}
|
||
|
||
results.Add(next.Value);
|
||
cursor = next.Value.AddSeconds(1);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
private static DateTime? NextInterval(TaskTrigger trigger, DateTime from)
|
||
{
|
||
if (trigger.IntervalValue < 1)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var interval = trigger.IntervalUnit switch
|
||
{
|
||
IntervalUnit.Seconds => TimeSpan.FromSeconds(trigger.IntervalValue),
|
||
IntervalUnit.Minutes => TimeSpan.FromMinutes(trigger.IntervalValue),
|
||
IntervalUnit.Hours => TimeSpan.FromHours(trigger.IntervalValue),
|
||
IntervalUnit.Days => TimeSpan.FromDays(trigger.IntervalValue),
|
||
_ => TimeSpan.FromMinutes(trigger.IntervalValue)
|
||
};
|
||
|
||
var start = trigger.StartsAt ?? DateTime.Now;
|
||
if (from <= start)
|
||
{
|
||
return start;
|
||
}
|
||
|
||
var steps = Math.Ceiling((from - start).TotalSeconds / interval.TotalSeconds);
|
||
return start.AddSeconds(steps * interval.TotalSeconds);
|
||
}
|
||
|
||
private static DateTime NextDaily(TimeSpan time, DateTime from)
|
||
{
|
||
var candidate = from.Date.Add(time);
|
||
return candidate > from ? candidate : candidate.AddDays(1);
|
||
}
|
||
|
||
private static DateTime NextWeekly(TaskTrigger trigger, DateTime from)
|
||
{
|
||
for (var i = 0; i < 14; i++)
|
||
{
|
||
var day = from.Date.AddDays(i);
|
||
if (HasDay(trigger.WeekDays, day.DayOfWeek))
|
||
{
|
||
var candidate = day.Add(trigger.TimeOfDay);
|
||
if (candidate > from)
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return from.AddDays(7);
|
||
}
|
||
|
||
private static DateTime? NextMonthly(TaskTrigger trigger, DateTime from)
|
||
{
|
||
for (var i = 0; i < 24; i++)
|
||
{
|
||
var month = new DateTime(from.Year, from.Month, 1).AddMonths(i);
|
||
var day = trigger.LastBusinessDay
|
||
? LastBusinessDay(month.Year, month.Month)
|
||
: Math.Min(Math.Max(trigger.MonthDay, 1), DateTime.DaysInMonth(month.Year, month.Month));
|
||
var candidate = new DateTime(month.Year, month.Month, day).Add(trigger.TimeOfDay);
|
||
if (candidate > from)
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static DateTime? NextCron(string expression, DateTime from)
|
||
{
|
||
var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
if (parts.Length < 6)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
for (var probe = from.AddSeconds(1); probe < from.AddDays(366); probe = probe.AddSeconds(1))
|
||
{
|
||
if (Matches(parts[0], probe.Second, 0, 59) &&
|
||
Matches(parts[1], probe.Minute, 0, 59) &&
|
||
Matches(parts[2], probe.Hour, 0, 23) &&
|
||
Matches(parts[3], probe.Day, 1, 31) &&
|
||
Matches(parts[4], probe.Month, 1, 12) &&
|
||
(parts[5] == "?" || Matches(parts[5], (int)probe.DayOfWeek, 0, 6)))
|
||
{
|
||
return probe;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool Matches(string field, int value, int min, int max)
|
||
{
|
||
if (field is "*" or "?")
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return field.Split(',').Any(part =>
|
||
{
|
||
if (part.Contains('/'))
|
||
{
|
||
var pieces = part.Split('/');
|
||
var step = int.TryParse(pieces[1], out var parsedStep) ? parsedStep : 1;
|
||
var start = pieces[0] == "*" ? min : int.TryParse(pieces[0], out var parsedStart) ? parsedStart : min;
|
||
return value >= start && (value - start) % step == 0;
|
||
}
|
||
|
||
if (part.Contains('-'))
|
||
{
|
||
var range = part.Split('-');
|
||
return int.TryParse(range[0], out var a) && int.TryParse(range[1], out var b) && value >= a && value <= b;
|
||
}
|
||
|
||
return int.TryParse(part, out var exact) && exact == value;
|
||
});
|
||
}
|
||
|
||
private static bool HasDay(DayOfWeekFlags flags, DayOfWeek day)
|
||
{
|
||
var flag = day switch
|
||
{
|
||
DayOfWeek.Sunday => DayOfWeekFlags.Sunday,
|
||
DayOfWeek.Monday => DayOfWeekFlags.Monday,
|
||
DayOfWeek.Tuesday => DayOfWeekFlags.Tuesday,
|
||
DayOfWeek.Wednesday => DayOfWeekFlags.Wednesday,
|
||
DayOfWeek.Thursday => DayOfWeekFlags.Thursday,
|
||
DayOfWeek.Friday => DayOfWeekFlags.Friday,
|
||
DayOfWeek.Saturday => DayOfWeekFlags.Saturday,
|
||
_ => DayOfWeekFlags.None
|
||
};
|
||
return flags.HasFlag(flag);
|
||
}
|
||
|
||
private static int LastBusinessDay(int year, int month)
|
||
{
|
||
var date = new DateTime(year, month, DateTime.DaysInMonth(year, month));
|
||
while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
|
||
{
|
||
date = date.AddDays(-1);
|
||
}
|
||
|
||
return date.Day;
|
||
}
|
||
}
|
||
|
||
public static class StartupRegistration
|
||
{
|
||
private const string RunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
|
||
|
||
public static void Apply(bool enabled)
|
||
{
|
||
using var key = Registry.CurrentUser.OpenSubKey(RunKey, true);
|
||
if (key is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (enabled)
|
||
{
|
||
key.SetValue("OmniScheduler", $"\"{Environment.ProcessPath}\" --tray");
|
||
}
|
||
else
|
||
{
|
||
key.DeleteValue("OmniScheduler", false);
|
||
}
|
||
}
|
||
}
|