feat(app): 初始化 OmniScheduler WPF 调度器

基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。

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

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

BREAKING CHANGE: 首次提交,建立项目初始结构
This commit is contained in:
2026-05-20 00:12:17 +08:00
commit 2a669bdfe7
15 changed files with 1733 additions and 0 deletions

449
OmniScheduler/Services.cs Normal file
View File

@@ -0,0 +1,449 @@
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);
}
}
}