使用.NET Native AOT构建Windows定时休眠工具

本文详细介绍如何使用.NET Native AOT技术构建Windows定时休眠工具,涵盖Win32 API调用、命令行参数解析、控制台输出优化及NuGet打包发布等核心技术实现。

sleep-pc:使用.NET Native AOT构建的Windows定时休眠工具

背景:让电脑休眠!

让笔记本电脑进入休眠状态似乎比想象中困难得多。无论是半夜自动唤醒加热背包的MacBook,还是拒绝休眠的Windows笔记本,我总是遇到各种问题。

某个周末,我正与后一个问题斗争:无法让Windows笔记本在Windows Media Player Legacy播放完播放列表后休眠。作为习惯在后台播放视频入睡的人,这非常令人烦恼…

我尝试了所有故障排除方法:电源计划设置正确,运行并探索了powercfg。最终我厌倦了,编写了一个小应用,在给定时间后强制笔记本电脑休眠。

使用SetSuspendState让电脑休眠

最初版本只需几分钟就完成了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using System.Runtime.InteropServices;

var wait = TimeSpan.FromSeconds(60 * 60); // 1小时
Console.WriteLine("Waiting for {wait}");
Thread.Sleep(wait);

Console.WriteLine("Sleeping!");
SetSuspendState(false, false, false);

[DllImport("PowrProf.dll", SetLastError = true)]
static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);

这个简单程序休眠固定时间(对我来说是1小时),然后通过P/Invoke调用PowrProf.dll中的SetSuspendState方法(仅限Windows),让笔记本电脑休眠。

虽然这里设置hibernate=false,但根据系统设置,Windows通常仍会休眠,特别是在启用混合睡眠时。对我来说这会休眠,这正合我意。

添加更多功能

考虑到这只是个小工具,我的想法是:

  • 添加适当的命令行参数解析
  • 使用新的.NET 10工具支持打包为Native AOT工具
  • 美化控制台输出
  • 允许"试运行"选项

使用ConsoleAppFramework创建控制台应用

有很多选项可供选择:

  • System.CommandLine
  • Spectre.Console.Cli
  • ConsoleAppFramework

ConsoleAppFramework v5是零依赖、零开销、零反射、零分配、AOT安全的CLI框架,由C#源生成器提供支持;实现极高的性能、最快的启动时间(使用NativeAOT)和最小的二进制大小。

将概念验证转换为"真正"的控制台应用非常简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System.ComponentModel.DataAnnotations;
using ConsoleAppFramework;
using System.Runtime.InteropServices;

await ConsoleApp.RunAsync(args, App.Countdown);

static partial class App
{
    /// <summary>
    /// 在发送计算机到休眠前等待指定的秒数
    /// </summary>
    /// <param name="sleepDelaySeconds">-s|--seconds, 计算机进入休眠前的时间(秒)。默认为1小时</param>
    /// <param name="dryRun">如果为true,打印消息而不是休眠</param>
    /// <param name="ct">用于取消执行</param>
    /// <returns></returns>
    public static async Task Countdown(
        [Range(1, 99 * 60 * 60)]uint sleepDelaySeconds = 60 * 60,
        bool dryRun = false,
        CancellationToken ct = default)
    {
        var wait = TimeSpan.FromSeconds(sleepDelaySeconds);
        Console.WriteLine("Waiting for {wait}");
        await Task.Delay(wait, ct);

        Console.WriteLine("Sleeping!");
        if (!dryRun)
        {
            SetSuspendState(false, false, false);
        }
    }

    [DllImport("PowrProf.dll", SetLastError = true)]
    static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
}

添加ConsoleAppFramework支持只需:

  • 调用源生成的ConsoleApp.RunAsync()方法
  • 用XML注释装饰目标方法,描述命令、参数和验证要求

添加Native AOT支持

添加Native AOT支持很简单,因为ConsoleAppFramework已支持Native AOT,且应用没有做太多其他事情。我在项目文件中添加了<PublishAot>true</PublishAot>设置,没有构建警告。

我切换到新的[LibraryImport]源生成器属性:

