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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
bin/
obj/
Screenshots/
*.user
*.suo
*.log

27
AutoShutdown.csproj Normal file
View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>app.ico</ApplicationIcon>
<AssemblyName>AutoShutdown</AssemblyName>
<RootNamespace>AutoShutdown</RootNamespace>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<Resource Include="app.ico" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

282
MainWindow.xaml Normal file
View File

@@ -0,0 +1,282 @@
<Window x:Class="AutoShutdown.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AutoShutdown"
Icon="app.ico"
Width="980"
Height="560"
MinWidth="860"
MinHeight="540"
Background="#F6F8FC"
FontFamily="Microsoft YaHei UI"
FontSize="14"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<SolidColorBrush x:Key="InkBrush" Color="#111827"/>
<SolidColorBrush x:Key="MutedBrush" Color="#64748B"/>
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB"/>
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E2ECFF"/>
<SolidColorBrush x:Key="BorderBrushSoft" Color="#DEE6F2"/>
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626"/>
<SolidColorBrush x:Key="SlateButtonBrush" Color="#475569"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#008060"/>
<Style x:Key="ModernButton" TargetType="Button">
<Setter Property="Height" Value="40"/>
<Setter Property="MinWidth" Value="112"/>
<Setter Property="Padding" Value="18,0"/>
<Setter Property="Margin" Value="0,0,12,0"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
CornerRadius="6"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Opacity" Value="0.76"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ActionChoiceButton" TargetType="Button">
<Setter Property="Height" Value="42"/>
<Setter Property="Margin" Value="0,0,0,8"/>
<Setter Property="Padding" Value="18,0"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Background" Value="{StaticResource CardBrush}"/>
<Setter Property="Foreground" Value="{StaticResource InkBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrushSoft}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6"
SnapsToDevicePixels="True">
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="#F4F8FF"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentSoftBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ModernTextBox" TargetType="TextBox">
<Setter Property="Height" Value="30"/>
<Setter Property="Width" Value="64"/>
<Setter Property="Padding" Value="8,3"/>
<Setter Property="Margin" Value="0,0,8,0"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{StaticResource InkBrush}"/>
<Setter Property="BorderBrush" Value="#CAD5E4"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5">
<ScrollViewer x:Name="PART_ContentHost"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.55"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource InkBrush}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<StackPanel Orientation="Horizontal">
<Grid Width="18" Height="18" Margin="0,1,8,0">
<Ellipse Stroke="#6B7280" StrokeThickness="1.4" Fill="White"/>
<Ellipse x:Name="Dot" Width="8" Height="8" Fill="{StaticResource AccentBrush}" Opacity="0"/>
</Grid>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Dot" Property="Opacity" Value="1"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.55"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource InkBrush}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="0,0,18,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Border x:Name="Box" Width="18" Height="18" Margin="0,1,8,0"
BorderBrush="#6B7280" BorderThickness="1.4" CornerRadius="4" Background="White">
<Path x:Name="Tick" Data="M 4 9 L 8 13 L 15 5"
Stroke="White" StrokeThickness="2" StrokeStartLineCap="Round"
StrokeEndLineCap="Round" Opacity="0"/>
</Border>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Tick" Property="Opacity" Value="1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Background="#F6F8FC">
<Grid Margin="18" Background="#F6F8FC">
<Grid.RowDefinitions>
<RowDefinition Height="480"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Width="920" Height="480" HorizontalAlignment="Center" VerticalAlignment="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="White" CornerRadius="8" Padding="20,18">
<StackPanel>
<TextBlock Text="选择动作"
FontSize="18"
FontWeight="Bold"
Foreground="{StaticResource InkBrush}"
Margin="0,0,0,14"/>
<Button x:Name="ShutdownButton" Content="关机" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="RestartButton" Content="重启" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="SleepButton" Content="睡眠" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="HibernateButton" Content="休眠" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="WakeButton" Content="唤醒" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="LockButton" Content="锁屏" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
<Button x:Name="LogOffButton" Content="注销" Style="{StaticResource ActionChoiceButton}" Click="ActionButton_Click"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Background="White" CornerRadius="8" Padding="28,18">
<StackPanel>
<TextBlock Text="任务计划"
FontSize="18"
FontWeight="Bold"
Foreground="{StaticResource InkBrush}"/>
<TextBlock x:Name="ActionTitleText"
Margin="0,10,0,0"
FontSize="28"
FontWeight="Bold"
Foreground="{StaticResource InkBrush}"/>
<TextBlock x:Name="ActionDescriptionText"
Margin="2,0,0,20"
FontSize="14"
Foreground="{StaticResource MutedBrush}"/>
<RadioButton x:Name="ClockRadio" Content="指定时间" IsChecked="True" Checked="ScheduleModeChanged"/>
<StackPanel Orientation="Horizontal" Margin="20,0,0,18">
<TextBox x:Name="DateInput" Style="{StaticResource ModernTextBox}" Width="120"/>
<TextBox x:Name="TimeInput" Style="{StaticResource ModernTextBox}" Width="74"/>
</StackPanel>
<RadioButton x:Name="CountdownRadio" Content="倒计时" Checked="ScheduleModeChanged"/>
<StackPanel Orientation="Horizontal" Margin="20,0,0,18">
<TextBox x:Name="HoursInput" Style="{StaticResource ModernTextBox}" Text="0"/>
<TextBlock Text="小时" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" Margin="0,0,14,0"/>
<TextBox x:Name="MinutesInput" Style="{StaticResource ModernTextBox}" Text="30"/>
<TextBlock Text="分钟" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,24">
<CheckBox x:Name="ForceCheck" Content="强制关闭"/>
<TextBlock Text="提醒" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="ReminderInput" Style="{StaticResource ModernTextBox}" Text="5"/>
<TextBlock Text="分钟前" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="StartPlanButton" Content="开始计划" Background="{StaticResource AccentBrush}" Style="{StaticResource ModernButton}" Click="StartButton_Click"/>
<Button x:Name="CancelPlanButton" Content="取消计划" Background="{StaticResource SlateButtonBrush}" Style="{StaticResource ModernButton}" Click="CancelButton_Click"/>
<Button x:Name="AbortShutdownButton" Content="撤销关机" Background="{StaticResource DangerBrush}" Style="{StaticResource ModernButton}" Click="AbortButton_Click"/>
<Button Content="通知设置" Background="{StaticResource SlateButtonBrush}" Style="{StaticResource ModernButton}" Click="SettingsButton_Click"/>
</StackPanel>
<Border Background="#F8FAFC" CornerRadius="6" Padding="12,8" Margin="0,22,0,0">
<Grid>
<TextBlock x:Name="StatusText"
VerticalAlignment="Center"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}"/>
<TextBlock x:Name="CountdownText"
HorizontalAlignment="Right"
VerticalAlignment="Center"
FontSize="18"
FontWeight="Bold"
Foreground="{StaticResource SuccessBrush}"/>
</Grid>
</Border>
</StackPanel>
</Border>
</Grid>
</Grid>
</Grid>
</Window>

