GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-09-wechat-uia-a-bridge-skeleton.md
2026-05-20 21:39:12 +08:00

70 KiB

WeChat UIA Phase A · Bridge 骨架 + UIA POC Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 新增 Neta.WeChatBridge .NET 8 桥接进程,完成版本白名单校验、PC 微信主窗口定位、UIA 消息列表读取 POC、本地 HTTP server (/health /diag) + tray-secret 鉴权、向 backend 调 handshake。本 plan 完成后 bridge.exe 可独立从命令行启动稳定运行,但尚不主动监听事件、不支持发送、不被 Tray 自动拉起(留给 Phase B / E)。

Architecture:Neta.Tray 平级的独立 .NET 8 Windows console 项目。启动时:(1) 解析 CLI 参数 → (2) 加载版本 profile YAML → (3) 定位运行中的 WeChat.exe + 读 FileVersion → (4) 匹配 profile (不匹配则 exit 1 + 控制台打印失败原因) → (5) UIA 定位主窗口、抓 wxid / nickname → (6) Kestrel 启 HTTP server → (7) 调 backend handshake (失败仅 warn,不退出)。所有非 UIA 纯逻辑用 xUnit 单测严格 TDD;UIA 交互部分提供骨架 + 手工验证清单 (CI 跳过)。

Tech Stack: .NET 8 / C# / System.Windows.Automation (UIA COM 包装) / Microsoft.AspNetCore.App (Kestrel via WebApplication) / YamlDotNet 16.3 / xUnit。

Spec: docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md

