2026-09-01-autostart.md 16 KB

开机自启动功能实施计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为 YZWater3 添加跨平台开机自启动功能,用户可在参数设置页面切换开关。

Architecture: 创建 StartupService 静态类,通过 RuntimeInformation.IsOSPlatform() 判断平台,分别操作 Windows 注册表、Linux .desktop 文件、macOS LaunchAgent。在 SystemConfig 中新增 RunAtStartup 属性持久化到 JSON,在 ViewBViewModel 中添加属性和切换逻辑,在 ViewBView.axaml 中添加 ToggleSwitch。

Tech Stack: .NET 8, Avalonia 12, CommunityToolkit.Mvvm, Microsoft.Win32.Registry

Spec: docs/superpowers/specs/2026-09-01-autostart-design.md


文件结构

操作 文件路径 职责
新建 src/YZWater.Core/Services/StartupService.cs 跨平台开机自启动管理
修改 src/YZWater.Core/Models/SystemConfig.cs 新增 RunAtStartup 属性
修改 src/YZWater.Core/ViewModels/ViewBViewModel.cs 新增 RunAtStartup 属性 + 保存逻辑 + 修复 SaveConfig bug
修改 src/YZWater.Avalonia/Views/ViewBView.axaml 新增系统设置区块 + ToggleSwitch
修改 src/YZWater.Core/Services/LanguageService.cs 新增中英文 i18n 键
修改 src/YZWater.Core/YZWater.Core.csproj 添加 Microsoft.Win32.Registry 包引用

Task 1: 添加 NuGet 包引用

Files:

  • Modify: src/YZWater.Core/YZWater.Core.csproj:10-23

  • [ ] Step 1: 添加 Microsoft.Win32.Registry 包

在 <ItemGroup> 的 PackageReference 列表中添加:

<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />

添加到 Serilog.Sinks.File 之后(第 20 行后)。

  • Step 2: 还原依赖

Run: dotnet restore src/YZWater.Core Expected: 成功还原,无错误

  • [ ] Step 3: 提交

    git add src/YZWater.Core/YZWater.Core.csproj
    git commit -m "chore: 添加 Microsoft.Win32.Registry 包引用"
    

Task 2: SystemConfig 模型新增属性

Files:

  • Modify: src/YZWater.Core/Models/SystemConfig.cs:130-141

  • [ ] Step 1: 在 _plcPollingIntervalMs 字段后添加新属性

在第 141 行(private int _plcPollingIntervalMs = 1000;)之后,添加:

/// <summary>
/// 是否开机自启动
/// </summary>
[ObservableProperty]
private bool _runAtStartup;
  • Step 2: 构建验证

Run: dotnet build src/YZWater.Core Expected: 编译成功

  • [ ] Step 3: 提交

    git add src/YZWater.Core/Models/SystemConfig.cs
    git commit -m "feat: SystemConfig 新增 RunAtStartup 属性"
    

Task 3: 创建 StartupService

Files:

  • Create: src/YZWater.Core/Services/StartupService.cs

  • [ ] Step 1: 创建 StartupService.cs

创建文件 src/YZWater.Core/Services/StartupService.cs,内容如下:

using System.Runtime.InteropServices;
using Microsoft.Win32;
using Serilog;

namespace YZWater.Core.Services;

/// <summary>
/// 跨平台开机自启动管理服务
/// </summary>
public static class StartupService
{
    private const string AppName = "YZWater";
    private const string WindowsRegistryPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
    private const string LinuxDesktopFileName = "yzwater.desktop";
    private const string MacPlistFileName = "com.yxwater.yzwater.plist";

    /// <summary>
    /// 设置开机自启动
    /// </summary>
    public static bool SetAutoStart(bool enabled)
    {
        try
        {
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                return SetWindowsAutoStart(enabled);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
                return SetLinuxAutoStart(enabled);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
                return SetMacAutoStart(enabled);

            Log.Warning("StartupService: 不支持的操作系统");
            return false;
        }
        catch (Exception ex)
        {
            Log.Error(ex, "StartupService: 设置开机自启动失败 (enabled={Enabled})", enabled);
            return false;
        }
    }