471
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,471 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
using AutoShutdown.Models;
using AutoShutdown.Services;
using Forms = System.Windows.Forms;
namespace AutoShutdown;
public partial class MainWindow : Window
{
private readonly PowerService _powerService = new();
private readonly WakeService _wakeService = new();
private readonly NotificationSettingsStore _settingsStore = new();
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<PowerAction, System.Windows.Controls.Button> _actionButtons;
private readonly Forms.NotifyIcon? _trayIcon;
private readonly bool _enableTray;
private NotificationSettings _notificationSettings;
private NotificationService _notificationService;
private PowerAction _selectedAction = PowerAction.Shutdown;
private PowerAction? _scheduledAction;
private DateTime? _scheduledAt;
private bool _hasExecuted;
private bool _reminderShown;
private bool _allowExit;
public MainWindow(bool enableTray = true)
{
_enableTray = enableTray;
_notificationSettings = _settingsStore.Load();
_notificationService = new NotificationService(_notificationSettings);
InitializeComponent();
_actionButtons = new Dictionary<PowerAction, System.Windows.Controls.Button>
{
[PowerAction.Shutdown] = ShutdownButton,
[PowerAction.Restart] = RestartButton,
[PowerAction.Sleep] = SleepButton,
[PowerAction.Hibernate] = HibernateButton,
[PowerAction.Wake] = WakeButton,
[PowerAction.Lock] = LockButton,
[PowerAction.LogOff] = LogOffButton
};
foreach (var (action, button) in _actionButtons)
{
button.Tag = action;
button.ToolTip = action.Description();
}
DateInput.Text = DateTime.Today.ToString("yyyy/M/d");
TimeInput.Text = DateTime.Now.AddMinutes(30).ToString("HH:mm");
ForceCheck.ToolTip = "强制关闭未响应程序,仅用于关机、重启、注销等动作。";
_trayIcon = _enableTray ? CreateTrayIcon() : null;
SelectAction(PowerAction.Shutdown);
UpdateScheduleInputs();
UpdateStatus();
_timer.Tick += (_, _) => OnTick();
_timer.Start();
}
protected override void OnClosing(CancelEventArgs e)
{
if (_enableTray && !_allowExit)
{
e.Cancel = true;
HideToTray();
return;
}
_trayIcon?.Dispose();
base.OnClosing(e);
}
private Forms.NotifyIcon CreateTrayIcon()
{
var menu = new Forms.ContextMenuStrip();
menu.Items.Add("显示/收起主窗口", null, (_, _) => Dispatcher.Invoke(ToggleMainWindowFromTray));
menu.Items.Add("取消当前计划", null, async (_, _) => await Dispatcher.InvokeAsync(CancelCurrentPlanAsync));
menu.Items.Add("OmniNotify 适配设置", null, (_, _) => Dispatcher.Invoke(OpenNotificationSettings));
menu.Items.Add(new Forms.ToolStripSeparator());
menu.Items.Add("退出 AutoShutdown", null, (_, _) => ExitFromTray());
var icon = new Forms.NotifyIcon
{
Icon = LoadTrayIcon(),
Text = "AutoShutdown",
ContextMenuStrip = menu,
Visible = true
};
icon.MouseClick += (_, e) =>
{
if (e.Button == Forms.MouseButtons.Left)
{
Dispatcher.Invoke(ToggleMainWindowFromTray);
}
};
return icon;
}
private static System.Drawing.Icon LoadTrayIcon()
{
var processPath = Environment.ProcessPath;
return !string.IsNullOrWhiteSpace(processPath) && System.Drawing.Icon.ExtractAssociatedIcon(processPath) is { } icon
? icon
: System.Drawing.SystemIcons.Application;
}
private void HideToTray(bool showHint = true)
{
Hide();
if (!showHint)
{
return;
}
if (_notificationSettings.Mode == NotificationMode.OmniNotify)
{
_ = _notificationService.NotifyAsync("AutoShutdown 已在托盘运行", "关闭窗口不会退出应用,可在托盘菜单中退出。");
return;
}
_trayIcon?.ShowBalloonTip(2500, "AutoShutdown 已在托盘运行", "关闭窗口不会退出应用,可在托盘菜单中退出。", Forms.ToolTipIcon.Info);
}
private void ToggleMainWindowFromTray()
{
if (IsVisible && WindowState != WindowState.Minimized)
{
HideToTray(showHint: false);
return;
}
RestoreFromTray();
}
private void RestoreFromTray()
{
Show();
WindowState = WindowState.Normal;
Activate();
}
private void ExitFromTray()
{
_allowExit = true;
Dispatcher.Invoke(Close);
}
private void UpdateTrayText(string text)
{
if (_trayIcon is null)
{
return;
}
_trayIcon.Text = text.Length > 63 ? text[..63] : text;
}
private void ActionButton_Click(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.Button { Tag: PowerAction action })
{
SelectAction(action);
}
}
private void ScheduleModeChanged(object sender, RoutedEventArgs e) => UpdateScheduleInputs();
private async void StartButton_Click(object sender, RoutedEventArgs e) => await StartPlanAsync();
private async void CancelButton_Click(object sender, RoutedEventArgs e) => await CancelPlanAsync();
private async void AbortButton_Click(object sender, RoutedEventArgs e) => await AbortSystemShutdownAsync();
private void SettingsButton_Click(object sender, RoutedEventArgs e) => OpenNotificationSettings();
private void OpenNotificationSettings()
{
var window = new NotificationSettingsWindow(_notificationSettings, _notificationService)
{
Owner = this
};
if (window.ShowDialog() != true)
{
return;
}
_notificationSettings = window.Settings;
_settingsStore.Save(_notificationSettings);
_notificationService.UpdateSettings(_notificationSettings);
UpdateStatus();
}
private void SelectAction(PowerAction action)
{
_selectedAction = action;
ActionTitleText.Text = action.Label();
ActionDescriptionText.Text = action.Description();
foreach (var (item, button) in _actionButtons)
{
var selected = item == action;
button.Background = selected ? BrushFrom("#E2ECFF") : BrushFrom("#FFFFFF");
button.Foreground = selected ? BrushFrom("#2563EB") : BrushFrom("#111827");
button.BorderBrush = selected ? BrushFrom("#2563EB") : BrushFrom("#DEE6F2");
button.BorderThickness = selected ? new Thickness(2) : new Thickness(1);
}
}
private async Task StartPlanAsync()
{
if (!TryResolveScheduleTime(out var scheduledAt))
{
await _notificationService.NotifyAsync(this, "计划无效", "请检查时间或倒计时输入。", MessageBoxImage.Warning);
return;
}
if (scheduledAt <= DateTime.Now.AddSeconds(1))
{
await _notificationService.NotifyAsync(this, "计划无效", "请选择一个晚于当前时间的计划。", MessageBoxImage.Warning);
return;
}
var scheduledAction = _selectedAction;
var forceApps = ForceCheck.IsChecked == true;
_scheduledAt = scheduledAt;
_scheduledAction = scheduledAction;
_hasExecuted = false;
_reminderShown = false;
UpdateStatus();
await Dispatcher.Yield(DispatcherPriority.Background);
var seconds = (int)Math.Ceiling((scheduledAt - DateTime.Now).TotalSeconds);
if (scheduledAction == PowerAction.Wake)
{
await TryRunAsync(
() => _wakeService.ScheduleWake(scheduledAt),
"计划已启动",
"已创建 Windows 唤醒任务。请确保电源选项允许唤醒计时器。");
}
else if (scheduledAction is PowerAction.Shutdown or PowerAction.Restart)
{
await TryRunAsync(
() => _powerService.Execute(scheduledAction, seconds, forceApps),
"计划已启动",
"已交给 Windows 系统计划执行。");
}
else
{
await _notificationService.NotifyAsync(this, "计划已启动", $"{scheduledAction.Label()} 将在 {scheduledAt:yyyy-MM-dd HH:mm} 执行。");
}
}
private async Task CancelPlanAsync()
{
var hadPlan = _scheduledAt is not null;
var scheduledAction = _scheduledAction;
_scheduledAt = null;
_scheduledAction = null;
_hasExecuted = false;
_reminderShown = false;
UpdateStatus();
if (scheduledAction == PowerAction.Wake)
{
await TryCancelWakeAsync();
}
if (hadPlan && scheduledAction is not null)
{
await _notificationService.NotifyAsync(this, "计划已取消", $"已取消 {scheduledAction.Value.Label()} 计划。");
}
}
private Task CancelCurrentPlanAsync()
{
return _scheduledAction is PowerAction.Shutdown or PowerAction.Restart
? AbortSystemShutdownAsync()
: CancelPlanAsync();
}
private async Task AbortSystemShutdownAsync()
{
if (_powerService.CancelShutdown())
{
_scheduledAt = null;
_scheduledAction = null;
_hasExecuted = false;
_reminderShown = false;
UpdateStatus();
await _notificationService.NotifyAsync(this, "已撤销", "已请求 Windows 撤销待执行的关机/重启任务。");
return;
}
await _notificationService.NotifyAsync(this, "无需撤销", "当前没有可撤销的系统关机/重启任务,或系统拒绝了该请求。");
}
private async void OnTick()
{
if (_scheduledAt is null)
{
UpdateStatus();
return;
}
var remaining = _scheduledAt.Value - DateTime.Now;
if (remaining.TotalSeconds <= 0 && !_hasExecuted)
{
_hasExecuted = true;
var scheduledAction = _scheduledAction ?? _selectedAction;
var forceApps = ForceCheck.IsChecked == true;
if (scheduledAction is not (PowerAction.Shutdown or PowerAction.Restart or PowerAction.Wake))
{
await TryRunAsync(
() => _powerService.Execute(scheduledAction, 0, forceApps),
"执行完成",
$"{scheduledAction.Label()} 已执行。",
showSuccess: false);
}
_scheduledAt = null;
_scheduledAction = null;
}
else if (!_reminderShown && TryReadInt(ReminderInput.Text, 0, 120, out var reminder) && reminder > 0 && remaining.TotalMinutes <= reminder)
{
_reminderShown = true;
var scheduledAction = _scheduledAction ?? _selectedAction;
await _notificationService.NotifyAsync(this, "任务提醒", $"{scheduledAction.Label()} 将在 {FormatRemaining(remaining)} 后执行。");
}
UpdateStatus();
}
private bool TryResolveScheduleTime(out DateTime scheduledAt)
{
if (ClockRadio.IsChecked == true)
{
if (!DateTime.TryParse(DateInput.Text, out var date) || !TimeSpan.TryParse(TimeInput.Text, out var time))
{
scheduledAt = default;
return false;
}
scheduledAt = date.Date.Add(time);
return true;
}
if (!TryReadInt(HoursInput.Text, 0, 168, out var hours) || !TryReadInt(MinutesInput.Text, 0, 59, out var minutes))
{
scheduledAt = default;
return false;
}
scheduledAt = DateTime.Now.AddHours(hours).AddMinutes(minutes);
return true;
}
private void UpdateScheduleInputs()
{
if (DateInput is null || TimeInput is null || HoursInput is null || MinutesInput is null)
{
return;
}
var byClock = ClockRadio.IsChecked == true;
DateInput.IsEnabled = byClock;
TimeInput.IsEnabled = byClock;
HoursInput.IsEnabled = !byClock;
MinutesInput.IsEnabled = !byClock;
}
private void UpdateStatus()
{
UpdatePlanActionButtons();
if (_scheduledAt is null)
{
StatusText.Text = _notificationSettings.Mode == NotificationMode.OmniNotify
? $"当前没有运行中的计划。提示OmniNotify / {_notificationSettings.Channel}"
: "当前没有运行中的计划。";
CountdownText.Text = "待命";
UpdateTrayText("AutoShutdown - 待命");
return;
}
var remaining = _scheduledAt.Value - DateTime.Now;
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
var scheduledAction = _scheduledAction ?? _selectedAction;
StatusText.Text = $"下一次任务:{_scheduledAt:yyyy-MM-dd HH:mm} 执行 {scheduledAction.Label()}";
CountdownText.Text = FormatRemaining(remaining);
UpdateTrayText($"AutoShutdown - {scheduledAction.Label()} {FormatRemaining(remaining)}");
}
private void UpdatePlanActionButtons()
{
if (StartPlanButton is null || CancelPlanButton is null || AbortShutdownButton is null)
{
return;
}
var hasPlan = _scheduledAt is not null;
var needsSystemAbort = hasPlan && _scheduledAction is PowerAction.Shutdown or PowerAction.Restart;
StartPlanButton.Visibility = hasPlan ? Visibility.Collapsed : Visibility.Visible;
CancelPlanButton.Visibility = hasPlan && !needsSystemAbort ? Visibility.Visible : Visibility.Collapsed;
AbortShutdownButton.Visibility = needsSystemAbort ? Visibility.Visible : Visibility.Collapsed;
}
private async Task TryRunAsync(Action action, string title, string successMessage, bool showSuccess = true)
{
try
{
await Task.Run(action);
if (showSuccess)
{
await _notificationService.NotifyAsync(this, title, successMessage);
}
}
catch (Exception ex)
{
_scheduledAt = null;
_scheduledAction = null;
UpdateStatus();
await _notificationService.NotifyAsync(this, "启动失败", ex.Message, MessageBoxImage.Error);
}
}
private async Task TryCancelWakeAsync()
{
try
{
await Task.Run(_wakeService.CancelWake);
}
catch
{
// 取消普通计划时不因为系统任务不存在或权限差异打断用户。
}
}
private static bool TryReadInt(string text, int min, int max, out int value)
{
return int.TryParse(text, out value) && value >= min && value <= max;
}
private static string FormatRemaining(TimeSpan value)
{
if (value.TotalDays >= 1)
{
return $"{(int)value.TotalDays}天 {value.Hours:D2}:{value.Minutes:D2}:{value.Seconds:D2}";
}
return $"{Math.Max(0, (int)value.TotalHours):D2}:{value.Minutes:D2}:{value.Seconds:D2}";
}
private static System.Windows.Media.Brush BrushFrom(string hex)
{
return (System.Windows.Media.Brush)new System.Windows.Media.BrushConverter().ConvertFromString(hex)!;
}
}

