# 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 ` 等),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:` | | `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.csproj`、`BackendProcessManager.cs`、`TrayApplicationContext.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`: ```csharp 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): ```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`: ```xml Exe net8.0-windows enable enable Neta.WeChatBridge bridge ``` > **架构说明**:`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`: ```csharp 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`: ```xml net8.0-windows enable enable false ``` - [ ] **Step 4: 运行测试确认通过** ```bash 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: 把新项目加入解决方案 (若存在)** ```bash 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** ```bash 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`: ```csharp 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(() => 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(() => 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(() => 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: 运行测试确认失败** ```bash cd packages/windows-tray dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BridgeRuntimeInfoTests" ``` Expected: 编译失败,`BridgeRuntimeInfo` 不存在。 - [ ] **Step 3: 实现最小代码** 创建 `packages/windows-tray/Neta.WeChatBridge/Runtime/BridgeRuntimeInfo.cs`: ```csharp namespace Neta.WeChatBridge.Runtime; public sealed record BridgeRuntimeInfo( string TraySecret, string BackendUrl, string DataDir, int BridgePort ) { public static BridgeRuntimeInfo Parse(IReadOnlyList args) { var dict = new Dictionary(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 dict, string key) { if (!dict.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) throw new ArgumentException($"缺少必需参数 {key}"); return value; } } ``` - [ ] **Step 4: 运行测试确认通过** ```bash dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BridgeRuntimeInfoTests" ``` Expected:`Total tests: 8. Passed: 8` (1 + 1 + 1 + 4 [Theory] + 1)。 - [ ] **Step 5: Commit** ```bash 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`: ```csharp 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: 运行测试确认失败** ```bash dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~GracefulShutdownTests" ``` Expected: 编译失败。 - [ ] **Step 3: 实现最小代码** 创建 `packages/windows-tray/Neta.WeChatBridge/Runtime/GracefulShutdown.cs`: ```csharp 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(); } /// 注册 Ctrl+C / SIGTERM / AppDomain 退出 hook。 public void HookConsoleSignals() { Console.CancelKeyPress += (_, e) => { e.Cancel = true; // 不让进程立即终止 Trigger("CTRL+C"); }; AppDomain.CurrentDomain.ProcessExit += (_, _) => Trigger("ProcessExit"); } } ``` - [ ] **Step 4: 运行测试确认通过** ```bash dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~GracefulShutdownTests" ``` Expected:`Passed: 2`。 - [ ] **Step 5: Commit** ```bash 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`: ```csharp namespace Neta.WeChatBridge.Config; /// YAML 单条 profile (未解析继承前)。 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; } } /// YAML 文件根结构。 public sealed class VersionProfileDocument { public List Profiles { get; set; } = new(); } /// 继承解析后的最终 profile。所有字段 non-null。 public sealed record VersionProfile( string Version, string MainWindowClass, string SessionListName, string MessageListName, string SearchBoxName, string InputBoxName, string ImageCacheDir ); ``` - [ ] **Step 2: 编译** ```bash dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj ``` Expected: Build succeeded。 - [ ] **Step 3: Commit** ```bash 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`: ```csharp 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(() => { 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(() => loader.MatchByVersion("A")); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~VersionProfileLoaderTests" ``` Expected: 编译失败。 - [ ] **Step 3: 实现** 创建 `packages/windows-tray/Neta.WeChatBridge/Config/VersionProfileLoader.cs`: ```csharp using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace Neta.WeChatBridge.Config; public sealed class VersionProfileLoader { private readonly List _raw; private VersionProfileLoader(List 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(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()); 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 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`: ```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`,在 `` (Reference) 之后追加: ```xml ``` - [ ] **Step 4: 运行测试确认通过** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~VersionProfileLoaderTests" ``` Expected:`Passed: 7`。 - [ ] **Step 5: Commit** ```bash 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`: ```csharp namespace Neta.WeChatBridge; /// 启动时填充、后续只读的 bridge 状态快照。 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; /// 诊断:最近错误堆栈环形缓冲。 public readonly DiagnosticsBuffer Diagnostics = new(capacity: 5); } public sealed class DiagnosticsBuffer { private readonly int _capacity; private readonly LinkedList _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 Snapshot() { lock (_lock) return _entries.ToList(); } } public sealed record DiagnosticsEntry(DateTimeOffset At, string Category, string Message); ``` - [ ] **Step 2: 编译** ```bash dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj ``` Expected: Build succeeded。 - [ ] **Step 3: Commit** ```bash 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`: ```csharp 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: 运行测试确认失败** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~WeChatProcessLocatorTests" ``` Expected: 编译失败。 - [ ] **Step 3: 实现** 创建 `packages/windows-tray/Neta.WeChatBridge/Uia/WeChatProcessLocator.cs`: ```csharp 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: 运行测试确认通过** ```bash 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** ```bash 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`: ```csharp 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`: ```csharp using System.Windows.Automation; namespace Neta.WeChatBridge.Uia; /// /// 对 PC 微信主窗口的 UIA 抽象。 /// Phase A 只负责 Attach + 身份识别;消息读取 / 发送 / 切窗留给 Phase B。 /// 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; } /// 通过已知进程信息定位 UIA root 并抓身份。失败返回 null。 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: 运行测试** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~WeChatWindowTests" ``` Expected:CI `Skipped: 2`,本地跑过至少 `Attach_returns_null_when_no_wechat` 通过。 - [ ] **Step 4: Commit** ```bash 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`: ```csharp 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; } /// /// 读取当前激活聊天框的最后 N 条消息。Phase A POC:只抓 Name 文本。 /// 失败返回空列表,不抛异常(UIA 调用时 WeChat 窗口可能被切走)。 /// public IReadOnlyList 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(); var items = messageList.FindAll(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem)); var result = new List(); 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(); } } } ``` - [ ] **Step 2: 编译** ```bash dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj ``` Expected: Build succeeded。 - [ ] **Step 3: Commit** ```bash 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`: ```csharp 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(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: 运行测试确认失败** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~TraySecretAuthTests" ``` Expected: 编译失败。 - [ ] **Step 3: 实现** 创建 `packages/windows-tray/Neta.WeChatBridge/Http/TraySecretAuth.cs`: ```csharp 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: 运行测试确认通过** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~TraySecretAuthTests" ``` Expected:`Passed: 4`。 - [ ] **Step 5: Commit** ```bash 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`: ```csharp 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("/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("/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 RecentErrors { get; set; } = new(); } public sealed class DiagError { public string Category { get; set; } = string.Empty; public string Message { get; set; } = string.Empty; } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~HealthEndpointTests" ``` Expected: 编译失败。 - [ ] **Step 3: 实现** 创建 `packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/HealthEndpoint.cs`: ```csharp 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`: ```csharp 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: 运行测试** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~HealthEndpointTests" ``` Expected:`Passed: 2`。 - [ ] **Step 5: Commit** ```bash 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`: ```csharp 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(traySecret); app.UseRouting(); #pragma warning disable ASP0014 app.UseEndpoints(endpoints => { HealthEndpoint.Map(endpoints); DiagEndpoint.Map(endpoints); }); #pragma warning restore ASP0014 return app; } } ``` - [ ] **Step 2: 编译** ```bash dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj ``` Expected: Build succeeded。 - [ ] **Step 3: Commit** ```bash 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`: ```csharp 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 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 SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => throw new HttpRequestException("connection refused"); } } ``` - [ ] **Step 2: 实现** 创建 `packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs`: ```csharp 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 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: 运行测试** ```bash dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BackendClientTests" ``` Expected:`Passed: 3`。 - [ ] **Step 4: Commit** ```bash 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`: ```csharp 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 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: 编译全量** ```bash 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 即可): ```powershell # 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): ```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): ```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** ```bash 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** ```markdown # 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 \ --backend-url http://127.0.0.1: \ --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** ```bash 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 | `TraySecretAuth` 用 `string.Equals(Ordinal)` 短路比较有时序侧信道 | 改用 `CryptographicOperations.FixedTimeEquals` + 加测试覆盖前缀子串场景 (Task 10) | | 2 | `Program.cs` 调 `LoadFromFile` 未捕获 IO/YAML 异常 | 显式 try/catch IOException/YamlException → exit code 3 (Task 14) | | 3 | `WaitForShutdownAsync` 的 `.ContinueWith(_=>{})` 静默吞 Kestrel 运行期异常 | 改成 try/catch + 写 Diagnostics + exit code 9 (Task 14) | | 4 | `.csproj` 用 `UseWPF=true` 拉 UIA refs 是语义错误 (会拉起 WPF 运行时) | 改用 `` + 显式 `` (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 跑通