    /// <summary>
    /// 检查是否已启用开机自启动
    /// </summary>
    public static bool IsAutoStartEnabled()
    {
        try
        {
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                return IsWindowsAutoStartEnabled();
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
                return IsLinuxAutoStartEnabled();
            if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
                return IsMacAutoStartEnabled();

            return false;
        }
        catch (Exception ex)
        {
            Log.Error(ex, "StartupService: 检查开机自启动状态失败");
            return false;
        }
    }

    /// <summary>
    /// 获取当前可执行文件路径
    /// </summary>
    private static string GetExecutablePath()
    {
        return Environment.ProcessPath ?? Environment.GetCommandLineArgs()[0];
    }

    #region Windows

    private static bool SetWindowsAutoStart(bool enabled)
    {
        using var key = Registry.CurrentUser.OpenSubKey(WindowsRegistryPath, true);
        if (key == null)
        {
            Log.Error("StartupService: 无法打开注册表路径 {Path}", WindowsRegistryPath);
            return false;
        }

        if (enabled)
        {
            key.SetValue(AppName, GetExecutablePath());
            Log.Information("StartupService: Windows 开机自启动已启用");
        }
        else
        {
            key.DeleteValue(AppName, false);
            Log.Information("StartupService: Windows 开机自启动已禁用");
        }
        return true;
    }

    private static bool IsWindowsAutoStartEnabled()
    {
        using var key = Registry.CurrentUser.OpenSubKey(WindowsRegistryPath);
        return key?.GetValue(AppName) != null;
    }

    #endregion

    #region Linux

    private static string GetLinuxDesktopFilePath()
    {
        var configDir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".config", "autostart");
        return Path.Combine(configDir, LinuxDesktopFileName);
    }

    private static bool SetLinuxAutoStart(bool enabled)
    {
        var filePath = GetLinuxDesktopFilePath();

        if (enabled)
        {
            var dir = Path.GetDirectoryName(filePath)!;
            Directory.CreateDirectory(dir);

            var content = $"""
                [Desktop Entry]
                Type=Application
                Name={AppName}
                Exec={GetExecutablePath()}
                X-GNOME-Autostart-enabled=true
                """;
            File.WriteAllText(filePath, content);
            Log.Information("StartupService: Linux 开机自启动已启用 ({Path})", filePath);
        }
        else
        {
            if (File.Exists(filePath))
                File.Delete(filePath);
            Log.Information("StartupService: Linux 开机自启动已禁用");
        }
        return true;
    }

    private static bool IsLinuxAutoStartEnabled()
    {
        return File.Exists(GetLinuxDesktopFilePath());
    }

    #endregion

    #region macOS

    private static string GetMacPlistPath()
    {
        var libraryDir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            "Library", "LaunchAgents");
        return Path.Combine(libraryDir, MacPlistFileName);
    }

    private static bool SetMacAutoStart(bool enabled)
    {
        var filePath = GetMacPlistPath();

        if (enabled)
        {
            var dir = Path.GetDirectoryName(filePath)!;
            Directory.CreateDirectory(dir);

            var content = $"""
                <?xml version="1.0" encoding="UTF-8"?>
                <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
                <plist version="1.0">
                <dict>
                    <key>Label</key>
                    <string>com.yxwater.yzwater</string>
                    <key>ProgramArguments</key>
                    <array>
                        <string>{GetExecutablePath()}</string>
                    </array>
                    <key>RunAtLoad</key>
                    <true/>
                </dict>
                </plist>
                """;
            File.WriteAllText(filePath, content);
            Log.Information("StartupService: macOS 开机自启动已启用 ({Path})", filePath);
        }
        else
        {
            if (File.Exists(filePath))
                File.Delete(filePath);
            Log.Information("StartupService: macOS 开机自启动已禁用");
        }
        return true;
    }

    private static bool IsMacAutoStartEnabled()
    {
        return File.Exists(GetMacPlistPath());
    }

    #endregion
}
  • Step 2: 构建验证