View File

@@ -0,0 +1,7 @@
namespace AutoShutdown.Models;
internal enum NotificationMode
{
Local,
OmniNotify
}

View File

@@ -0,0 +1,12 @@
namespace AutoShutdown.Models;
internal sealed class NotificationSettings
{
public NotificationMode Mode { get; set; } = NotificationMode.Local;
public string OmniNotifyEndpoint { get; set; } = "http://127.0.0.1:19845/notify";
public string Channel { get; set; } = "default";
public static NotificationSettings Default() => new();
}

39
Models/PowerAction.cs Normal file
View File

@@ -0,0 +1,39 @@
namespace AutoShutdown.Models;
internal enum PowerAction
{
Shutdown,
Restart,
Sleep,
Hibernate,
Wake,
Lock,
LogOff
}
internal static class PowerActionText
{
public static string Label(this PowerAction action) => action switch
{
PowerAction.Shutdown => "关机",
PowerAction.Restart => "重启",
PowerAction.Sleep => "睡眠",
PowerAction.Hibernate => "休眠",
PowerAction.Wake => "唤醒",
PowerAction.Lock => "锁屏",
PowerAction.LogOff => "注销",
_ => action.ToString()
};
public static string Description(this PowerAction action) => action switch
{
PowerAction.Shutdown => "关闭电脑",
PowerAction.Restart => "重新启动电脑",
PowerAction.Sleep => "保留会话并进入低功耗状态",
PowerAction.Hibernate => "保存当前状态后断电待机",
PowerAction.Wake => "通过任务计划从睡眠或休眠中唤醒",
PowerAction.Lock => "锁定当前 Windows 会话",
PowerAction.LogOff => "注销当前用户",
_ => string.Empty
};
}