关键约束:

  • 项目布局必须与 Neta.Tray 对齐 (同级目录、同包结构、同测试项目命名)。
  • CLI 参数形式与 BackendProcessManager.BuildBackendStartInfo 一致 (--tray-secret <s> 等),Phase E 的 BridgeProcessManager 才能零修改复用。
  • HTTP 鉴权 header 名 x-neta-tray-secret 与 Tray ↔ Backend 完全一致;Phase C 后端 controller 可零修改对接。
  • Secret 比较走恒定时间 CryptographicOperations.FixedTimeEquals,杜绝时序侧信道(架构师审查 #1)。
  • Bridge 与 backend 解耦:handshake 失败不阻塞 bridge 自身运行,仅 warn 日志,Phase C 才接通后端真实 controller。
  • net8.0-windows + Microsoft.WindowsDesktop.App framework reference 意味着本项目只能在 Windows 上构建和测试 (Linux CI 会编译失败,与 Neta.Tray 一致,沿用其 CI 过滤逻辑)。
  • 本 plan 不实现:消息事件订阅 (Phase B)、消息发送 (Phase B)、附件采集 (Phase B)、SQLite 归档 (Phase C)、Tray 拉起 bridge (Phase E)、安装包打包 (Phase E)、日志写文件 (Phase B,Phase A 只 Console)。
  • 所有 UIA 调用仅在 Windows 平台的开发者机上手工验证;CI 上不跑 UIA,xUnit 集成测试用 [SkippableFact]WeChat.exe 不存在时整体 skip。

文件结构

新增 — 主项目 packages/windows-tray/Neta.WeChatBridge/

文件 责任
Neta.WeChatBridge.csproj net8.0-windows, OutputType=Exe, UseWPF=true (拉取 UIAutomationClient/Types refs), FrameworkReference=Microsoft.AspNetCore.App, PackageReference YamlDotNet=16.3.0
Program.cs 启动入口:参数解析 → profile 加载 → 微信定位 → HTTP 启动 → handshake → 等 Ctrl+C
Runtime/BridgeRuntimeInfo.cs CLI 参数 record (TraySecret / BackendUrl / DataDir / BridgePort) + parser
Runtime/GracefulShutdown.cs 注册 Ctrl+C / SIGTERM / Console.CancelKeyPress → 触发 CancellationTokenSource
Config/VersionProfile.cs profile 数据类 + inherit 继承解析
Config/VersionProfileLoader.cs YAML 文件加载 + MatchByVersion(string)
Config/VersionProfiles.yaml 内置默认 profile (3.9.11.17 + 3.9.12.x 继承)
Uia/WeChatProcessLocator.cs 在系统进程中查 WeChat.exe + 读 MainModule.FileVersionInfo
Uia/WeChatWindow.cs 通过 UIA 定位主窗口、缓存子控件、抓取 wxid/nickname (best effort)
Uia/ChatBoxReader.cs POC:从消息 ListControl 读 ListItem,返回结构化 ItemSnapshot 列表
Http/TraySecretAuth.cs ASP.NET Core middleware:校验 x-neta-tray-secret header
Http/Endpoints/HealthEndpoint.cs MapGet("/health") 返回 { ok, wxid, nickname, wechatVersion }
Http/Endpoints/DiagEndpoint.cs MapGet("/diag") 返回诊断 JSON
Http/BridgeHttpServer.cs WebApplication.CreateBuilder 拼 Kestrel + 中间件 + endpoints,绑定 127.0.0.1:<port>
Backend/BackendClient.cs HttpClient 包装:HandshakeAsync POST /open/netaclaw/channel/uia/handshake
BridgeState.cs 单例 record:{ WechatVersion, Wxid, Nickname, ProfileName, StartedAtUtc },被 endpoints / handshake 共享只读

新增 — 测试项目 packages/windows-tray/Neta.WeChatBridge.Tests/

文件 覆盖
Neta.WeChatBridge.Tests.csproj xUnit + Microsoft.NET.Test.Sdk + Xunit.SkippableFact
Runtime/BridgeRuntimeInfoTests.cs CLI 参数解析:全填 / 缺参 / 端口非数字 / 端口越界
Config/VersionProfileLoaderTests.cs YAML 解析 / inherit 合并 / 精确版本匹配 / 通配 3.9.12.x 匹配 / 未匹配返回 null
Http/TraySecretAuthTests.cs TestServer (Microsoft.AspNetCore.TestHost) 验证 header 缺失/正确/错误
Http/HealthEndpointTests.cs TestServer 验证返回结构
Backend/BackendClientTests.cs HttpMessageHandler mock 验证请求 URL/header/body;模拟 404/超时
Uia/WeChatProcessLocatorTests.cs [SkippableFact]:WeChat.exe 不存在则 skip;存在则断言 Version != null

修改 — 解决方案级

文件 改动
packages/windows-tray/Neta.Tray.sln (若存在;不存在则跳过) dotnet sln add 加入新项目

本 plan 不修改 Neta.Tray.csprojBackendProcessManager.csTrayApplicationContext.cs——这些留到 Phase E。


Phase 1 · 项目骨架 + 启动参数

Task 1: Neta.WeChatBridge 项目骨架

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj

  • Create: packages/windows-tray/Neta.WeChatBridge/Program.cs

  • Create: packages/windows-tray/Neta.WeChatBridge.Tests/Neta.WeChatBridge.Tests.csproj

  • Create: packages/windows-tray/Neta.WeChatBridge.Tests/SmokeTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/SmokeTests.cs:

using Xunit;
using Neta.WeChatBridge;

public class SmokeTests
{
    [Fact]
    public void Assembly_loads()
    {
        // 仅验证类型可解析,csproj 配置正确
        Assert.NotNull(typeof(Program).Assembly);
    }
}
  • Step 2: 运行测试确认失败

Run (Windows PowerShell 或 bash):

cd packages/windows-tray
dotnet test Neta.WeChatBridge.Tests/Neta.WeChatBridge.Tests.csproj

Expected: 编译失败,Neta.WeChatBridge.Tests.csproj 不存在。

  • Step 3: 实现最小代码

创建 packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>Neta.WeChatBridge</RootNamespace>
    <AssemblyName>bridge</AssemblyName>
  </PropertyGroup>
  <ItemGroup>
    <!-- 显式声明 WindowsDesktop.App 以引入 System.Windows.Automation;
         不使用 UseWPF,避免 WPF 运行时被拉起 (bridge 是纯 console) -->
    <FrameworkReference Include="Microsoft.WindowsDesktop.App" />
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="YamlDotNet" Version="16.3.0" />
  </ItemGroup>
  <ItemGroup>
    <!-- 显式引用 UIAutomation 程序集 (WindowsDesktop.App framework 下可用) -->
    <Reference Include="UIAutomationClient" />
    <Reference Include="UIAutomationTypes" />
  </ItemGroup>
</Project>

架构说明:UseWPF=true 会把项目标成 WPF,SDK 会自动初始化 System.Windows.Application 静态资源、拉入 PresentationFramework 等,bridge 完全用不到。改用 FrameworkReference Include="Microsoft.WindowsDesktop.App" 按需显式引用,体积更小、启动更快、语义正确。net8.0-windows TFM + WindowsDesktop.App framework 决定了本项目只能在 Windows 上构建。

创建 packages/windows-tray/Neta.WeChatBridge/Program.cs:

namespace Neta.WeChatBridge;

public class Program
{
    public static int Main(string[] args)
    {
        Console.WriteLine("Neta.WeChatBridge 启动占位 — Phase A 后续 task 会替换。");
        return 0;
    }
}

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Neta.WeChatBridge.Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
    <PackageReference Include="Xunit.SkippableFact" Version="1.5.23" />
    <!-- 用轻量 TestHost 即可;不需要 Mvc.Testing 这种高阶集成包 -->
    <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.10" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Neta.WeChatBridge\Neta.WeChatBridge.csproj" />
  </ItemGroup>
</Project>
  • Step 4: 运行测试确认通过
cd packages/windows-tray
dotnet build Neta.WeChatBridge/Neta.WeChatBridge.csproj
dotnet test Neta.WeChatBridge.Tests/Neta.WeChatBridge.Tests.csproj

Expected:Test Run Successful. Total tests: 1. Passed: 1

  • Step 5: 把新项目加入解决方案 (若存在)
cd packages/windows-tray
# 检查是否有 sln
ls *.sln 2>/dev/null && \
  dotnet sln add Neta.WeChatBridge/Neta.WeChatBridge.csproj && \
  dotnet sln add Neta.WeChatBridge.Tests/Neta.WeChatBridge.Tests.csproj || \
  echo "no sln found, skip"
  • Step 6: Commit
git add packages/windows-tray/Neta.WeChatBridge \
        packages/windows-tray/Neta.WeChatBridge.Tests \
        packages/windows-tray/*.sln
git commit -m "feat(bridge): scaffold Neta.WeChatBridge .NET 8 project"

Task 2: BridgeRuntimeInfo CLI 参数解析

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Runtime/BridgeRuntimeInfo.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/BridgeRuntimeInfoTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/BridgeRuntimeInfoTests.cs:

using Xunit;
using Neta.WeChatBridge.Runtime;

public class BridgeRuntimeInfoTests
{
    [Fact]
    public void Parse_returns_all_fields_when_args_complete()
    {
        var args = new[]
        {
            "--tray-secret", "sec-abc",
            "--backend-url", "http://127.0.0.1:7071",
            "--data-dir", @"C:\ProgramData\Neta",
            "--bridge-port", "7702",
        };

        var info = BridgeRuntimeInfo.Parse(args);

        Assert.Equal("sec-abc", info.TraySecret);
        Assert.Equal("http://127.0.0.1:7071", info.BackendUrl);
        Assert.Equal(@"C:\ProgramData\Neta", info.DataDir);
        Assert.Equal(7702, info.BridgePort);
    }

    [Fact]
    public void Parse_throws_when_tray_secret_missing()
    {
        var args = new[]
        {
            "--backend-url", "http://127.0.0.1:7071",
            "--data-dir", @"C:\Neta",
            "--bridge-port", "7702",
        };

        var ex = Assert.Throws<ArgumentException>(() => BridgeRuntimeInfo.Parse(args));
        Assert.Contains("--tray-secret", ex.Message);
    }

    [Fact]
    public void Parse_throws_when_bridge_port_not_integer()
    {
        var args = new[]
        {
            "--tray-secret", "sec",
            "--backend-url", "http://127.0.0.1:7071",
            "--data-dir", @"C:\Neta",
            "--bridge-port", "not-a-number",
        };

        var ex = Assert.Throws<ArgumentException>(() => BridgeRuntimeInfo.Parse(args));
        Assert.Contains("--bridge-port", ex.Message);
    }

    [Theory]
    [InlineData("0")]
    [InlineData("-1")]
    [InlineData("65536")]
    [InlineData("99999")]
    public void Parse_throws_when_bridge_port_out_of_range(string portStr)
    {
        var args = new[]
        {
            "--tray-secret", "sec",
            "--backend-url", "http://127.0.0.1:7071",
            "--data-dir", @"C:\Neta",
            "--bridge-port", portStr,
        };

        Assert.Throws<ArgumentException>(() => BridgeRuntimeInfo.Parse(args));
    }

    [Fact]
    public void Parse_treats_args_case_insensitively()
    {
        var args = new[]
        {
            "--Tray-Secret", "sec",
            "--BACKEND-URL", "http://127.0.0.1:7071",
            "--Data-Dir", @"C:\Neta",
            "--Bridge-Port", "7702",
        };

        var info = BridgeRuntimeInfo.Parse(args);
        Assert.Equal("sec", info.TraySecret);
    }
}
  • Step 2: 运行测试确认失败
cd packages/windows-tray
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BridgeRuntimeInfoTests"

Expected: 编译失败,BridgeRuntimeInfo 不存在。

  • Step 3: 实现最小代码

创建 packages/windows-tray/Neta.WeChatBridge/Runtime/BridgeRuntimeInfo.cs:

namespace Neta.WeChatBridge.Runtime;

public sealed record BridgeRuntimeInfo(
    string TraySecret,
    string BackendUrl,
    string DataDir,
    int BridgePort
)
{
    public static BridgeRuntimeInfo Parse(IReadOnlyList<string> args)
    {
        var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        for (var i = 0; i < args.Count - 1; i++)
        {
            var key = args[i];
            if (!key.StartsWith("--", StringComparison.Ordinal)) continue;
            dict[key.ToLowerInvariant()] = args[i + 1];
            i++;
        }

        var traySecret = Require(dict, "--tray-secret");
        var backendUrl = Require(dict, "--backend-url");
        var dataDir    = Require(dict, "--data-dir");
        var portStr    = Require(dict, "--bridge-port");

        if (!int.TryParse(portStr, out var port))
            throw new ArgumentException($"--bridge-port 必须是整数,实际值: {portStr}");
        if (port < 1 || port > 65535)
            throw new ArgumentException($"--bridge-port 必须在 1-65535 区间,实际值: {port}");

        return new BridgeRuntimeInfo(traySecret, backendUrl, dataDir, port);
    }

    private static string Require(Dictionary<string, string> dict, string key)
    {
        if (!dict.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
            throw new ArgumentException($"缺少必需参数 {key}");
        return value;
    }
}
  • Step 4: 运行测试确认通过
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BridgeRuntimeInfoTests"

Expected:Total tests: 8. Passed: 8 (1 + 1 + 1 + 4 [Theory] + 1)。

  • Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Runtime/BridgeRuntimeInfo.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/BridgeRuntimeInfoTests.cs
git commit -m "feat(bridge): add BridgeRuntimeInfo CLI args parser"

Task 3: GracefulShutdown 信号处理

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Runtime/GracefulShutdown.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/GracefulShutdownTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/GracefulShutdownTests.cs:

using Xunit;
using Neta.WeChatBridge.Runtime;

public class GracefulShutdownTests
{
    [Fact]
    public void Trigger_signals_token()
    {
        var shutdown = new GracefulShutdown();
        Assert.False(shutdown.Token.IsCancellationRequested);

        shutdown.Trigger("test");

        Assert.True(shutdown.Token.IsCancellationRequested);
        Assert.Equal("test", shutdown.Reason);
    }

    [Fact]
    public void Trigger_is_idempotent()
    {
        var shutdown = new GracefulShutdown();
        shutdown.Trigger("first");
        shutdown.Trigger("second");
        // 第二次不覆盖 reason
        Assert.Equal("first", shutdown.Reason);
    }
}
  • Step 2: 运行测试确认失败
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~GracefulShutdownTests"

Expected: 编译失败。

  • Step 3: 实现最小代码

创建 packages/windows-tray/Neta.WeChatBridge/Runtime/GracefulShutdown.cs:

namespace Neta.WeChatBridge.Runtime;

public sealed class GracefulShutdown
{
    private readonly CancellationTokenSource _cts = new();
    private string? _reason;

    public CancellationToken Token => _cts.Token;
    public string? Reason => _reason;

    public void Trigger(string reason)
    {
        if (_cts.IsCancellationRequested) return;
        _reason = reason;
        _cts.Cancel();
    }

    /// <summary>注册 Ctrl+C / SIGTERM / AppDomain 退出 hook。</summary>
    public void HookConsoleSignals()
    {
        Console.CancelKeyPress += (_, e) =>
        {
            e.Cancel = true; // 不让进程立即终止
            Trigger("CTRL+C");
        };
        AppDomain.CurrentDomain.ProcessExit += (_, _) => Trigger("ProcessExit");
    }
}
  • Step 4: 运行测试确认通过
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~GracefulShutdownTests"

Expected:Passed: 2

  • Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Runtime/GracefulShutdown.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Runtime/GracefulShutdownTests.cs
git commit -m "feat(bridge): add GracefulShutdown signal handler"

Phase 2 · 版本 profile

Task 4: VersionProfile 数据类

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Config/VersionProfile.cs

  • Step 1: 实现数据类 (纯 POCO,无逻辑,不需要先写测试)

创建 packages/windows-tray/Neta.WeChatBridge/Config/VersionProfile.cs:

namespace Neta.WeChatBridge.Config;

/// <summary>YAML 单条 profile (未解析继承前)。</summary>
public sealed class VersionProfileRaw
{
    public string Version { get; set; } = string.Empty;
    public string? Inherit { get; set; }
    public string? MainWindowClass { get; set; }
    public string? SessionListName { get; set; }
    public string? MessageListName { get; set; }
    public string? SearchBoxName { get; set; }
    public string? InputBoxName { get; set; }
    public string? ImageCacheDir { get; set; }
}

/// <summary>YAML 文件根结构。</summary>
public sealed class VersionProfileDocument
{
    public List<VersionProfileRaw> Profiles { get; set; } = new();
}

/// <summary>继承解析后的最终 profile。所有字段 non-null。</summary>
public sealed record VersionProfile(
    string Version,
    string MainWindowClass,
    string SessionListName,
    string MessageListName,
    string SearchBoxName,
    string InputBoxName,
    string ImageCacheDir
);
  • Step 2: 编译
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj

Expected: Build succeeded。

  • Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Config/VersionProfile.cs
git commit -m "feat(bridge): add VersionProfile data types"

Task 5: VersionProfileLoader YAML 加载 + inherit 合并 + 匹配

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Config/VersionProfileLoader.cs

  • Create: packages/windows-tray/Neta.WeChatBridge/Config/VersionProfiles.yaml (EmbeddedResource)

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Config/VersionProfileLoaderTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Config/VersionProfileLoaderTests.cs:

using Xunit;
using Neta.WeChatBridge.Config;

public class VersionProfileLoaderTests
{
    private const string Yaml = """
profiles:
  - version: "3.9.11.17"
    mainWindowClass: "WeChatMainWndForPC"
    sessionListName: "会话"
    messageListName: "消息"
    searchBoxName: "搜索"
    inputBoxName: "输入"
    imageCacheDir: "FileStorage/Image/{YYYY-MM}"
  - version: "3.9.12.x"
    inherit: "3.9.11.17"
    messageListName: "消息列表"
""";

    [Fact]
    public void LoadFromString_parses_two_profiles()
    {
        var loader = VersionProfileLoader.LoadFromString(Yaml);
        Assert.Equal(2, loader.Count);
    }

    [Fact]
    public void Match_exact_version_returns_profile()
    {
        var loader = VersionProfileLoader.LoadFromString(Yaml);
        var p = loader.MatchByVersion("3.9.11.17");
        Assert.NotNull(p);
        Assert.Equal("WeChatMainWndForPC", p!.MainWindowClass);
        Assert.Equal("消息", p.MessageListName);
    }

    [Fact]
    public void Match_wildcard_returns_inherited_profile_with_overrides()
    {
        var loader = VersionProfileLoader.LoadFromString(Yaml);
        var p = loader.MatchByVersion("3.9.12.15");
        Assert.NotNull(p);
        // 继承 3.9.11.17 的大部分字段
        Assert.Equal("WeChatMainWndForPC", p!.MainWindowClass);
        Assert.Equal("会话", p.SessionListName);
        // 但 messageListName 被 3.9.12.x override
        Assert.Equal("消息列表", p.MessageListName);
    }

    [Fact]
    public void Match_returns_null_when_no_match()
    {
        var loader = VersionProfileLoader.LoadFromString(Yaml);
        Assert.Null(loader.MatchByVersion("4.0.0.0"));
    }

    [Fact]
    public void Match_returns_null_for_empty_version()
    {
        var loader = VersionProfileLoader.LoadFromString(Yaml);
        Assert.Null(loader.MatchByVersion(""));
    }

    [Fact]
    public void LoadFromString_throws_on_circular_inherit()
    {
        const string bad = """
profiles:
  - version: "A"
    inherit: "B"
    mainWindowClass: "X"
  - version: "B"
    inherit: "A"
    mainWindowClass: "Y"
""";
        Assert.Throws<InvalidOperationException>(() =>
        {
            var l = VersionProfileLoader.LoadFromString(bad);
            l.MatchByVersion("A");
        });
    }

    [Fact]
    public void LoadFromString_throws_when_required_field_missing_after_inherit()
    {
        const string bad = """
profiles:
  - version: "A"
    mainWindowClass: "X"
""";
        var loader = VersionProfileLoader.LoadFromString(bad);
        Assert.Throws<InvalidOperationException>(() => loader.MatchByVersion("A"));
    }
}
  • Step 2: 运行测试确认失败
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~VersionProfileLoaderTests"

Expected: 编译失败。

  • Step 3: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Config/VersionProfileLoader.cs:

using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace Neta.WeChatBridge.Config;

public sealed class VersionProfileLoader
{
    private readonly List<VersionProfileRaw> _raw;

    private VersionProfileLoader(List<VersionProfileRaw> raw)
    {
        _raw = raw;
    }

    public int Count => _raw.Count;

    public static VersionProfileLoader LoadFromString(string yaml)
    {
        var deserializer = new DeserializerBuilder()
            .WithNamingConvention(CamelCaseNamingConvention.Instance)
            .IgnoreUnmatchedProperties()
            .Build();
        var doc = deserializer.Deserialize<VersionProfileDocument>(yaml)
                  ?? new VersionProfileDocument();
        return new VersionProfileLoader(doc.Profiles);
    }

    public static VersionProfileLoader LoadFromFile(string path)
        => LoadFromString(File.ReadAllText(path));

    public VersionProfile? MatchByVersion(string version)
    {
        if (string.IsNullOrWhiteSpace(version)) return null;

        var raw = FindRaw(version);
        if (raw is null) return null;

        var merged = MergeInherit(raw, new HashSet<string>());
        return BuildFinal(merged);
    }

    private VersionProfileRaw? FindRaw(string version)
    {
        // 先精确匹配
        var exact = _raw.FirstOrDefault(p =>
            string.Equals(p.Version, version, StringComparison.OrdinalIgnoreCase));
        if (exact is not null) return exact;

        // 再通配:profile.Version 形如 "3.9.12.x" 匹配输入 "3.9.12.*"
        foreach (var p in _raw)
        {
            if (!p.Version.EndsWith(".x", StringComparison.OrdinalIgnoreCase)) continue;
            var prefix = p.Version[..^2]; // 去掉 ".x"
            if (version.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))
                return p;
        }
        return null;
    }

    private VersionProfileRaw MergeInherit(VersionProfileRaw p, HashSet<string> visiting)
    {
        if (!visiting.Add(p.Version))
            throw new InvalidOperationException(
                $"版本 profile 存在循环继承,链涉及 {p.Version}");

        if (string.IsNullOrWhiteSpace(p.Inherit)) return p;
        var parent = _raw.FirstOrDefault(x =>
            string.Equals(x.Version, p.Inherit, StringComparison.OrdinalIgnoreCase));
        if (parent is null)
            throw new InvalidOperationException(
                $"版本 profile {p.Version} 继承的 {p.Inherit} 不存在");

        var mergedParent = MergeInherit(parent, visiting);
        return new VersionProfileRaw
        {
            Version         = p.Version,
            MainWindowClass = p.MainWindowClass ?? mergedParent.MainWindowClass,
            SessionListName = p.SessionListName ?? mergedParent.SessionListName,
            MessageListName = p.MessageListName ?? mergedParent.MessageListName,
            SearchBoxName   = p.SearchBoxName   ?? mergedParent.SearchBoxName,
            InputBoxName    = p.InputBoxName    ?? mergedParent.InputBoxName,
            ImageCacheDir   = p.ImageCacheDir   ?? mergedParent.ImageCacheDir,
        };
    }

    private static VersionProfile BuildFinal(VersionProfileRaw p)
    {
        string Req(string? v, string field) =>
            string.IsNullOrWhiteSpace(v)
                ? throw new InvalidOperationException(
                    $"版本 profile {p.Version} 继承合并后缺少字段 {field}")
                : v;

        return new VersionProfile(
            Version:         p.Version,
            MainWindowClass: Req(p.MainWindowClass, nameof(p.MainWindowClass)),
            SessionListName: Req(p.SessionListName, nameof(p.SessionListName)),
            MessageListName: Req(p.MessageListName, nameof(p.MessageListName)),
            SearchBoxName:   Req(p.SearchBoxName,   nameof(p.SearchBoxName)),
            InputBoxName:    Req(p.InputBoxName,    nameof(p.InputBoxName)),
            ImageCacheDir:   Req(p.ImageCacheDir,   nameof(p.ImageCacheDir))
        );
    }
}

创建内置默认 packages/windows-tray/Neta.WeChatBridge/Config/VersionProfiles.yaml:

profiles:
  - version: "3.9.11.17"
    mainWindowClass: "WeChatMainWndForPC"
    sessionListName: "会话"
    messageListName: "消息"
    searchBoxName: "搜索"
    inputBoxName: "输入"
    imageCacheDir: "FileStorage/Image/{YYYY-MM}"
  - version: "3.9.12.x"
    inherit: "3.9.11.17"

修改 packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj,在 </ItemGroup> (Reference) 之后追加:

  <ItemGroup>
    <!-- 只用 CopyToOutputDirectory,程序运行时从 BaseDirectory 读文件;
         不用 EmbeddedResource (避免 SDK "Duplicate item" 警告/冲突) -->
    <None Include="Config\VersionProfiles.yaml" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
  • Step 4: 运行测试确认通过
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~VersionProfileLoaderTests"

Expected:Passed: 7

  • Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Config/VersionProfileLoader.cs \
        packages/windows-tray/Neta.WeChatBridge/Config/VersionProfiles.yaml \
        packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj \
        packages/windows-tray/Neta.WeChatBridge.Tests/Config/VersionProfileLoaderTests.cs
git commit -m "feat(bridge): add VersionProfileLoader with YAML + inherit support"

Phase 3 · 微信进程定位 + UIA 主窗口

Task 6: BridgeState 共享状态容器

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/BridgeState.cs

  • Step 1: 实现 (纯数据容器,无逻辑,直接写)

创建 packages/windows-tray/Neta.WeChatBridge/BridgeState.cs:

namespace Neta.WeChatBridge;

/// <summary>启动时填充、后续只读的 bridge 状态快照。</summary>
public sealed class BridgeState
{
    public string WechatVersion { get; init; } = string.Empty;
    public string ProfileName   { get; init; } = string.Empty;
    public string Wxid          { get; init; } = string.Empty;
    public string Nickname      { get; init; } = string.Empty;
    public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;

    /// <summary>诊断:最近错误堆栈环形缓冲。</summary>
    public readonly DiagnosticsBuffer Diagnostics = new(capacity: 5);
}

public sealed class DiagnosticsBuffer
{
    private readonly int _capacity;
    private readonly LinkedList<DiagnosticsEntry> _entries = new();
    private readonly object _lock = new();

    public DiagnosticsBuffer(int capacity)
    {
        _capacity = capacity;
    }

    public void Record(string category, string message)
    {
        lock (_lock)
        {
            _entries.AddFirst(new DiagnosticsEntry(DateTimeOffset.UtcNow, category, message));
            while (_entries.Count > _capacity) _entries.RemoveLast();
        }
    }

    public IReadOnlyList<DiagnosticsEntry> Snapshot()
    {
        lock (_lock) return _entries.ToList();
    }
}

public sealed record DiagnosticsEntry(DateTimeOffset At, string Category, string Message);
  • Step 2: 编译
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj

Expected: Build succeeded。

  • Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/BridgeState.cs
git commit -m "feat(bridge): add BridgeState shared container"

Task 7: WeChatProcessLocator (进程 + 版本号)

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Uia/WeChatProcessLocator.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatProcessLocatorTests.cs

  • Step 1: 写失败测试 (用 SkippableFact,CI 没微信时 skip)

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatProcessLocatorTests.cs:

using Xunit;
using Neta.WeChatBridge.Uia;

public class WeChatProcessLocatorTests
{
    [SkippableFact]
    public void Locate_returns_null_when_wechat_not_running()
    {
        // 用随机不存在的进程名验证"未找到"分支
        var locator = new WeChatProcessLocator(processName: "__never_exists__");
        Assert.Null(locator.Locate());
    }

    [SkippableFact]
    public void Locate_returns_info_when_wechat_running()
    {
        var locator = new WeChatProcessLocator();
        var info = locator.Locate();
        Skip.If(info is null, "WeChat.exe not running on this machine (expected on CI)");

        Assert.NotNull(info);
        Assert.True(info!.Pid > 0);
        Assert.False(string.IsNullOrWhiteSpace(info.FileVersion));
        Assert.NotEqual(IntPtr.Zero, info.MainWindowHandle);
    }
}
  • Step 2: 运行测试确认失败
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~WeChatProcessLocatorTests"

Expected: 编译失败。

  • Step 3: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Uia/WeChatProcessLocator.cs:

using System.Diagnostics;

namespace Neta.WeChatBridge.Uia;

public sealed record WeChatProcessInfo(
    int Pid,
    string FileVersion,
    IntPtr MainWindowHandle,
    string MainWindowTitle
);

public sealed class WeChatProcessLocator
{
    private readonly string _processName;

    public WeChatProcessLocator(string processName = "WeChat")
    {
        _processName = processName;
    }

    public WeChatProcessInfo? Locate()
    {
        Process[] candidates;
        try
        {
            candidates = Process.GetProcessesByName(_processName);
        }
        catch
        {
            return null;
        }

        try
        {
            // 有多个时选 MainWindowHandle != 0 的
            var p = candidates.FirstOrDefault(x =>
                x.MainWindowHandle != IntPtr.Zero && !x.HasExited);
            if (p is null) return null;

            string version;
            try
            {
                version = p.MainModule?.FileVersionInfo.FileVersion ?? string.Empty;
            }
            catch
            {
                version = string.Empty;
            }

            return new WeChatProcessInfo(
                Pid:              p.Id,
                FileVersion:      version,
                MainWindowHandle: p.MainWindowHandle,
                MainWindowTitle:  p.MainWindowTitle
            );
        }
        finally
        {
            foreach (var c in candidates) c.Dispose();
        }
    }
}
  • Step 4: 运行测试确认通过
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~WeChatProcessLocatorTests"

Expected:

  • CI (没微信):Passed: 1, Skipped: 1 (Locate_returns_info skipped)

  • 本地 (装了微信):Passed: 2

  • Step 5: Commit

git add packages/windows-tray/Neta.WeChatBridge/Uia/WeChatProcessLocator.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatProcessLocatorTests.cs
git commit -m "feat(bridge): add WeChatProcessLocator for PID + version"

Task 8: WeChatWindow UIA 主窗口 + wxid/nickname 抓取

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Uia/WeChatWindow.cs
  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatWindowTests.cs

本 task 不实现消息事件订阅,仅做"窗口定位 + 抓身份"。UIA 交互部分在 CI 上 skip,只在本地开发机上手工验证。

  • Step 1: 写测试 (全部 SkippableFact)

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatWindowTests.cs:

using Xunit;
using Neta.WeChatBridge.Uia;

public class WeChatWindowTests
{
    [SkippableFact]
    public void Attach_returns_null_when_no_wechat()
    {
        var locator = new WeChatProcessLocator(processName: "__never_exists__");
        var info = locator.Locate();
        var win = WeChatWindow.Attach(info);
        Assert.Null(win);
    }

    [SkippableFact]
    public void Attach_populates_identity_when_wechat_present()
    {
        var locator = new WeChatProcessLocator();
        var info = locator.Locate();
        Skip.If(info is null, "WeChat not running");

        var win = WeChatWindow.Attach(info!);
        Skip.If(win is null, "UIA failed to attach — check WeChat window state");

        // Nickname/wxid 至少 wxid 字段应非空(UIA 取自设置页或 window title)
        Assert.False(string.IsNullOrWhiteSpace(win!.Nickname) &&
                     string.IsNullOrWhiteSpace(win.Wxid),
                     "nickname 或 wxid 至少一个应可读取");
    }
}
  • Step 2: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Uia/WeChatWindow.cs:

using System.Windows.Automation;

namespace Neta.WeChatBridge.Uia;

/// <summary>
/// 对 PC 微信主窗口的 UIA 抽象。
/// Phase A 只负责 Attach + 身份识别;消息读取 / 发送 / 切窗留给 Phase B。
/// </summary>
public sealed class WeChatWindow
{
    public AutomationElement Root { get; }
    public string Wxid { get; }
    public string Nickname { get; }

    private WeChatWindow(AutomationElement root, string wxid, string nickname)
    {
        Root = root;
        Wxid = wxid;
        Nickname = nickname;
    }

    /// <summary>通过已知进程信息定位 UIA root 并抓身份。失败返回 null。</summary>
    public static WeChatWindow? Attach(WeChatProcessInfo? info)
    {
        if (info is null || info.MainWindowHandle == IntPtr.Zero) return null;
        AutomationElement? root;
        try
        {
            root = AutomationElement.FromHandle(info.MainWindowHandle);
        }
        catch
        {
            return null;
        }
        if (root is null) return null;

        // MVP:wxid 暂无稳定 UIA 路径,先留空 (Phase B 再补);
        // nickname 优先读 window title (多数版本 window title == 当前登录人昵称)
        var nickname = info.MainWindowTitle?.Trim() ?? string.Empty;
        const string wxid = "";

        return new WeChatWindow(root, wxid, nickname);
    }
}
  • Step 3: 运行测试
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~WeChatWindowTests"

Expected:CI Skipped: 2,本地跑过至少 Attach_returns_null_when_no_wechat 通过。

  • Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/WeChatWindow.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Uia/WeChatWindowTests.cs
git commit -m "feat(bridge): add WeChatWindow UIA attach + identity"

Task 9: ChatBoxReader POC (只读不发送)

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Uia/ChatBoxReader.cs

本 task 提供一个最小可用的读取器,用途是 Phase A 手工冒烟验证"UIA 的确能读到消息"。Phase B 才做完整的多类型解析 (@ / 引用 / 图片)。这里只读"最后一条消息的 raw text"。

  • Step 1: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Uia/ChatBoxReader.cs:

using System.Windows.Automation;
using Neta.WeChatBridge.Config;

namespace Neta.WeChatBridge.Uia;

public sealed record ChatItemSnapshot(
    string SenderName,
    string Content,
    string RawText
);

public sealed class ChatBoxReader
{
    private readonly VersionProfile _profile;
    private readonly AutomationElement _root;

    public ChatBoxReader(AutomationElement root, VersionProfile profile)
    {
        _root = root;
        _profile = profile;
    }

    /// <summary>
    /// 读取当前激活聊天框的最后 N 条消息。Phase A POC:只抓 Name 文本。
    /// 失败返回空列表,不抛异常(UIA 调用时 WeChat 窗口可能被切走)。
    /// </summary>
    public IReadOnlyList<ChatItemSnapshot> ReadLatest(int maxItems = 20)
    {
        try
        {
            var messageList = _root.FindFirst(
                TreeScope.Descendants,
                new AndCondition(
                    new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List),
                    new PropertyCondition(AutomationElement.NameProperty, _profile.MessageListName)
                )
            );
            if (messageList is null) return Array.Empty<ChatItemSnapshot>();

            var items = messageList.FindAll(TreeScope.Children,
                new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem));

            var result = new List<ChatItemSnapshot>();
            var start = Math.Max(0, items.Count - maxItems);
            for (var i = start; i < items.Count; i++)
            {
                var item = items[i];
                var name = item.Current.Name?.Trim() ?? string.Empty;
                if (string.IsNullOrWhiteSpace(name)) continue;
                result.Add(new ChatItemSnapshot(
                    SenderName: string.Empty, // Phase B 细化
                    Content:    name,
                    RawText:    name));
            }
            return result;
        }
        catch
        {
            return Array.Empty<ChatItemSnapshot>();
        }
    }
}
  • Step 2: 编译
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj

Expected: Build succeeded。

  • Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/ChatBoxReader.cs
git commit -m "feat(bridge): add ChatBoxReader POC for latest items"

Phase 4 · HTTP 层

Task 10: TraySecretAuth middleware

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Http/TraySecretAuth.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Http/TraySecretAuthTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Http/TraySecretAuthTests.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Net;
using Xunit;
using Neta.WeChatBridge.Http;

public class TraySecretAuthTests
{
    private static IHost BuildHost(string expected)
    {
        return new HostBuilder()
            .ConfigureWebHost(webHost =>
            {
                webHost.UseTestServer();
                webHost.Configure(app =>
                {
                    app.UseMiddleware<TraySecretAuth>(expected);
                    app.Run(async ctx =>
                    {
                        ctx.Response.StatusCode = 200;
                        await ctx.Response.WriteAsync("ok");
                    });
                });
            })
            .Start();
    }

    [Fact]
    public async Task Missing_header_returns_401()
    {
        using var host = BuildHost("expected-sec");
        var client = host.GetTestClient();
        var resp = await client.GetAsync("/any");
        Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
    }

    [Fact]
    public async Task Wrong_header_returns_401()
    {
        using var host = BuildHost("expected-sec");
        var client = host.GetTestClient();
        client.DefaultRequestHeaders.Add("x-neta-tray-secret", "wrong");
        var resp = await client.GetAsync("/any");
        Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
    }

    [Fact]
    public async Task Correct_header_passes_through()
    {
        using var host = BuildHost("expected-sec");
        var client = host.GetTestClient();
        client.DefaultRequestHeaders.Add("x-neta-tray-secret", "expected-sec");
        var resp = await client.GetAsync("/any");
        Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
    }

    [Fact]
    public async Task Prefix_of_expected_secret_returns_401()
    {
        // 长度不同但前缀匹配 — 时序安全比较必须拒绝
        using var host = BuildHost("expected-sec-full");
        var client = host.GetTestClient();
        client.DefaultRequestHeaders.Add("x-neta-tray-secret", "expected-sec");
        var resp = await client.GetAsync("/any");
        Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
    }
}
  • Step 2: 运行测试确认失败
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~TraySecretAuthTests"

Expected: 编译失败。

  • Step 3: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Http/TraySecretAuth.cs:

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;

namespace Neta.WeChatBridge.Http;

public sealed class TraySecretAuth
{
    public const string HeaderName = "x-neta-tray-secret";

    private readonly RequestDelegate _next;
    private readonly byte[] _expectedBytes;

    public TraySecretAuth(RequestDelegate next, string expected)
    {
        _next = next;
        _expectedBytes = Encoding.UTF8.GetBytes(expected ?? string.Empty);
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        var ok = false;
        if (ctx.Request.Headers.TryGetValue(HeaderName, out var value) && value.Count > 0)
        {
            var provided = Encoding.UTF8.GetBytes(value.ToString());
            // CryptographicOperations.FixedTimeEquals 要求长度一致才比较,
            // 自动处理长度不同的情况(返回 false 且仍恒定时间)
            ok = CryptographicOperations.FixedTimeEquals(provided, _expectedBytes);
        }

        if (!ok)
        {
            ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await ctx.Response.WriteAsync("tray secret mismatch");
            return;
        }
        await _next(ctx);
    }
}

架构说明:string.Equals(Ordinal) 短路比较会泄漏长度 + 前缀信息,攻击者通过测量请求延迟可以逐字节推测 secret。改用 CryptographicOperations.FixedTimeEquals 走恒定时间比较,即使 loopback 场景下攻击面小,这也是安全基线。

  • Step 4: 运行测试确认通过
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~TraySecretAuthTests"

Expected:Passed: 4

  • Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/TraySecretAuth.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Http/TraySecretAuthTests.cs
git commit -m "feat(bridge): add TraySecretAuth middleware"

Task 11: HealthEndpoint + DiagEndpoint

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/HealthEndpoint.cs

  • Create: packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/DiagEndpoint.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Http/HealthEndpointTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Http/HealthEndpointTests.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Net.Http.Json;
using Xunit;
using Neta.WeChatBridge;
using Neta.WeChatBridge.Http.Endpoints;

public class HealthEndpointTests
{
    private static IHost BuildHost(BridgeState state)
    {
        return new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(s => s.AddSingleton(state).AddRouting());
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(e =>
                    {
                        HealthEndpoint.Map(e);
                        DiagEndpoint.Map(e);
                    });
                });
            })
            .Start();
    }

    [Fact]
    public async Task Health_returns_state_snapshot()
    {
        var state = new BridgeState
        {
            WechatVersion = "3.9.11.17",
            ProfileName   = "3.9.11.17",
            Wxid          = "wxid_test",
            Nickname      = "测试号",
        };
        using var host = BuildHost(state);
        var client = host.GetTestClient();
        var body = await client.GetFromJsonAsync<HealthResponse>("/health");

        Assert.NotNull(body);
        Assert.True(body!.Ok);
        Assert.Equal("3.9.11.17", body.WechatVersion);
        Assert.Equal("wxid_test", body.Wxid);
        Assert.Equal("测试号", body.Nickname);
    }

    [Fact]
    public async Task Diag_returns_uptime_and_recent_errors()
    {
        var state = new BridgeState { WechatVersion = "3.9.11.17" };
        state.Diagnostics.Record("uia", "demo error");
        using var host = BuildHost(state);
        var client = host.GetTestClient();
        var body = await client.GetFromJsonAsync<DiagResponse>("/diag");

        Assert.NotNull(body);
        Assert.NotEmpty(body!.RecentErrors);
        Assert.Equal("uia", body.RecentErrors[0].Category);
    }

    public sealed class HealthResponse
    {
        public bool Ok { get; set; }
        public string? Wxid { get; set; }
        public string? Nickname { get; set; }
        public string? WechatVersion { get; set; }
    }

    public sealed class DiagResponse
    {
        public string? WechatVersion { get; set; }
        public long UptimeSeconds { get; set; }
        public List<DiagError> RecentErrors { get; set; } = new();
    }

    public sealed class DiagError
    {
        public string Category { get; set; } = string.Empty;
        public string Message { get; set; } = string.Empty;
    }
}
  • Step 2: 运行测试确认失败
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~HealthEndpointTests"

Expected: 编译失败。

  • Step 3: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/HealthEndpoint.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Neta.WeChatBridge.Http.Endpoints;

public static class HealthEndpoint
{
    public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
    {
        app.MapGet("/health", (BridgeState state) => Results.Json(new
        {
            ok = true,
            wxid = state.Wxid,
            nickname = state.Nickname,
            wechatVersion = state.WechatVersion,
            profileName = state.ProfileName,
        }));
        return app;
    }
}

创建 packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/DiagEndpoint.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Neta.WeChatBridge.Http.Endpoints;

public static class DiagEndpoint
{
    public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
    {
        app.MapGet("/diag", (BridgeState state) =>
        {
            var uptime = (long)(DateTimeOffset.UtcNow - state.StartedAtUtc).TotalSeconds;
            return Results.Json(new
            {
                wechatVersion = state.WechatVersion,
                profileName = state.ProfileName,
                uptimeSeconds = uptime,
                recentErrors = state.Diagnostics.Snapshot().Select(e => new
                {
                    at = e.At,
                    category = e.Category,
                    message = e.Message,
                }),
            });
        });
        return app;
    }
}
  • Step 4: 运行测试
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~HealthEndpointTests"

Expected:Passed: 2

  • Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints \
        packages/windows-tray/Neta.WeChatBridge.Tests/Http/HealthEndpointTests.cs
git commit -m "feat(bridge): add /health and /diag endpoints"

Task 12: BridgeHttpServer 组装 Kestrel

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs

本 task 不写单测(Kestrel 本身启动 socket,测试要占真实端口,不稳定)。Program.cs 的集成 smoke 在手工验证清单里跑。

  • Step 1: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Neta.WeChatBridge.Http.Endpoints;

namespace Neta.WeChatBridge.Http;

public sealed class BridgeHttpServer
{
    public static WebApplication Build(
        int port,
        string traySecret,
        BridgeState state)
    {
        var builder = WebApplication.CreateBuilder();
        builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
        builder.Services.AddSingleton(state);
        builder.Services.AddRouting();

        var app = builder.Build();
        app.UseMiddleware<TraySecretAuth>(traySecret);
        app.UseRouting();

#pragma warning disable ASP0014
        app.UseEndpoints(endpoints =>
        {
            HealthEndpoint.Map(endpoints);
            DiagEndpoint.Map(endpoints);
        });
#pragma warning restore ASP0014
        return app;
    }
}
  • Step 2: 编译
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj

Expected: Build succeeded。

  • Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs
git commit -m "feat(bridge): add BridgeHttpServer Kestrel bootstrap"

Phase 5 · Backend Handshake + 主启动流程

Task 13: BackendClient (HandshakeAsync)

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs

  • Test: packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientTests.cs

  • Step 1: 写失败测试

创建 packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientTests.cs:

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
using Neta.WeChatBridge.Backend;

public class BackendClientTests
{
    private sealed class StubHandler : HttpMessageHandler
    {
        public HttpRequestMessage? LastRequest;
        public string? LastBody;
        public HttpResponseMessage Response { get; set; } =
            new(HttpStatusCode.OK) { Content = JsonContent.Create(new { channelId = 1 }) };

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken ct)
        {
            LastRequest = request;
            if (request.Content is not null)
                LastBody = await request.Content.ReadAsStringAsync(ct);
            return Response;
        }
    }

    [Fact]
    public async Task HandshakeAsync_posts_to_correct_url_with_secret_header()
    {
        var handler = new StubHandler();
        var http = new HttpClient(handler);
        var client = new BackendClient(http, "http://127.0.0.1:7071", "sec");

        var result = await client.HandshakeAsync("wxid_x", "小明", "3.9.11.17", "http://127.0.0.1:7702", default);

        Assert.True(result.Ok);
        Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
        Assert.Equal(
            "http://127.0.0.1:7071/open/netaclaw/channel/uia/handshake",
            handler.LastRequest.RequestUri!.ToString());
        Assert.Equal("sec", handler.LastRequest.Headers.GetValues("x-neta-tray-secret").First());

        using var doc = JsonDocument.Parse(handler.LastBody!);
        Assert.Equal("wxid_x", doc.RootElement.GetProperty("wxid").GetString());
        Assert.Equal("小明", doc.RootElement.GetProperty("nickname").GetString());
        Assert.Equal("3.9.11.17", doc.RootElement.GetProperty("wechatVersion").GetString());
        Assert.Equal("http://127.0.0.1:7702", doc.RootElement.GetProperty("bridgeBaseUrl").GetString());
    }

    [Fact]
    public async Task HandshakeAsync_returns_failure_on_non_2xx()
    {
        var handler = new StubHandler
        {
            Response = new HttpResponseMessage(HttpStatusCode.NotFound)
        };
        var client = new BackendClient(new HttpClient(handler), "http://127.0.0.1:7071", "sec");
        var result = await client.HandshakeAsync("w", "n", "3.9.11.17", "http://x", default);
        Assert.False(result.Ok);
        Assert.Contains("404", result.Error);
    }

    [Fact]
    public async Task HandshakeAsync_returns_failure_on_network_error()
    {
        var client = new BackendClient(
            new HttpClient(new ThrowingHandler()),
            "http://127.0.0.1:7071",
            "sec");
        var result = await client.HandshakeAsync("w", "n", "3.9.11.17", "http://x", default);
        Assert.False(result.Ok);
    }

    private sealed class ThrowingHandler : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
            => throw new HttpRequestException("connection refused");
    }
}
  • Step 2: 实现

创建 packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs:

using System.Net.Http.Json;

namespace Neta.WeChatBridge.Backend;

public sealed record HandshakeResult(bool Ok, string? Error = null, int? ChannelId = null);

public sealed class BackendClient
{
    private readonly HttpClient _http;
    private readonly string _baseUrl;
    private readonly string _secret;

    public BackendClient(HttpClient http, string baseUrl, string secret)
    {
        _http = http;
        _baseUrl = baseUrl.TrimEnd('/');
        _secret = secret;
    }

    public async Task<HandshakeResult> HandshakeAsync(
        string wxid, string nickname, string wechatVersion, string bridgeBaseUrl, CancellationToken ct)
    {
        try
        {
            using var req = new HttpRequestMessage(
                HttpMethod.Post,
                $"{_baseUrl}/open/netaclaw/channel/uia/handshake");
            req.Headers.Add("x-neta-tray-secret", _secret);
            req.Content = JsonContent.Create(new { wxid, nickname, wechatVersion, bridgeBaseUrl });
            using var resp = await _http.SendAsync(req, ct);
            if (!resp.IsSuccessStatusCode)
                return new HandshakeResult(false,
                    $"handshake HTTP {(int)resp.StatusCode}");
            return new HandshakeResult(true);
        }
        catch (Exception ex)
        {
            return new HandshakeResult(false, ex.Message);
        }
    }
}
  • Step 3: 运行测试
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BackendClientTests"

Expected:Passed: 3

  • Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs \
        packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientTests.cs
git commit -m "feat(bridge): add BackendClient with handshake"

Task 14: Program.cs 主启动流程

Files:

  • Modify: packages/windows-tray/Neta.WeChatBridge/Program.cs

  • Step 1: 替换 Program.cs

修改 packages/windows-tray/Neta.WeChatBridge/Program.cs:

using System.Text;
using Neta.WeChatBridge.Backend;
using Neta.WeChatBridge.Config;
using Neta.WeChatBridge.Http;
using Neta.WeChatBridge.Runtime;
using Neta.WeChatBridge.Uia;

namespace Neta.WeChatBridge;

public class Program
{
    public static async Task<int> Main(string[] args)
    {
        // 0. Windows 终端默认 GBK/CP936,强制 UTF-8 避免中文 wxid/nickname 乱码
        try { Console.OutputEncoding = Encoding.UTF8; } catch { /* headless ignore */ }

        // 1. 参数解析
        BridgeRuntimeInfo info;
        try
        {
            info = BridgeRuntimeInfo.Parse(args);
        }
        catch (ArgumentException ex)
        {
            Console.Error.WriteLine($"[bridge] 参数错误: {ex.Message}");
            return 2;
        }

        // 2. 加载版本 profile YAML (打包为 CopyToOutputDirectory)
        var profilePath = Path.Combine(
            AppContext.BaseDirectory, "Config", "VersionProfiles.yaml");
        if (!File.Exists(profilePath))
        {
            Console.Error.WriteLine(
                $"[bridge] 找不到版本 profile 文件: {profilePath}");
            return 3;
        }

        VersionProfileLoader loader;
        try
        {
            loader = VersionProfileLoader.LoadFromFile(profilePath);
        }
        catch (Exception ex) when (ex is IOException ||
                                   ex is YamlDotNet.Core.YamlException ||
                                   ex is InvalidOperationException)
        {
            Console.Error.WriteLine(
                $"[bridge] 版本 profile 文件解析失败: {ex.Message}");
            return 3;
        }

        // 3. 定位微信
        var wechatProcess = new WeChatProcessLocator().Locate();
        if (wechatProcess is null)
        {
            Console.Error.WriteLine("[bridge] 未找到运行中的 WeChat.exe");
            return 4;
        }

        // 4. 版本白名单
        VersionProfile? profile;
        try
        {
            profile = loader.MatchByVersion(wechatProcess.FileVersion);
        }
        catch (InvalidOperationException ex)
        {
            Console.Error.WriteLine($"[bridge] 版本 profile 匹配错误: {ex.Message}");
            return 5;
        }
        if (profile is null)
        {
            Console.Error.WriteLine(
                $"[bridge] 该 PC 微信版本未经适配: {wechatProcess.FileVersion}");
            return 6;
        }

        // 5. UIA 附着主窗口
        var window = WeChatWindow.Attach(wechatProcess);
        if (window is null)
        {
            Console.Error.WriteLine("[bridge] UIA 无法附着微信主窗口(窗口可能被最小化?)");
            return 7;
        }

        // 6. 初始化状态容器
        var state = new BridgeState
        {
            WechatVersion = wechatProcess.FileVersion,
            ProfileName   = profile.Version,
            Wxid          = window.Wxid,
            Nickname      = window.Nickname,
        };

        Console.WriteLine(
            $"[bridge] ready — version={state.WechatVersion} " +
            $"profile={state.ProfileName} nickname={state.Nickname} " +
            $"port={info.BridgePort}");

        // 7. 启 HTTP server
        var app = BridgeHttpServer.Build(info.BridgePort, info.TraySecret, state);

        // 8. 优雅退出
        var shutdown = new GracefulShutdown();
        shutdown.HookConsoleSignals();

        try
        {
            await app.StartAsync(shutdown.Token);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"[bridge] HTTP server 启动失败: {ex.Message}");
            return 8;
        }

        // 9. Handshake backend (失败仅 warn,不致命)
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
        var client = new BackendClient(http, info.BackendUrl, info.TraySecret);
        var bridgeBaseUrl = $"http://127.0.0.1:{info.BridgePort}";
        try
        {
            var hs = await client.HandshakeAsync(
                state.Wxid, state.Nickname, state.WechatVersion, bridgeBaseUrl, shutdown.Token);
            if (!hs.Ok)
            {
                state.Diagnostics.Record("handshake", hs.Error ?? "unknown");
                Console.Error.WriteLine($"[bridge] handshake 失败 (忽略,bridge 仍然运行): {hs.Error}");
            }
            else
            {
                Console.WriteLine("[bridge] handshake 成功");
            }
        }
        catch (OperationCanceledException)
        {
            // 关停期不视为错误
        }

        // 10. 等退出 — 不吞异常,Kestrel 运行期崩溃必须暴露
        try
        {
            await app.WaitForShutdownAsync(shutdown.Token);
        }
        catch (OperationCanceledException)
        {
            // 正常关停
        }
        catch (Exception ex)
        {
            state.Diagnostics.Record("http", ex.ToString());
            Console.Error.WriteLine($"[bridge] HTTP server 运行期错误: {ex.Message}");
            return 9;
        }
        Console.WriteLine($"[bridge] shutdown reason={shutdown.Reason ?? "normal"}");
        return 0;
    }
}
  • Step 2: 编译全量
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj
dotnet test  packages/windows-tray/Neta.WeChatBridge.Tests

