feat(app): 初始化自动关机工具首个版本
实现基于 WPF 的 AutoShutdown 主界面,支持关机、重启、睡眠、休眠、唤醒、锁屏和注销等电源任务。 支持指定时间和倒计时计划、执行前提醒、系统关机撤销、Windows 唤醒任务、托盘运行、自定义图标以及 OmniNotify 通知适配。 修复关闭到托盘时的运行提醒,并支持单击托盘图标打开或收起主界面。 补充 README、发布配置和 win-x64 Release 输出要求。 Release: win-x64
This commit is contained in:
92
Services/NotificationService.cs
Normal file
92
Services/NotificationService.cs
Normal 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 适配模式下不再弹窗打扰用户,发送失败只静默跳过。
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Services/NotificationSettingsStore.cs
Normal file
38
Services/NotificationSettingsStore.cs
Normal 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
95
Services/PowerService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
33
Services/ScreenshotService.cs
Normal file
33
Services/ScreenshotService.cs
Normal 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
119
Services/WakeService.cs
Normal 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 删除失败不影响计划本身。
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user