Files
omni-scheduler/OmniScheduler/Services.cs
home-PC 2a669bdfe7 feat(app): 初始化 OmniScheduler WPF 调度器
基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。

实现本地任务模型、触发器规则、JSON 状态持久化、OmniNotify HTTP 推送、执行日志记录、动态变量替换以及基础 Cron 预览能力。

补充 .gitignore,排除构建产物和本地 IDE 文件。

BREAKING CHANGE: 首次提交,建立项目初始结构
2026-05-20 00:12:17 +08:00

450 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}