2175 lines
70 KiB
Markdown
2175 lines
70 KiB
Markdown
# 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.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
|
|
<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`:
|
|
|
|
```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
|
|
<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: 运行测试确认通过**
|
|
|
|
```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<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: 运行测试确认失败**
|
|
|
|
```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<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: 运行测试确认通过**
|
|
|
|
```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();
|
|
}
|
|
|
|
/// <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: 运行测试确认通过**
|
|
|
|
```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;
|
|
|
|
/// <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: 编译**
|
|
|
|
```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<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: 运行测试确认失败**
|
|
|
|
```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<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`:
|
|
|
|
```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) 之后追加:
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<!-- 只用 CopyToOutputDirectory,程序运行时从 BaseDirectory 读文件;
|
|
不用 EmbeddedResource (避免 SDK "Duplicate item" 警告/冲突) -->
|
|
<None Include="Config\VersionProfiles.yaml" CopyToOutputDirectory="PreserveNewest" />
|
|
</ItemGroup>
|
|
```
|
|
|
|
- [ ] **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;
|
|
|
|
/// <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: 编译**
|
|
|
|
```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;
|
|
|
|
/// <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: 运行测试**
|
|
|
|
```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;
|
|
}
|
|
|
|
/// <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: 编译**
|
|
|
|
```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<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: 运行测试确认失败**
|
|
|
|
```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<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: 运行测试确认失败**
|
|
|
|
```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<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: 编译**
|
|
|
|
```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<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`:
|
|
|
|
```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<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: 运行测试**
|
|
|
|
```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<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: 编译全量**
|
|
|
|
```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 <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**
|
|
|
|
```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 运行时) | 改用 `<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 跑通
|