Run: dotnet build src/YZWater.Core Expected: 编译成功

  • [ ] Step 3: 提交

    git add src/YZWater.Core/Services/StartupService.cs
    git commit -m "feat: 添加 StartupService 跨平台开机自启动服务"
    

Task 4: ViewBViewModel 添加属性和逻辑

Files:

  • Modify: src/YZWater.Core/ViewModels/ViewBViewModel.cs

  • [ ] Step 1: 添加 RunAtStartup 属性

在 _isConnecting 字段(第 40 行)之后添加:

[ObservableProperty]
private bool _runAtStartup;
  • Step 2: 添加翻译文本属性

在 _connectingText 字段(第 62 行)之后添加:

[ObservableProperty] private string _runAtStartupText;
[ObservableProperty] private string _systemSettingsText;
  • Step 3: 在 UpdateTexts() 方法中添加赋值

在 UpdateTexts() 方法中 ConnectingText = _lang.Get("Connecting");(第 92 行)之后添加:

RunAtStartupText = _lang.Get("RunAtStartup");
SystemSettingsText = _lang.Get("SystemSettings");
  • Step 4: 添加 OnRunAtStartupChanged 部分方法

在 UpdateTexts() 方法之后(第 93 行后),添加:

partial void OnRunAtStartupChanged(bool value)
{
    var success = StartupService.SetAutoStart(value);
    if (!success)
    {
        _runAtStartup = !value;
        OnPropertyChanged(nameof(RunAtStartup));
        Log.Warning("开机自启动设置失败,已回滚 UI 状态");
    }
    else
    {
        var config = ConfigService.GetConfig();
        if (config != null)
        {
            config.RunAtStartup = value;
            ConfigService.SaveConfig();
            Log.Information("开机自启动已{Status}", value ? "启用" : "禁用");
        }
    }
}
  • Step 5: 修改 LoadConfig() 加载 RunAtStartup

在 LoadConfig() 方法中,PumpFrequency = config.PumpFrequency;(第 110 行)之后添加:

RunAtStartup = config.RunAtStartup;
  • Step 6: 修复 SaveConfig() bug

将 SaveConfig() 方法(第 118-134 行)替换为:

/// <summary>
/// 保存配置
/// </summary>
[RelayCommand]
private void SaveConfig()
{
    var config = ConfigService.GetConfig();
    if (config == null)
    {
        Log.Warning("配置为空,无法保存");
        return;
    }

    config.PlcIp = PlcIp;
    config.PlcPort = PlcPort;
    config.AutoConnect = AutoConnect;
    config.LevelHighAlarm = LevelHighAlarm;
    config.LevelLowAlarm = LevelLowAlarm;
    config.FlowHighAlarm = FlowHighAlarm;
    config.PumpFrequency = PumpFrequency;
    // RunAtStartup 在 OnRunAtStartupChanged 中已单独保存

    ConfigService.SaveConfig();
    Log.Information("配置已保存");
}
  • Step 7: 构建验证

Run: dotnet build src/YZWater.Core Expected: 编译成功

  • [ ] Step 8: 提交

    git add src/YZWater.Core/ViewModels/ViewBViewModel.cs
    git commit -m "feat: ViewBViewModel 添加开机自启动属性和逻辑"
    

Task 5: LanguageService 添加 i18n 键

Files:

  • Modify: src/YZWater.Core/Services/LanguageService.cs

  • [ ] Step 1: 在中文词典中添加键

在 ["Connecting"] = "连接中...",(第 145 行)之后添加:

["RunAtStartup"] = "开机自启动",
["SystemSettings"] = "系统设置",
  • Step 2: 在英文词典中添加键

在 ["Connecting"] = "Connecting...";(约第 392 行)之后添加:

["RunAtStartup"] = "START WITH WINDOWS",
["SystemSettings"] = "SYSTEM SETTINGS",
  • Step 3: 构建验证

Run: dotnet build src/YZWater.Core Expected: 编译成功

  • [ ] Step 4: 提交

    git add src/YZWater.Core/Services/LanguageService.cs
    git commit -m "feat: 添加开机自启动中英文翻译"
    

Task 6: ViewBView 添加 UI

Files:

  • Modify: src/YZWater.Avalonia/Views/ViewBView.axaml

  • [ ] Step 1: 调整 Grid RowDefinitions

将第 24 行的 RowDefinitions="Auto,Auto" 改为 RowDefinitions="Auto,Auto,Auto",为新区块增加一行。

  • Step 2: 将"泵频率设置"移到 Row 1

将"泵频率设置"的 Border(第 80-95 行)中的 Grid.Row="1" 改为 Grid.Row="1"(保持不变),但将 Margin 从 "0,4,4,0" 改为 "0,4,4,4"。

  • Step 3: 将"操作按钮"移到 Row 2

将"操作按钮"的 Border(第 97-108 行)中的 Grid.Row="1" 改为 Grid.Row="2",Margin 从 "4,4,0,0" 改为 "4,4,0,4"。

  • Step 4: 在 Row 1 Column 1 添加系统设置区块

在"泵频率设置" Border(第 95 行 </Border>)之后,"操作按钮" Border 之前,插入:

<!-- 系统设置 -->
<Border Grid.Row="1" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
    <StackPanel>
        <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
            <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
            <TextBlock Text="{Binding SystemSettingsText}" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
                       Foreground="{DynamicResource TextSecondaryBrush}"/>
        </StackPanel>
        <Grid ColumnDefinitions="120,*" Margin="4,0">
            <TextBlock Grid.Column="0" Text="{Binding RunAtStartupText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
            <ToggleSwitch Grid.Column="1" IsChecked="{Binding RunAtStartup}" Margin="0,2"/>
        </Grid>
    </StackPanel>
</Border>
  • Step 5: 构建验证

Run: dotnet build src/YZWater.Avalonia Expected: 编译成功

  • [ ] Step 6: 提交

    git add src/YZWater.Avalonia/Views/ViewBView.axaml
    git commit -m "feat: ViewBView 添加开机自启动 ToggleSwitch"
    

Task 7: 集成测试

  • Step 1: 全量构建

Run: dotnet build Expected: 编译成功,无错误

  • Step 2: 运行应用验证

Run: dotnet run --project src/YZWater.Avalonia

手动验证:

  1. 登录应用
  2. 导航到参数设置页面(ViewB)
  3. 确认"系统设置"区块可见,包含"开机自启动"标签和 ToggleSwitch
  4. 切换 ToggleSwitch 为开启状态
  5. (Windows)打开注册表编辑器,检查 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 下是否有 YZWater 键
  6. 关闭应用,重新打开,确认 ToggleSwitch 状态已恢复
  7. 切换 ToggleSwitch 为关闭状态,确认注册表键已删除
  8. 切换语言为英文,确认"开机自启动"变为"START WITH WINDOWS"
  • [ ] Step 3: 提交(如果有修改)

    git add -A
    git commit -m "fix: 开机自启动集成测试修复"
    

完成标准

  • SystemConfig 包含 RunAtStartup 属性
  • StartupService 支持 Windows/Linux/macOS 三平台
  • ViewB 页面显示"系统设置"区块,包含开机自启动开关
  • 开关切换即时生效(写入注册表/文件 + 持久化到 JSON)
  • 重启应用后状态正确恢复
  • 中英文翻译完整
  • SaveConfig() bug 已修复
  • 全量构建无错误