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

View File

@@ -0,0 +1,92 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Windows;
using AutoShutdown.Models;
namespace AutoShutdown.Services;
internal sealed class NotificationService
{
private static readonly HttpClient HttpClient = new();
private NotificationSettings _settings;
public NotificationService(NotificationSettings settings)
{
_settings = settings;
}
public void UpdateSettings(NotificationSettings settings)
{
_settings = settings;
}
public async Task NotifyAsync(Window owner, string title, string body, MessageBoxImage image = MessageBoxImage.Information)
{
if (_settings.Mode == NotificationMode.OmniNotify)
{
await TrySendToOmniNotifyAsync(title, body);
return;
}
System.Windows.MessageBox.Show(owner, body, title, MessageBoxButton.OK, image);
}
public Task NotifyAsync(string title, string body)
{
return _settings.Mode == NotificationMode.OmniNotify
? TrySendToOmniNotifyAsync(title, body)
: Task.CompletedTask;
}
public async Task<bool> TestOmniNotifyAsync(NotificationSettings settings)
{
var oldSettings = _settings;
try
{
_settings = settings;
await SendToOmniNotifyAsync("AutoShutdown 测试消息", "OmniNotify 适配已连接。");
return true;
}
catch
{
return false;
}
finally
{
_settings = oldSettings;
}
}
private async Task SendToOmniNotifyAsync(string title, string body)
{
if (!Uri.TryCreate(_settings.OmniNotifyEndpoint, UriKind.Absolute, out var endpoint))
{
return;
}
var payload = JsonSerializer.Serialize(new
{
channel = string.IsNullOrWhiteSpace(_settings.Channel) ? "default" : _settings.Channel.Trim(),
title,
body
});
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await HttpClient.PostAsync(endpoint, content);
response.EnsureSuccessStatusCode();
}
private async Task TrySendToOmniNotifyAsync(string title, string body)
{
try
{
await SendToOmniNotifyAsync(title, body);
}
catch
{
// OmniNotify 适配模式下不再弹窗打扰用户,发送失败只静默跳过。
}
}
}

View File

@@ -0,0 +1,38 @@
using System.IO;
using System.Text.Json;
using AutoShutdown.Models;
namespace AutoShutdown.Services;
internal sealed class NotificationSettingsStore
{
private readonly string _path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AutoShutdown",
"notification-settings.json");
public NotificationSettings Load()
{
try
{
if (!File.Exists(_path))
{
return NotificationSettings.Default();
}
var json = File.ReadAllText(_path);
return JsonSerializer.Deserialize<NotificationSettings>(json) ?? NotificationSettings.Default();
}
catch
{
return NotificationSettings.Default();
}
}
public void Save(NotificationSettings settings)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_path, json);
}
}

95
Services/PowerService.cs Normal file
View File

@@ -0,0 +1,95 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using AutoShutdown.Models;
namespace AutoShutdown.Services;
internal sealed class PowerService
{
[DllImport("PowrProf.dll", SetLastError = true)]
private static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool LockWorkStation();
public void Execute(PowerAction action, int delaySeconds, bool forceApps)
{
switch (action)
{
case PowerAction.Shutdown:
RunShutdownCommand($"/s /t {Math.Max(0, delaySeconds)}{ForceFlag(forceApps)}");
break;
case PowerAction.Restart:
RunShutdownCommand($"/r /t {Math.Max(0, delaySeconds)}{ForceFlag(forceApps)}");
break;
case PowerAction.LogOff:
if (delaySeconds > 0)
{
using var timer = new System.Threading.Timer(_ => RunShutdownCommand($"/l{ForceFlag(forceApps)}"), null, TimeSpan.FromSeconds(delaySeconds), Timeout.InfiniteTimeSpan);
Thread.Sleep(TimeSpan.FromSeconds(delaySeconds + 1));
}
else
{
RunShutdownCommand($"/l{ForceFlag(forceApps)}");
}
break;
case PowerAction.Lock:
DelayThen(delaySeconds, () => LockWorkStation());
break;
case PowerAction.Sleep:
DelayThen(delaySeconds, () => SetSuspendState(false, forceApps, false));
break;
case PowerAction.Hibernate:
DelayThen(delaySeconds, () => SetSuspendState(true, forceApps, false));
break;
}
}
public bool CancelShutdown()
{
var result = RunProcess("shutdown.exe", "/a", showWindow: false);
return result == 0;
}
private static string ForceFlag(bool forceApps) => forceApps ? " /f" : string.Empty;
private static void DelayThen(int delaySeconds, Action action)
{
if (delaySeconds > 0)
{
Task.Delay(TimeSpan.FromSeconds(delaySeconds)).ContinueWith(_ => action(), TaskScheduler.Default);
return;
}
action();
}
private static void RunShutdownCommand(string arguments)
{
var code = RunProcess("shutdown.exe", arguments, showWindow: false);
if (code != 0)
{
throw new InvalidOperationException($"shutdown.exe 执行失败,退出码 {code}。");
}
}
private static int RunProcess(string fileName, string arguments, bool showWindow)
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = !showWindow,
WindowStyle = showWindow ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden
});
if (process is null)
{
return -1;
}
process.WaitForExit();
return process.ExitCode;
}
}

View File

@@ -0,0 +1,33 @@
#if DEBUG
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace AutoShutdown.Services;
internal static class ScreenshotService
{
public static string Capture(Window window, string? requestedPath = null)
{
var path = string.IsNullOrWhiteSpace(requestedPath)
? Path.Combine(AppContext.BaseDirectory, "Screenshots", $"ui-{DateTime.Now:yyyyMMdd-HHmmss}.png")
: Path.GetFullPath(requestedPath);
var directory = Path.GetDirectoryName(path) ?? AppContext.BaseDirectory;
Directory.CreateDirectory(directory);
var visual = (FrameworkElement)window.Content;
var width = Math.Max(1, (int)Math.Ceiling(visual.ActualWidth));
var height = Math.Max(1, (int)Math.Ceiling(visual.ActualHeight));
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
target.Render(visual);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(target));
using var stream = File.Create(path);
encoder.Save(stream);
return path;
}
}
#endif

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 删除失败不影响计划本身。
}
}
}