View File

@@ -0,0 +1,220 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AutoShutdown.Models;
using AutoShutdown.Services;
using WpfBrush = System.Windows.Media.Brush;
using WpfBrushes = System.Windows.Media.Brushes;
using WpfButton = System.Windows.Controls.Button;
using WpfCursors = System.Windows.Input.Cursors;
using WpfFontFamily = System.Windows.Media.FontFamily;
using WpfOrientation = System.Windows.Controls.Orientation;
using WpfRadioButton = System.Windows.Controls.RadioButton;
using WpfTextBox = System.Windows.Controls.TextBox;
namespace AutoShutdown;
internal sealed class NotificationSettingsWindow : Window
{
private readonly NotificationService _notificationService;
private readonly WpfRadioButton _localMode = new() { Content = "软件本身" };
private readonly WpfRadioButton _omniNotifyMode = new() { Content = "OmniNotify" };
private readonly WpfTextBox _endpoint = new();
private readonly WpfTextBox _channel = new();
private readonly TextBlock _status = new();
public NotificationSettings Settings { get; private set; }
public NotificationSettingsWindow(NotificationSettings settings, NotificationService notificationService)
{
_notificationService = notificationService;
Settings = new NotificationSettings
{
Mode = settings.Mode,
OmniNotifyEndpoint = settings.OmniNotifyEndpoint,
Channel = settings.Channel
};
Title = "OmniNotify 适配设置";
Width = 640;
SizeToContent = SizeToContent.Height;
MinWidth = 640;
MaxWidth = 640;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
Background = BrushFrom("#F6F8FC");
FontFamily = new WpfFontFamily("Microsoft YaHei UI");
FontSize = 14;
Content = BuildContent();
LoadSettings();
}
private UIElement BuildContent()
{
var host = new Grid
{
Background = BrushFrom("#F6F8FC")
};
var root = new Border
{
Background = BrushFrom("#FFFFFF"),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(26),
Margin = new Thickness(22)
};
var stack = new StackPanel();
root.Child = stack;
stack.Children.Add(new TextBlock
{
Text = "通知方式",
FontSize = 22,
FontWeight = FontWeights.Bold,
Foreground = BrushFrom("#111827")
});
stack.Children.Add(new TextBlock
{
Text = "选择提示由 AutoShutdown 弹窗显示,或发送给本机 OmniNotify。",
Margin = new Thickness(0, 4, 0, 20),
Foreground = BrushFrom("#64748B"),
TextWrapping = TextWrapping.Wrap
});
stack.Children.Add(MakeLabel("提示模式"));
var modeRow = new StackPanel { Orientation = WpfOrientation.Horizontal, Margin = new Thickness(0, 8, 0, 18) };
StyleRadio(_localMode);
StyleRadio(_omniNotifyMode);
_localMode.Margin = new Thickness(0, 0, 22, 0);
modeRow.Children.Add(_localMode);
modeRow.Children.Add(_omniNotifyMode);
stack.Children.Add(modeRow);
stack.Children.Add(MakeLabel("监听地址"));
StyleInput(_endpoint, 536);
_endpoint.Margin = new Thickness(0, 8, 0, 14);
stack.Children.Add(_endpoint);
stack.Children.Add(MakeLabel("频道名"));
StyleInput(_channel, 260);
_channel.Margin = new Thickness(0, 8, 0, 12);
stack.Children.Add(_channel);
_status.Text = "OmniNotify 模式需要先在 OmniNotify 中创建对应频道。";
_status.Foreground = BrushFrom("#64748B");
_status.TextWrapping = TextWrapping.Wrap;
_status.Margin = new Thickness(0, 0, 0, 20);
stack.Children.Add(_status);
var buttons = new StackPanel
{
Orientation = WpfOrientation.Horizontal,
HorizontalAlignment = System.Windows.HorizontalAlignment.Right
};
var test = MakeButton("测试发送", BrushFrom("#475569"));
test.Click += async (_, _) => await TestAsync();
var cancel = MakeButton("取消", BrushFrom("#64748B"));
cancel.Click += (_, _) => DialogResult = false;
var save = MakeButton("保存", BrushFrom("#2563EB"));
save.Click += (_, _) => SaveAndClose();
buttons.Children.Add(test);
buttons.Children.Add(cancel);
buttons.Children.Add(save);
stack.Children.Add(buttons);
host.Children.Add(root);
return host;
}
private void LoadSettings()
{
_localMode.IsChecked = Settings.Mode == NotificationMode.Local;
_omniNotifyMode.IsChecked = Settings.Mode == NotificationMode.OmniNotify;
_endpoint.Text = Settings.OmniNotifyEndpoint;
_channel.Text = Settings.Channel;
}
private async Task TestAsync()
{
var testSettings = ReadSettings();
_status.Text = "正在发送测试消息...";
_status.Foreground = BrushFrom("#64748B");
var ok = await _notificationService.TestOmniNotifyAsync(testSettings);
_status.Text = ok
? "测试消息已发送。"
: "测试失败,请确认 OmniNotify 正在运行、监听地址正确,并且频道已创建。";
_status.Foreground = ok ? BrushFrom("#008060") : BrushFrom("#DC2626");
}
private void SaveAndClose()
{
Settings = ReadSettings();
DialogResult = true;
}
private NotificationSettings ReadSettings()
{
return new NotificationSettings
{
Mode = _omniNotifyMode.IsChecked == true ? NotificationMode.OmniNotify : NotificationMode.Local,
OmniNotifyEndpoint = string.IsNullOrWhiteSpace(_endpoint.Text) ? "http://127.0.0.1:19845/notify" : _endpoint.Text.Trim(),
Channel = string.IsNullOrWhiteSpace(_channel.Text) ? "default" : _channel.Text.Trim()
};
}
private static TextBlock MakeLabel(string text)
{
return new TextBlock
{
Text = text,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFrom("#111827")
};
}
private static void StyleRadio(WpfRadioButton radio)
{
radio.Foreground = BrushFrom("#111827");
radio.FontSize = 14;
radio.Cursor = WpfCursors.Hand;
}
private static void StyleInput(WpfTextBox input, double width)
{
input.Height = 34;
input.Width = width;
input.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
input.Padding = new Thickness(10, 5, 10, 5);
input.BorderBrush = BrushFrom("#CBD5E1");
input.BorderThickness = new Thickness(1);
input.Background = WpfBrushes.White;
input.Foreground = BrushFrom("#111827");
}
private static WpfButton MakeButton(string text, WpfBrush background)
{
var button = new WpfButton
{
Content = text,
Height = 38,
MinWidth = 92,
Margin = new Thickness(10, 0, 0, 0),
Padding = new Thickness(16, 0, 16, 0),
Background = background,
Foreground = WpfBrushes.White,
BorderThickness = new Thickness(0),
FontWeight = FontWeights.SemiBold,
Cursor = WpfCursors.Hand
};
return button;
}
private static WpfBrush BrushFrom(string hex)
{
return (WpfBrush)new BrushConverter().ConvertFromString(hex)!;
}
}