Expected:全部 build succeeded + 所有现有 xUnit test pass (UIA test skip)。

  • Step 3: 手工冒烟 (Windows 开发机必做)

bridge 是 Windows-only 进程,本步骤所有命令都在 Windows PowerShell / cmd 上跑。%APPDATA% 自动展开;bash on Windows 也可用 $APPDATA

3.1 起一个最简后端 echo server(Phase A 不需要真后端实现 handshake,200 即可):

# Windows PowerShell:用 .NET 内置的简易 listener
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://127.0.0.1:7071/")
$listener.Start()
Write-Host "stub backend listening on 7071"
while ($listener.IsListening) {
    $ctx = $listener.GetContext()
    Write-Host "$($ctx.Request.HttpMethod) $($ctx.Request.Url.AbsolutePath)"
    $ctx.Response.StatusCode = 200
    $ctx.Response.OutputStream.Close()
}

(若需要返回 4xx 验证 handshake 失败分支,把 200 改成 404。)

3.2 启 bridge (新开一个 PowerShell):

cd packages\windows-tray\Neta.WeChatBridge
dotnet run -- `
  --tray-secret test-sec `
  --backend-url http://127.0.0.1:7071 `
  --data-dir $env:APPDATA\Neta `
  --bridge-port 7702

3.3 验证 endpoints(再开一个 PowerShell):

# 鉴权通过
curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/health
curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/diag

# 鉴权失败
curl.exe -i http://127.0.0.1:7702/health             # 期望 401
curl.exe -i -H "x-neta-tray-secret: wrong" http://127.0.0.1:7702/health   # 期望 401

3.4 验证输出:

  • /health 返回 { "ok": true, "wxid": "", "nickname": "<您的微信昵称>", "wechatVersion": "3.9.11.17", ... }
  • /diag 返回 uptime + 可能的 handshake 错误记录
  • 未带/错误 header 返回 401
  • bridge 控制台中文显示正常(不乱码)

3.5 验证错误路径:

  • 关掉 PC 微信 → bridge 启动应 exit code 4 + 打印"未找到运行中的 WeChat.exe"

  • 占用端口:(New-Object System.Net.Sockets.TcpListener("127.0.0.1", 7702)).Start() 后再启 bridge → exit code 8

  • Ctrl+C bridge → 控制台打印 shutdown reason=CTRL+C 且进程干净退出。

  • Step 4: Commit

git add packages/windows-tray/Neta.WeChatBridge/Program.cs
git commit -m "feat(bridge): wire up Phase A bootstrap (version check + UIA + HTTP + handshake)"

Task 15: README 与手工验证清单 (非代码,必要交付物)

Files:

  • Create: packages/windows-tray/Neta.WeChatBridge/README.md

  • Step 1: 创建 README

# Neta.WeChatBridge

Phase A MVP:PC 微信 UI Automation 桥接进程骨架。

## 运行前提

- Windows 10/11
- .NET 8 SDK (dev) 或 Runtime (prod)
- PC 微信 3.9.11.17 (或 3.9.12.x) 已登录、主窗口未最小化

## 命令行启动

\`\`\`
bridge.exe \
  --tray-secret <secret> \
  --backend-url http://127.0.0.1:<backend-port> \
  --data-dir %APPDATA%\Neta \
  --bridge-port 7702
\`\`\`

## HTTP 接口

所有 endpoint 必须带 `x-neta-tray-secret` header。

- `GET /health` — 基本健康信息
- `GET /diag` — 诊断 (uptime + 最近错误)

## Phase A 覆盖 / 不覆盖

| 能力 | 状态 |
|---|---|
| 版本白名单 | ✅ |
| 微信进程定位 | ✅ |
| UIA 主窗口附着 | ✅ (仅身份) |
| HTTP + 鉴权 | ✅ |
| Backend handshake | ✅ (失败不致命) |
| 消息事件订阅 | ❌ (Phase B) |
| 消息发送 | ❌ (Phase B) |
| 附件采集 | ❌ (Phase B) |
| Tray 自动拉起 | ❌ (Phase E) |

## 退出码

| code | 含义 |
|---|---|
| 0 | 正常 |
| 2 | CLI 参数错误 |
| 3 | 版本 profile 文件缺失或解析失败 |
| 4 | WeChat.exe 未运行 |
| 5 | 版本 profile 匹配错误 (循环继承 / 字段缺失) |
| 6 | 版本不在白名单 |
| 7 | UIA 附着失败 |
| 8 | HTTP server 启动失败 (端口被占等) |
| 9 | HTTP server 运行期崩溃 |
  • Step 2: Commit
git add packages/windows-tray/Neta.WeChatBridge/README.md
git commit -m "docs(bridge): add Phase A README with CLI + HTTP contract"

自检 (Self-Review)

1. Spec 覆盖:

Spec 章节 覆盖的 Task
"Neta.WeChatBridge 项目结构与部署 · .NET 项目骨架" Task 1
"启动参数与 IPC 凭证" Task 2
"Runtime/" (BridgeRuntimeInfo / GracefulShutdown) Task 2 + Task 3
"Config/" (VersionProfile + YAML) Task 4 + Task 5
"Uia/" (WeChatWindow 基础部分) Task 7 + Task 8
"Uia/ChatBoxReader" (POC 级) Task 9
"Http/TraySecretAuth + HealthEndpoint" Task 10 + Task 11
"Http/BridgeHttpServer" Task 12
"Backend/BackendClient" (handshake 子集) Task 13
"版本白名单 profile 热更新 + 未匹配退出" Task 14 (exit code 6)
"冷启动序列 · 步骤 1-3" (读版本 → 校验 → 定位 → handshake) Task 14

Spec 中未覆盖(已明确留给后续 Phase):

  • 事件订阅 (SessionListWatcher) → Phase B
  • 切窗队列 (RoomEventQueue) → Phase B
  • 发送/附件 (MessageSender / AttachmentExtractor) → Phase B
  • /rooms /send /enable-room /disable-room 端点 → Phase B
  • 入站消息 POST /open/netaclaw/channel/uia/inbound → Phase B (后端侧 Plan C)
  • BridgeProcessManager + 崩溃自愈 → Phase E
  • 安装包打包 bridge.exe → Phase E

2. Placeholder 扫描: 无 "TBD" / "TODO" / "implement later"。UIA 部分给了 Phase A 够用的最小实现 + 手工验证,没有占位骨架。

3. 类型一致性: BridgeRuntimeInfo/BridgeState/VersionProfile/HandshakeResult 在定义后的所有引用字段名一致。TraySecretAuth.HeaderName 常量被测试引用 (隐式,通过字符串 "x-neta-tray-secret")。

4. 跨 Plan 衔接: 本 plan 暴露的契约:

  • CLI 参数格式 → Plan 4 (Phase E) 的 BridgeProcessManager.BuildBridgeStartInfo 必须与之对齐
  • HTTP header x-neta-tray-secret + /health /diag endpoints → Plan 2 (Phase C) 后端轮询 health 时沿用
  • POST /open/netaclaw/channel/uia/handshake 的 body schema { wxid, nickname, wechatVersion } → Plan 2 的 controller 入参 DTO 必须一致
  • 退出码表 (0,2,3,4,5,6,7,8,9) → Plan 4 Tray BridgeProcessManager 读取非 0 退出码后弹气泡

5. 架构师交叉审查记录 (2026-05-09):

本 plan 在初稿后做了一轮系统架构师审查,修复了 7 个问题:

# 问题 修复
1 TraySecretAuthstring.Equals(Ordinal) 短路比较有时序侧信道 改用 CryptographicOperations.FixedTimeEquals + 加测试覆盖前缀子串场景 (Task 10)
2 Program.csLoadFromFile 未捕获 IO/YAML 异常 显式 try/catch IOException/YamlException → exit code 3 (Task 14)
3 WaitForShutdownAsync.ContinueWith(_=>{}) 静默吞 Kestrel 运行期异常 改成 try/catch + 写 Diagnostics + exit code 9 (Task 14)
4 .csprojUseWPF=true 拉 UIA refs 是语义错误 (会拉起 WPF 运行时) 改用 <FrameworkReference Include="Microsoft.WindowsDesktop.App" /> + 显式 <Reference Include="UIAutomationClient" /> (Task 1)
5 YAML 同时声明 EmbeddedResource + None 双重包含 只保留 None CopyToOutputDirectory (Task 5)
6 测试 csproj 引 Microsoft.AspNetCore.Mvc.Testing 过重 换成轻量 Microsoft.AspNetCore.TestHost (Task 1)
7 net8.0-windows 在 Linux CI 编译失败,plan 未声明 在"关键约束"节显式说明 Windows-only,沿用 Neta.Tray CI 过滤 (头部)
G1 Task 14 手工冒烟混用 Linux/Mac 工具,误导性 全改为 PowerShell 命令 + Windows 自带 HttpListener stub backend (Task 14)
G2 中文输出在 Windows 默认 GBK 终端乱码 Program.Main 第 0 步设 Console.OutputEncoding = Encoding.UTF8 (Task 14)

Execution Handoff

Plan A 写作完成,保存至 docs/superpowers/plans/2026-05-09-wechat-uia-a-bridge-skeleton.md

两种执行方式:

  1. Subagent-Driven (推荐) — 每个 Task 派独立 subagent,review 后再派下一个,快速迭代
  2. Inline Execution — 本会话内顺序跑 Task,带 checkpoint review

另外 3 份 plan (B/C/D) 尚未撰写。选择:

  • 继续写完 Plan B/C/D 后再挑一个开始执行
  • 先跑 Plan A (Backend 已有 Handshake 目标 URL 即可,即便真 endpoint 未实现 handshake 失败不阻塞 bridge)
  • 先跳去写 Plan C (Backend),因为它的 POST /open/netaclaw/channel/uia/handshake controller 会让 Plan A 的 handshake 跑通