feat(app): 初始化自动关机工具首个版本

实现基于 WPF 的 AutoShutdown 主界面,支持关机、重启、睡眠、休眠、唤醒、锁屏和注销等电源任务。

支持指定时间和倒计时计划、执行前提醒、系统关机撤销、Windows 唤醒任务、托盘运行、自定义图标以及 OmniNotify 通知适配。

修复关闭到托盘时的运行提醒,并支持单击托盘图标打开或收起主界面。

补充 README、发布配置和 win-x64 Release 输出要求。

Release: win-x64
This commit is contained in:
2026-05-18 23:54:58 +08:00
commit d2d81a482b
17 changed files with 1611 additions and 0 deletions

119
Services/WakeService.cs Normal file
View File

@@ -0,0 +1,119 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Security.Principal;
using System.Xml.Linq;
namespace AutoShutdown.Services;
internal sealed class WakeService
{
private const string TaskName = "AutoShutdown Wake";
public void ScheduleWake(DateTime wakeAt)
{
var taskXmlPath = Path.Combine(Path.GetTempPath(), $"AutoShutdown-Wake-{Guid.NewGuid():N}.xml");
try
{
File.WriteAllText(taskXmlPath, CreateWakeTaskXml(wakeAt));
RunProcess("schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{taskXmlPath}\" /F");
}
finally
{
TryDelete(taskXmlPath);
}
}
public void CancelWake()
{
RunProcess("schtasks.exe", $"/Delete /TN \"{TaskName}\" /F", ignoreErrors: true);
}
private static string CreateWakeTaskXml(DateTime wakeAt)
{
XNamespace ns = "http://schemas.microsoft.com/windows/2004/02/mit/task";
var startBoundary = wakeAt.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
var userId = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName;
var task = new XDocument(
new XElement(ns + "Task",
new XAttribute("version", "1.4"),
new XElement(ns + "RegistrationInfo",
new XElement(ns + "Description", "AutoShutdown wake timer")),
new XElement(ns + "Triggers",
new XElement(ns + "TimeTrigger",
new XElement(ns + "StartBoundary", startBoundary),
new XElement(ns + "Enabled", "true"))),
new XElement(ns + "Principals",
new XElement(ns + "Principal",
new XAttribute("id", "Author"),
new XElement(ns + "UserId", userId),
new XElement(ns + "LogonType", "InteractiveToken"),
new XElement(ns + "RunLevel", "LeastPrivilege"))),
new XElement(ns + "Settings",
new XElement(ns + "MultipleInstancesPolicy", "IgnoreNew"),
new XElement(ns + "DisallowStartIfOnBatteries", "false"),
new XElement(ns + "StopIfGoingOnBatteries", "false"),
new XElement(ns + "AllowHardTerminate", "true"),
new XElement(ns + "StartWhenAvailable", "true"),
new XElement(ns + "RunOnlyIfNetworkAvailable", "false"),
new XElement(ns + "IdleSettings",
new XElement(ns + "StopOnIdleEnd", "false"),
new XElement(ns + "RestartOnIdle", "false")),
new XElement(ns + "AllowStartOnDemand", "true"),
new XElement(ns + "Enabled", "true"),
new XElement(ns + "Hidden", "true"),
new XElement(ns + "RunOnlyIfIdle", "false"),
new XElement(ns + "WakeToRun", "true"),
new XElement(ns + "ExecutionTimeLimit", "PT1M"),
new XElement(ns + "Priority", "7")),
new XElement(ns + "Actions",
new XAttribute("Context", "Author"),
new XElement(ns + "Exec",
new XElement(ns + "Command", "cmd.exe"),
new XElement(ns + "Arguments", "/c exit")))));
return task.ToString(SaveOptions.DisableFormatting);
}
private static void RunProcess(string fileName, string arguments, bool ignoreErrors = false)
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
});
if (process is null)
{
throw new InvalidOperationException("无法启动 Windows 任务计划程序命令。");
}
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (!ignoreErrors && process.ExitCode != 0)
{
var message = string.Join(Environment.NewLine, new[] { error, output }.Where(item => !string.IsNullOrWhiteSpace(item)));
throw new InvalidOperationException(string.IsNullOrWhiteSpace(message) ? "创建唤醒计划失败。" : message.Trim());
}
}
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch
{
// 临时任务 XML 删除失败不影响计划本身。
}
}
}