103
Program.cs Normal file
View File

@@ -0,0 +1,103 @@
using System.Windows;
namespace AutoShutdown;
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
var app = new System.Windows.Application();
#if DEBUG
if (args.Any(arg => arg.Equals("--capture-notification-settings-ui", StringComparison.OrdinalIgnoreCase)))
{
CaptureNotificationSettingsUiAndExit(app, args);
return;
}
if (args.Any(arg => arg.Equals("--capture-ui", StringComparison.OrdinalIgnoreCase) || arg.StartsWith("--capture-ui=", StringComparison.OrdinalIgnoreCase)))
{
CaptureUiAndExit(app, args);
return;
}
#endif
app.Run(new MainWindow());
}
#if DEBUG
private static void CaptureUiAndExit(System.Windows.Application app, string[] args)
{
var size = ResolveCaptureSize(args);
var window = new MainWindow(enableTray: false)
{
WindowStartupLocation = WindowStartupLocation.Manual,
Left = -20000,
Top = -20000,
Width = size.Width,
Height = size.Height,
ShowInTaskbar = false
};
window.ContentRendered += (_, _) =>
{
window.Dispatcher.InvokeAsync(() =>
{
var requestedPath = args
.FirstOrDefault(arg => arg.StartsWith("--capture-ui=", StringComparison.OrdinalIgnoreCase))
?.Split('=', 2)[1];
var path = Services.ScreenshotService.Capture(window, requestedPath);
Console.WriteLine(path);
window.Close();
}, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
};
app.Run(window);
}
private static void CaptureNotificationSettingsUiAndExit(System.Windows.Application app, string[] args)
{
var window = new NotificationSettingsWindow(
Models.NotificationSettings.Default(),
new Services.NotificationService(Models.NotificationSettings.Default()))
{
WindowStartupLocation = WindowStartupLocation.Manual,
Left = -20000,
Top = -20000,
ShowInTaskbar = false
};
window.ContentRendered += (_, _) =>
{
window.Dispatcher.InvokeAsync(() =>
{
var requestedPath = args
.FirstOrDefault(arg => arg.StartsWith("--capture-ui=", StringComparison.OrdinalIgnoreCase))
?.Split('=', 2)[1];
var path = Services.ScreenshotService.Capture(window, requestedPath);
Console.WriteLine(path);
window.Close();
}, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
};
app.Run(window);
}
private static System.Windows.Size ResolveCaptureSize(string[] args)
{
var sizeArg = args.FirstOrDefault(arg => arg.StartsWith("--capture-size=", StringComparison.OrdinalIgnoreCase));
if (sizeArg is null)
{
return new System.Windows.Size(1100, 760);
}
var raw = sizeArg.Split('=', 2)[1].ToLowerInvariant().Split('x', 2);
return raw.Length == 2 && double.TryParse(raw[0], out var width) && double.TryParse(raw[1], out var height)
? new System.Windows.Size(width, height)
: new System.Windows.Size(1100, 760);
}
#endif
}

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# AutoShutdown
面向 Windows 10 及以上系统的自动电源任务工具,使用 C# WPF 和 .NET 8 开发,无第三方依赖。
## 功能
- 按指定日期时间或倒计时执行任务
- 支持关机、重启、睡眠、休眠、唤醒、锁屏、注销
- 关机/重启交给 Windows 系统计划执行,关闭应用后仍可生效
- 唤醒通过 Windows 任务计划程序实现,可从睡眠/休眠状态唤醒
- 支持强制关闭未响应程序、执行前提醒、撤销系统关机/重启
- 使用自定义应用图标,并支持系统托盘运行
- 点击窗口关闭按钮会最小化到托盘,只有通过托盘菜单才能真正退出
- 支持 OmniNotify 适配模式,可将原本的软件弹窗提示改为发送到 OmniNotify
- Debug 构建提供隐藏的界面自截图参数,用于开发者检查 UI
- Release 构建不会编译自截图逻辑,保持发布版本轻量
## 开发
```powershell
dotnet build
dotnet run
```
开发 UI 后可以运行隐藏截图命令,自动渲染主界面并保存图片:
```powershell
dotnet run -- --capture-ui=.\Screenshots\ui-review.png
dotnet run -- --capture-ui=.\Screenshots\ui-min.png --capture-size=960x720
dotnet run -- --capture-ui=.\Screenshots\ui-large.png --capture-size=2048x1152
```
该能力只在 Debug 构建中存在,不会出现在用户界面里。
## OmniNotify 适配
在主界面点击“通知设置”或在托盘菜单中打开“OmniNotify 适配设置”。
- 软件本身:保留 AutoShutdown 原有弹窗/托盘提示
- OmniNotify将原提示发送到 OmniNotify 的本地接口
- 默认监听地址:`http://127.0.0.1:19845/notify`
- 默认频道名:`default`
OmniNotify 需要先运行,并提前创建对应频道。设置保存到 `%LOCALAPPDATA%\AutoShutdown\notification-settings.json`
## 发布
```powershell
dotnet publish -c Release -r win-x64
```
发布目录位于 `bin/Release/net8.0-windows/win-x64/publish`
## 说明
普通 Windows 应用无法让已经完全断电的电脑自动开机。应用中的“唤醒”功能依赖 Windows 任务计划程序和系统电源设置,请在“电源选项”中允许唤醒计时器。

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

BIN
app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

11
app.manifest Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AutoShutdown.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>