1
2
3
4
5
6
[LibraryImport("PowrProf.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] // 返回实际上是BOOL (int)
private static partial bool SetSuspendState(
    [MarshalAs(UnmanagedType.U1)] bool hibernate, 
    [MarshalAs(UnmanagedType.U1)] bool forceCritical, 
    [MarshalAs(UnmanagedType.U1)] bool disableWakeEvent);

使用dotnet publish -r win-x64 -c Release发布Native AOT版本后,生成的sleep-pc.exe文件为3.3MB—对于包含所有运行时组件的.NET应用来说还不错!

打包为Native AOT工具

我选择了"折衷包"方法:

  • “根"sleep-pc.nupkg包含工具的.NET 8框架相关构建,为.NET 10 SDK用户指向平台特定版本
  • 打包的应用具有<RollForward>Major</RollForward>,如果用户安装了.NET 9而没有.NET 8运行时,它将在.NET 9上运行
  • 平台特定包仅包含Native AOT资源,在用户安装.NET 10+时使用

最终.csproj文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <RootNamespace>SleepPc</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

    <!-- NuGet/工具设置 -->
    <PackAsTool>true</PackAsTool>
    <ToolCommandName>sleep-pc</ToolCommandName>
    <PackageId>sleep-pc</PackageId>
    <PackageVersion>0.1.0</PackageVersion>
    <Authors>Andrew Lock</Authors>
    <Description>在给定时间后发送Windows笔记本电脑休眠的工具</Description>
    <PackageTags>sleep;timer;windows;tool</PackageTags>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <RollForward>Major</RollForward>
    <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
  </PropertyGroup>

  <!-- 支持NuGet工具的条件框架版本 -->
  <PropertyGroup Condition="$(RuntimeIdentifier) != ''">
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="$(RuntimeIdentifier) == ''">
    <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
  </PropertyGroup>

  <PropertyGroup Condition="$(TargetFramework) == 'net10.0'">
    <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
    <PublishAot>true</PublishAot>
    
    <!-- Native AOT大小优化位 -->
    <DebuggerSupport>false</DebuggerSupport>
    <EventSourceSupport>false</EventSourceSupport>
    <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
    <MetricsSupport>false</MetricsSupport>
    <TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
    <StripSymbols>true</StripSymbols>
    <InvariantGlobalization>true</InvariantGlobalization>
    <IlcDisableReflection>true</IlcDisableReflection>
    <IlcTrimMetadata>true</IlcTrimMetadata>
    <IlcOptimizationPreference>Size</IlcOptimizationPreference>

    <!-- 用于Sizoscope分析 -->
    <IlcGenerateMstatFile>true</IlcGenerateMstatFile>
    <IlcGenerateDgmlFile>true</IlcGenerateDgmlFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ConsoleAppFramework" Version="5.5.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

</Project>

改进控制台输出

我决定不使用Spectre.Console,因为它技术上不兼容AOT,而且我只需要少量动态功能。我只想添加倒计时器,而不是让应用停留在Thread.Sleep()或Task.Wait()中。

通过在Console.WriteLine()中发送退格来"替换"控制台中的行:

1
2
3
4
5
6
7
var wait = TimeSpan.FromSeconds(sleepDelaySeconds);

// 写入初始文本
Console.Write("⏰ 剩余时间:         ");

// 发送一堆退格后跟格式化文本
Console.Write("\b\b\b\b\b\b\b\b{0:hh\\:mm\\:ss}", wait);

运行时,控制台看起来在原地更新。虽然不完美,但对我来说足够好。

为了让倒计时正常运行,我们必须切换到循环以确保定期更新控制台:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 计算结束时间以避免漂移
var wait = TimeSpan.FromSeconds(sleepDelaySeconds);
var deadline = DateTimeOffset.UtcNow.Add(wait);
var dryRunText = (dryRun ? " (试运行)" : "");

Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($@"将在 {wait:hh\:mm\:ss} 后休眠,时间 {deadline:dd MMM yy HH:mm:ss}{dryRunText}");
Console.WriteLine("按 ctrl-c 取消");
Console.WriteLine();
Console.Write("⏰ 剩余时间:         ");

while (!ct.IsCancellationRequested)
{
    // 更新时钟
    Console.Write("\b\b\b\b\b\b\b\b{0:hh\\:mm\\:ss}", wait);
    try
    {
        await Task.Delay(1_000, ct);
    }
    catch
    {
        // 用户取消 (Ctrl+C)
        goto canceled;
    }

    wait = deadline - DateTimeOffset.UtcNow; 

    if (wait <= TimeSpan.Zero)
    {
        Console.WriteLine();
        Console.WriteLine($"💤 达到截止时间,正在休眠{dryRunText}");
        if (!dryRun)
        {
            try
            {
                SetSuspendState(false, false, false);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"⚠️ 触发休眠错误: {ex}");
            } 
        }

        return;
    }
}

canceled:
Console.WriteLine();
Console.WriteLine("❌ 休眠已取消");

总结

在本文中,我描述了一个小型.NET工具,用于在计时器到期后强制Windows PC进入休眠。我从展示用于让笔记本电脑休眠的Win32 API和小型概念验证应用开始,然后将此实现扩展为Native AOT编译应用,打包为.NET工具,并添加改进的控制台输出。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计