GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-25-windows-tray-mode.md
2026-05-20 21:39:12 +08:00

1222 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Windows Tray Mode 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:** 在现有 Windows 安装链路上新增独立 `tray.exe` 托盘控制层,同时让 `backend.exe` 暴露最小本机控制面,并通过本地引导文件闭环托盘对已运行后端的发现、控制和优雅退出。
**Architecture:** 保持 `backend.exe` 作为唯一运行时真源继续负责真实端口、数据目录、日志、单实例、ready 状态和优雅退出;新增仅本机可用的 `status/stop` 控制面,并在 `{dataDir}/runtime-info.json` 写入真实控制地址与本机 secret。`tray.exe` 只做 Windows 托盘壳层、读取 `config.yaml``runtime-info.json`、按“status > process > lock”顺序判断状态并编排 stop + spawn不承担业务逻辑也不成为第二个运行时中心。
**Tech Stack:** Midway.js 3.x、Koa、Jest + ts-jest、Node.js 22、Inno Setup 6、.NET 8 WinForms + xUnit
---
## File Structure
**Create**
- `packages/backend/src/comm/runtime-secret.ts` — 生成/校验托盘 secret校验 loopback 访问
- `packages/backend/src/comm/runtime-state.ts` — 统一构建运行时状态对象,输出真实 URL、目录、PID、ready 状态
- `packages/backend/src/comm/runtime-info.ts` — 读写 `{dataDir}/runtime-info.json`,作为托盘首次发现的本地引导文件
- `packages/backend/src/comm/graceful-shutdown.ts` — 统一 shutdown coordinator收口 stop endpoint、信号退出和锁清理
- `packages/backend/src/modules/base/controller/app/runtime.ts` — 以当前 `app` 控制器风格提供 `/base/runtime/status``/base/runtime/stop`
- `packages/backend/src/modules/base/service/runtime.ts` — 本机运行时控制服务,封装状态读取与停止调用
- `packages/backend/test/windows-tray/runtime-secret.test.ts` — secret 与 loopback 校验测试
- `packages/backend/test/windows-tray/runtime-state.test.ts` — 状态对象与 URL 生成测试
- `packages/backend/test/windows-tray/runtime-info.test.ts` — 本地引导文件读写测试
- `packages/backend/test/windows-tray/runtime-controller.test.ts` — 本机控制面服务测试
- `packages/windows-tray/Neta.Tray/Neta.Tray.csproj` — 托盘程序工程
- `packages/windows-tray/Neta.Tray/Program.cs` — 进程入口,支持正常托盘模式与 `--shutdown` 无界面停机模式
- `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs` — NotifyIcon、菜单、状态切换
- `packages/windows-tray/Neta.Tray/BackendProcessManager.cs` — 启动后端、探测已有进程、兜底结束残留进程
- `packages/windows-tray/Neta.Tray/RuntimeInfoStore.cs` — 读取 `config.yaml``runtime-info.json`
- `packages/windows-tray/Neta.Tray/StatusClient.cs` — 使用 `runtime-info.json` 提供的 `controlBaseUrl` 调用 status/stop
- `packages/windows-tray/Neta.Tray/SingleInstance.cs` — 托盘单实例
- `packages/windows-tray/Neta.Tray/Assets/neta.ico` — 托盘图标
- `packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj` — 托盘测试工程
- `packages/windows-tray/Neta.Tray.Tests/BackendProcessManagerTests.cs` — 后端启动参数与进程探测测试
- `packages/windows-tray/Neta.Tray.Tests/RuntimeInfoStoreTests.cs` — 引导文件发现与解析测试
- `packages/windows-tray/Neta.Tray.Tests/StatusClientTests.cs` — 状态解析与 stop 调用测试
**Modify**
- `packages/backend/bootstrap.js` — 解析 `--tray-secret``--no-browser`,把 secret 注入环境
- `packages/backend/src/configuration.ts` — 注册统一 shutdown coordinator、维护 ready/startTime、写入并清理 `runtime-info.json`
- `packages/backend/src/config/config.default.ts``autoOpenBrowser` 读取 `NETA_NO_BROWSER` 覆盖
- `packages/backend/scripts/build-windows-installer.js` — 在 `backend.exe` 之外追加 `dotnet publish` 产出 `tray.exe`
- `packages/backend/installer/setup.iss` — 安装 `tray.exe`,快捷方式和开机自启改指向 `tray.exe`,卸载先调用 `tray.exe --shutdown`
- `packages/backend/README.md` — 补充 `.NET 8 SDK` 打包前置条件、托盘模式产物和验证步骤
---
### Task 1: 建立 backend 的本地引导文件与基础契约
**Files:**
- Create: `packages/backend/src/comm/runtime-secret.ts`
- Create: `packages/backend/src/comm/runtime-state.ts`
- Create: `packages/backend/src/comm/runtime-info.ts`
- Create: `packages/backend/test/windows-tray/runtime-secret.test.ts`
- Create: `packages/backend/test/windows-tray/runtime-state.test.ts`
- Create: `packages/backend/test/windows-tray/runtime-info.test.ts`
- Modify: `packages/backend/bootstrap.js`
- Modify: `packages/backend/src/config/config.default.ts`
- [ ] **Step 1: 写 secret、状态对象和 runtime-info 的失败测试**
```ts
// packages/backend/test/windows-tray/runtime-secret.test.ts
import { isLoopbackAddress, validateRuntimeSecret } from '../../src/comm/runtime-secret';
describe('runtime-secret', () => {
it('accepts loopback ipv4/ipv6 addresses', () => {
expect(isLoopbackAddress('127.0.0.1')).toBe(true);
expect(isLoopbackAddress('::1')).toBe(true);
expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
expect(isLoopbackAddress('192.168.1.20')).toBe(false);
});
it('validates exact tray secret', () => {
expect(validateRuntimeSecret('neta-secret', 'neta-secret')).toBe(true);
expect(validateRuntimeSecret('neta-secret', 'wrong-secret')).toBe(false);
});
});
// packages/backend/test/windows-tray/runtime-state.test.ts
import { buildRuntimeStatus } from '../../src/comm/runtime-state';
describe('runtime-state', () => {
it('builds status payload from actual runtime inputs', () => {
const status = buildRuntimeStatus({
port: 8007,
ready: true,
controlBaseUrl: 'http://127.0.0.1:8007',
dataDir: 'D:/NetaData',
logDir: 'D:/NetaData/logs',
configDir: 'C:/Program Files/Neta',
pid: 1234,
startedAt: '2026-04-25T10:00:00.000Z',
});
expect(status.url).toBe('http://127.0.0.1:8007');
expect(status.controlBaseUrl).toBe('http://127.0.0.1:8007');
expect(status.paths.logDir).toBe('D:/NetaData/logs');
});
});
// packages/backend/test/windows-tray/runtime-info.test.ts
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { readRuntimeInfo, writeRuntimeInfo } from '../../src/comm/runtime-info';
describe('runtime-info', () => {
it('writes and reads bootstrap metadata from data dir', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-runtime-info-'));
const runtimeInfoPath = path.join(tempDir, 'runtime-info.json');
writeRuntimeInfo(runtimeInfoPath, {
pid: 321,
ready: true,
startedAt: '2026-04-25T10:00:00.000Z',
port: 8009,
url: 'http://127.0.0.1:8009',
controlBaseUrl: 'http://127.0.0.1:8009',
controlSecret: 'secret-1',
dataDir: tempDir,
logDir: path.join(tempDir, 'logs'),
configDir: 'C:/Program Files/Neta',
});
const loaded = readRuntimeInfo(runtimeInfoPath);
expect(loaded?.controlSecret).toBe('secret-1');
expect(loaded?.controlBaseUrl).toBe('http://127.0.0.1:8009');
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-secret.test.ts test/windows-tray/runtime-state.test.ts test/windows-tray/runtime-info.test.ts -i
```
Expected: FAIL提示 `runtime-secret.ts` / `runtime-state.ts` / `runtime-info.ts` 不存在。
- [ ] **Step 3: 写最小实现,建立 secret/状态/runtime-info 契约**
```ts
// packages/backend/src/comm/runtime-secret.ts
import { randomBytes, timingSafeEqual } from 'node:crypto';
export function isLoopbackAddress(value?: string | null) {
if (!value) return false;
const normalized = value.replace(/^::ffff:/, '');
return normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost';
}
export function validateRuntimeSecret(expected?: string, actual?: string | null) {
if (!expected || !actual) return false;
const left = Buffer.from(expected);
const right = Buffer.from(actual);
return left.length === right.length && timingSafeEqual(left, right);
}
export function resolveTraySecret(argv: string[], env: NodeJS.ProcessEnv) {
const index = argv.indexOf('--tray-secret');
if (index > -1 && argv[index + 1]) return argv[index + 1];
return env.NETA_TRAY_SECRET || '';
}
export function createRuntimeSecret() {
return randomBytes(24).toString('hex');
}
```
```ts
// packages/backend/src/comm/runtime-state.ts
export interface RuntimeStatus {
pid: number;
ready: boolean;
startedAt: string;
port: number;
url: string;
controlBaseUrl: string;
paths: {
dataDir: string;
logDir: string;
configDir: string;
};
}
export function buildRuntimeStatus(input: {
pid: number;
ready: boolean;
startedAt: string;
port: number;
controlBaseUrl: string;
dataDir: string;
logDir: string;
configDir: string;
}): RuntimeStatus {
return {
pid: input.pid,
ready: input.ready,
startedAt: input.startedAt,
port: input.port,
url: `http://127.0.0.1:${input.port}`,
controlBaseUrl: input.controlBaseUrl,
paths: {
dataDir: input.dataDir,
logDir: input.logDir,
configDir: input.configDir,
},
};
}
```
```ts
// packages/backend/src/comm/runtime-info.ts
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface RuntimeInfo {
pid: number;
ready: boolean;
startedAt: string;
port: number;
url: string;
controlBaseUrl: string;
controlSecret: string;
dataDir: string;
logDir: string;
configDir: string;
}
export function readRuntimeInfo(filePath: string): RuntimeInfo | null {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as RuntimeInfo;
}
export function writeRuntimeInfo(filePath: string, info: RuntimeInfo) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf8');
}
export function clearRuntimeInfo(filePath: string) {
fs.rmSync(filePath, { force: true });
}
```
```js
// packages/backend/bootstrap.js
const { resolveTraySecret } = require('./dist/comm/runtime-secret');
const traySecret = resolveTraySecret(process.argv, process.env);
if (traySecret) {
process.env.NETA_TRAY_SECRET = traySecret;
}
if (process.argv.includes('--no-browser')) {
process.env.NETA_NO_BROWSER = 'true';
}
```
```ts
// packages/backend/src/config/config.default.ts
const browserDisabledByCli = process.env.NETA_NO_BROWSER === 'true';
export default {
// ...existing config...
autoOpenBrowser:
!browserDisabledByCli && ((global as any).__NETA_EXTERNAL_CONFIG__?.autoOpenBrowser ?? false),
} as MidwayConfig;
```
- [ ] **Step 4: 重新运行测试确认通过**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-secret.test.ts test/windows-tray/runtime-state.test.ts test/windows-tray/runtime-info.test.ts -i
```
Expected: PASS3 个测试文件全部通过。
- [ ] **Step 5: 提交本轮基础契约改动**
```bash
git add packages/backend/src/comm/runtime-secret.ts packages/backend/src/comm/runtime-state.ts packages/backend/src/comm/runtime-info.ts packages/backend/test/windows-tray/runtime-secret.test.ts packages/backend/test/windows-tray/runtime-state.test.ts packages/backend/test/windows-tray/runtime-info.test.ts packages/backend/bootstrap.js packages/backend/src/config/config.default.ts
git commit -m "feat: add tray runtime bootstrap contracts"
```
### Task 2: 在 backend 中落地最小本机控制面与统一退出路径
**Files:**
- Create: `packages/backend/src/comm/graceful-shutdown.ts`
- Create: `packages/backend/src/modules/base/service/runtime.ts`
- Create: `packages/backend/src/modules/base/controller/app/runtime.ts`
- Create: `packages/backend/test/windows-tray/runtime-controller.test.ts`
- Modify: `packages/backend/src/configuration.ts`
- [ ] **Step 1: 先写本机控制面与统一退出路径的失败测试**
```ts
// packages/backend/test/windows-tray/runtime-controller.test.ts
import { buildRuntimeStatus } from '../../src/comm/runtime-state';
import { RuntimeService } from '../../src/modules/base/service/runtime';
describe('RuntimeService', () => {
it('returns status from runtime truth source', async () => {
const service = new RuntimeService();
service.getRuntimeStatus = jest.fn().mockResolvedValue(
buildRuntimeStatus({
port: 8008,
ready: true,
controlBaseUrl: 'http://127.0.0.1:8008',
dataDir: 'D:/NetaData',
logDir: 'D:/NetaData/logs',
configDir: 'C:/Program Files/Neta',
pid: 999,
startedAt: '2026-04-25T10:00:00.000Z',
})
);
await expect(service.getRuntimeStatus()).resolves.toMatchObject({
ready: true,
port: 8008,
controlBaseUrl: 'http://127.0.0.1:8008',
});
});
it('calls shutdown coordinator once', async () => {
const service = new RuntimeService();
const stop = jest.fn().mockResolvedValue(undefined);
service.registerStopHandler(stop);
await service.stopRuntime();
expect(stop).toHaveBeenCalledTimes(1);
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-controller.test.ts -i
```
Expected: FAIL提示 `RuntimeService``graceful-shutdown.ts` 不存在。
- [ ] **Step 3: 写最小实现,按现有 `app` 控制器风格提供 `/base/runtime/status` 与 `/base/runtime/stop`**
```ts
// packages/backend/src/comm/graceful-shutdown.ts
let shutdownHandler: (() => Promise<void>) | null = null;
let shuttingDown = false;
export function registerGracefulShutdown(handler: () => Promise<void>) {
shutdownHandler = handler;
}
export async function triggerGracefulShutdown() {
if (shuttingDown) return;
if (!shutdownHandler) throw new Error('未注册停止回调');
shuttingDown = true;
await shutdownHandler();
}
```
```ts
// packages/backend/src/modules/base/service/runtime.ts
import { Provide, App } from '@midwayjs/core';
import { IMidwayApplication } from '@midwayjs/core';
import * as path from 'node:path';
import { pDataPath } from '../../../comm/path';
import { buildRuntimeStatus } from '../../../comm/runtime-state';
import { triggerGracefulShutdown } from '../../../comm/graceful-shutdown';
import { readRuntimeInfo } from '../../../comm/runtime-info';
@Provide()
export class RuntimeService {
@App()
app: IMidwayApplication;
private stopHandler: (() => Promise<void>) | null = null;
registerStopHandler(handler: () => Promise<void>) {
this.stopHandler = handler;
}
async getRuntimeStatus() {
const dataDir = pDataPath();
const runtimeInfoPath = path.join(dataDir, 'runtime-info.json');
const runtimeInfo = readRuntimeInfo(runtimeInfoPath);
if (runtimeInfo) {
return buildRuntimeStatus({
pid: runtimeInfo.pid,
ready: runtimeInfo.ready,
startedAt: runtimeInfo.startedAt,
port: runtimeInfo.port,
controlBaseUrl: runtimeInfo.controlBaseUrl,
dataDir: runtimeInfo.dataDir,
logDir: runtimeInfo.logDir,
configDir: runtimeInfo.configDir,
});
}
const port = this.app.getConfig('koa.port');
return buildRuntimeStatus({
pid: process.pid,
ready: (global as any).__NETA_RUNTIME_READY__ === true,
startedAt: (global as any).__NETA_RUNTIME_STARTED_AT__,
port,
controlBaseUrl: `http://127.0.0.1:${port}`,
dataDir,
logDir: path.join(dataDir, 'logs'),
configDir: path.dirname(process.env.NETA_CONFIG_PATH || process.execPath),
});
}
async stopRuntime() {
if (this.stopHandler) {
await this.stopHandler();
return;
}
await triggerGracefulShutdown();
}
}
```
```ts
// packages/backend/src/modules/base/controller/app/runtime.ts
import { Provide, Inject, Get, Post, Headers } from '@midwayjs/core';
import { CoolController, CoolTag, CoolUrlTag, BaseController, TagTypes } from '@cool-midway/core';
import { Context } from '@midwayjs/koa';
import { RuntimeService } from '../../service/runtime';
import { isLoopbackAddress, validateRuntimeSecret } from '../../../../comm/runtime-secret';
@CoolUrlTag()
@Provide()
@CoolController()
export class BaseAppRuntimeController extends BaseController {
@Inject()
runtimeService: RuntimeService;
@Inject()
ctx: Context;
private assertLocalRuntimeAccess(secret?: string | null) {
const remoteAddress = this.ctx.ip || this.ctx.request.ip || this.ctx.req.socket.remoteAddress;
if (!isLoopbackAddress(remoteAddress)) {
throw new Error('仅允许本机访问');
}
if (!validateRuntimeSecret(process.env.NETA_TRAY_SECRET, secret)) {
throw new Error('托盘密钥无效');
}
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/status', { summary: '本机运行时状态' })
async status(@Headers('x-neta-tray-secret') secret: string) {
this.assertLocalRuntimeAccess(secret);
return this.ok(await this.runtimeService.getRuntimeStatus());
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/stop', { summary: '本机停止服务' })
async stop(@Headers('x-neta-tray-secret') secret: string) {
this.assertLocalRuntimeAccess(secret);
await this.runtimeService.stopRuntime();
return this.ok(true);
}
}
```
```ts
// packages/backend/src/configuration.ts
import { registerGracefulShutdown, triggerGracefulShutdown } from './comm/graceful-shutdown';
import { writeRuntimeInfo, clearRuntimeInfo } from './comm/runtime-info';
async onReady() {
const dataDir = pDataPath();
const lockPath = path.join(dataDir, 'neta.lock');
const runtimeInfoPath = path.join(dataDir, 'runtime-info.json');
const configDir = path.dirname(process.env.NETA_CONFIG_PATH || process.execPath);
const port = this.app.getConfig('koa.port');
const startedAt = new Date().toISOString();
(global as any).__NETA_RUNTIME_STARTED_AT__ = startedAt;
(global as any).__NETA_RUNTIME_READY__ = false;
acquireRuntimeLock(lockPath);
registerGracefulShutdown(async () => {
if ((global as any).__NETA_RUNTIME_READY__ !== false) {
(global as any).__NETA_RUNTIME_READY__ = false;
}
clearRuntimeInfo(runtimeInfoPath);
releaseRuntimeLock(lockPath);
setTimeout(() => process.exit(0), 50);
});
process.on('SIGINT', () => void triggerGracefulShutdown());
process.on('SIGTERM', () => void triggerGracefulShutdown());
// ... existing restoreConnectedRunners() ...
(global as any).__NETA_RUNTIME_READY__ = true;
writeRuntimeInfo(runtimeInfoPath, {
pid: process.pid,
ready: true,
startedAt,
port,
url: `http://127.0.0.1:${port}`,
controlBaseUrl: `http://127.0.0.1:${port}/base/runtime`,
controlSecret: process.env.NETA_TRAY_SECRET || '',
dataDir,
logDir: path.join(dataDir, 'logs'),
configDir,
});
}
```
- [ ] **Step 4: 运行测试确认控制面通过**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-controller.test.ts -i
```
Expected: PASS`RuntimeService` 的状态读取与 stop 调用通过。
- [ ] **Step 5: 再跑一轮已有 Windows 基础设施测试,防止回归**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-installer/config-loader.test.ts test/windows-installer/data-dir.test.ts test/windows-installer/runtime-lock.test.ts -i
```
Expected: PASS现有 installer/data-dir/runtime-lock 测试不回归。
- [ ] **Step 6: 提交本机控制面改动**
```bash
git add packages/backend/src/comm/graceful-shutdown.ts packages/backend/src/modules/base/service/runtime.ts packages/backend/src/modules/base/controller/app/runtime.ts packages/backend/test/windows-tray/runtime-controller.test.ts packages/backend/src/configuration.ts
git commit -m "feat: add local runtime control and shutdown coordinator"
```
### Task 3: 建立独立 `tray.exe` 工程与引导文件发现能力
**Files:**
- Create: `packages/windows-tray/Neta.Tray/Neta.Tray.csproj`
- Create: `packages/windows-tray/Neta.Tray/Program.cs`
- Create: `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs`
- Create: `packages/windows-tray/Neta.Tray/BackendProcessManager.cs`
- Create: `packages/windows-tray/Neta.Tray/RuntimeInfoStore.cs`
- Create: `packages/windows-tray/Neta.Tray/StatusClient.cs`
- Create: `packages/windows-tray/Neta.Tray/SingleInstance.cs`
- Create: `packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj`
- Create: `packages/windows-tray/Neta.Tray.Tests/BackendProcessManagerTests.cs`
- Create: `packages/windows-tray/Neta.Tray.Tests/RuntimeInfoStoreTests.cs`
- Create: `packages/windows-tray/Neta.Tray.Tests/StatusClientTests.cs`
- [ ] **Step 1: 先写托盘核心测试,固定“读 config.yaml -> 找 data.dir -> 读 runtime-info.json”的启动边界**
```csharp
// packages/windows-tray/Neta.Tray.Tests/RuntimeInfoStoreTests.cs
using Xunit;
using Neta.Tray;
public class RuntimeInfoStoreTests
{
[Fact]
public void Reads_data_dir_from_config_yaml_and_runtime_info()
{
var config = "server:\n port: 8003\ndata:\n dir: \"D:\\\\NetaData\"\n";
var runtimeInfo = "{\"pid\":123,\"ready\":true,\"controlBaseUrl\":\"http://127.0.0.1:8011/base/runtime\",\"controlSecret\":\"secret-abc\"}";
var resolved = RuntimeInfoStore.ParseConfigAndRuntimeInfo(config, runtimeInfo);
Assert.Equal(@"D:\NetaData", resolved.DataDir);
Assert.Equal("http://127.0.0.1:8011/base/runtime", resolved.ControlBaseUrl);
Assert.Equal("secret-abc", resolved.ControlSecret);
}
}
// packages/windows-tray/Neta.Tray.Tests/BackendProcessManagerTests.cs
using Xunit;
using Neta.Tray;
public class BackendProcessManagerTests
{
[Fact]
public void BuildBackendStartInfo_passes_tray_secret_and_no_browser()
{
var info = BackendProcessManager.BuildBackendStartInfo(
@"C:\Program Files\Neta\backend.exe",
"tray-secret-123"
);
Assert.Equal(@"C:\Program Files\Neta\backend.exe", info.FileName);
Assert.Contains("--tray-secret", info.ArgumentList);
Assert.Contains("tray-secret-123", info.ArgumentList);
Assert.Contains("--no-browser", info.ArgumentList);
Assert.True(info.UseShellExecute == false);
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj
```
Expected: FAIL提示工程或类型不存在。
- [ ] **Step 3: 写最小托盘工程,实现引导文件发现、状态请求和单实例骨架**
```xml
<!-- packages/windows-tray/Neta.Tray/Neta.Tray.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
```
```csharp
// packages/windows-tray/Neta.Tray/RuntimeInfoStore.cs
using System.Text.Json;
using YamlDotNet.RepresentationModel;
namespace Neta.Tray;
public sealed class RuntimeBootstrapInfo
{
public string DataDir { get; set; } = string.Empty;
public string ControlBaseUrl { get; set; } = string.Empty;
public string ControlSecret { get; set; } = string.Empty;
public int Pid { get; set; }
public bool Ready { get; set; }
}
public static class RuntimeInfoStore
{
public static RuntimeBootstrapInfo ParseConfigAndRuntimeInfo(string configYaml, string runtimeInfoJson)
{
using var input = new StringReader(configYaml);
var yaml = new YamlStream();
yaml.Load(input);
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var dataDir = ((YamlScalarNode)((YamlMappingNode)root.Children[new YamlScalarNode("data")]).Children[new YamlScalarNode("dir")]).Value!;
var runtime = JsonSerializer.Deserialize<RuntimeBootstrapInfo>(runtimeInfoJson)!;
runtime.DataDir = dataDir.Replace("\\\\", "\\");
return runtime;
}
}
```
```csharp
// packages/windows-tray/Neta.Tray/BackendProcessManager.cs
using System.Diagnostics;
namespace Neta.Tray;
public sealed class BackendProcessManager
{
public static ProcessStartInfo BuildBackendStartInfo(string backendExePath, string traySecret)
{
var info = new ProcessStartInfo(backendExePath)
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(backendExePath)!
};
info.ArgumentList.Add("--tray-secret");
info.ArgumentList.Add(traySecret);
info.ArgumentList.Add("--no-browser");
return info;
}
public Process Start(string backendExePath, string traySecret)
{
return Process.Start(BuildBackendStartInfo(backendExePath, traySecret))
?? throw new InvalidOperationException("backend.exe 启动失败");
}
public bool IsBackendProcessAlive(int pid)
{
try
{
var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch
{
return false;
}
}
}
```
```csharp
// packages/windows-tray/Neta.Tray/StatusClient.cs
using System.Net.Http.Json;
namespace Neta.Tray;
public sealed class StatusClient(HttpClient httpClient)
{
public async Task<RuntimeStatusDto?> GetStatusAsync(RuntimeBootstrapInfo runtime, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, runtime.ControlBaseUrl + "/status");
request.Headers.Add("x-neta-tray-secret", runtime.ControlSecret);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<RuntimeEnvelope>(cancellationToken: cancellationToken);
return envelope?.Data;
}
public async Task StopAsync(RuntimeBootstrapInfo runtime, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, runtime.ControlBaseUrl + "/stop");
request.Headers.Add("x-neta-tray-secret", runtime.ControlSecret);
using var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
}
}
public sealed class RuntimeEnvelope
{
public RuntimeStatusDto? Data { get; set; }
}
public sealed class RuntimeStatusDto
{
public bool Ready { get; set; }
public int Port { get; set; }
public string Url { get; set; } = string.Empty;
public string ControlBaseUrl { get; set; } = string.Empty;
}
```
```csharp
// packages/windows-tray/Neta.Tray/Program.cs
namespace Neta.Tray;
internal static class Program
{
[STAThread]
static void Main(string[] args)
{
if (args.Contains("--shutdown"))
{
ShutdownCommand.Run();
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
}
}
```
```xml
<!-- packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</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="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Neta.Tray\Neta.Tray.csproj" />
</ItemGroup>
</Project>
```
- [ ] **Step 4: 运行 .NET 单元测试确认通过**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj
```
Expected: PASS`BuildBackendStartInfo` 与引导文件解析测试通过。
- [ ] **Step 5: 提交托盘工程骨架**
```bash
git add packages/windows-tray/Neta.Tray packages/windows-tray/Neta.Tray.Tests
git commit -m "feat: add windows tray bootstrap discovery"
```
### Task 4: 把托盘补成可工作的状态控制器与无界面停机入口
**Files:**
- Modify: `packages/windows-tray/Neta.Tray/BackendProcessManager.cs`
- Modify: `packages/windows-tray/Neta.Tray/RuntimeInfoStore.cs`
- Modify: `packages/windows-tray/Neta.Tray/StatusClient.cs`
- Modify: `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs`
- Modify: `packages/windows-tray/Neta.Tray/SingleInstance.cs`
- Modify: `packages/windows-tray/Neta.Tray/Program.cs`
- Modify: `packages/windows-tray/Neta.Tray.Tests/RuntimeInfoStoreTests.cs`
- Modify: `packages/windows-tray/Neta.Tray.Tests/StatusClientTests.cs`
- [ ] **Step 1: 先补“status > process > lock”与无界面 shutdown 的失败测试**
```csharp
// packages/windows-tray/Neta.Tray.Tests/StatusClientTests.cs
using System.Net;
using System.Net.Http;
using System.Text;
using Xunit;
public class StatusClientTests
{
[Fact]
public async Task GetStatusAsync_reads_runtime_url_from_runtime_info_base_url()
{
var runtime = new RuntimeBootstrapInfo
{
ControlBaseUrl = "http://127.0.0.1:8011/base/runtime",
ControlSecret = "secret"
};
var handler = new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"data\":{\"ready\":true,\"port\":8011,\"url\":\"http://127.0.0.1:8011\",\"controlBaseUrl\":\"http://127.0.0.1:8011/base/runtime\"}}", Encoding.UTF8, "application/json")
});
var client = new StatusClient(new HttpClient(handler));
var status = await client.GetStatusAsync(runtime, CancellationToken.None);
Assert.NotNull(status);
Assert.Equal(8011, status!.Port);
Assert.Equal("http://127.0.0.1:8011", status.Url);
}
}
file sealed class FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> callback) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(callback(request));
}
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj
```
Expected: FAIL提示状态请求尚未通过 `runtime-info.json``ControlBaseUrl` 驱动。
- [ ] **Step 3: 实现附着已有 backend、动态控制地址、无界面 shutdown 和菜单动作**
```csharp
// packages/windows-tray/Neta.Tray/TrayApplicationContext.cs
namespace Neta.Tray;
public sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly BackendProcessManager _processManager = new();
private readonly StatusClient _statusClient = new(new HttpClient());
private RuntimeBootstrapInfo? _runtime;
private RuntimeStatusDto? _lastStatus;
public TrayApplicationContext()
{
_notifyIcon = new NotifyIcon
{
Text = "Neta",
Visible = true,
ContextMenuStrip = new ContextMenuStrip()
};
_notifyIcon.ContextMenuStrip.Items.Add("打开系统", null, async (_, _) => await OpenSystemAsync());
_notifyIcon.ContextMenuStrip.Items.Add("重启服务", null, async (_, _) => await RestartBackendAsync());
_notifyIcon.ContextMenuStrip.Items.Add("停止服务", null, async (_, _) => await StopBackendAsync());
_notifyIcon.ContextMenuStrip.Items.Add("打开日志目录", null, (_, _) => OpenLogs());
_notifyIcon.ContextMenuStrip.Items.Add("打开配置目录", null, (_, _) => OpenConfigDir());
_notifyIcon.ContextMenuStrip.Items.Add("退出程序", null, async (_, _) => await ExitAllAsync());
_ = EnsureBackendAttachedAsync();
}
private async Task EnsureBackendAttachedAsync()
{
_runtime = RuntimeInfoStore.LoadFromInstalledConfig(AppContext.BaseDirectory);
if (_runtime is not null)
{
var status = await _statusClient.GetStatusAsync(_runtime, CancellationToken.None);
if (status is { Ready: true })
{
_lastStatus = status;
return;
}
if (_processManager.IsBackendProcessAlive(_runtime.Pid))
{
throw new TimeoutException("backend.exe 已存在但控制面不可达");
}
}
var traySecret = Guid.NewGuid().ToString("N");
var backendExe = Path.Combine(AppContext.BaseDirectory, "backend.exe");
_processManager.Start(backendExe, traySecret);
_runtime = RuntimeInfoStore.WaitUntilAvailable(AppContext.BaseDirectory, TimeSpan.FromSeconds(20));
_lastStatus = await _statusClient.GetStatusAsync(_runtime, CancellationToken.None)
?? throw new InvalidOperationException("backend.exe 启动后未返回运行状态");
}
private async Task OpenSystemAsync()
{
if (_lastStatus is null) await EnsureBackendAttachedAsync();
Process.Start(new ProcessStartInfo(_lastStatus!.Url) { UseShellExecute = true });
}
private async Task RestartBackendAsync()
{
await StopBackendAsync();
_runtime = null;
_lastStatus = null;
await EnsureBackendAttachedAsync();
}
private async Task StopBackendAsync()
{
if (_runtime is null) return;
await _statusClient.StopAsync(_runtime, CancellationToken.None);
_lastStatus = null;
}
private async Task ExitAllAsync()
{
await StopBackendAsync();
_notifyIcon.Visible = false;
ExitThread();
}
private void OpenLogs() => RuntimeInfoStore.OpenLogs(AppContext.BaseDirectory);
private void OpenConfigDir() => RuntimeInfoStore.OpenConfigDir(AppContext.BaseDirectory);
}
```
```csharp
// packages/windows-tray/Neta.Tray/Program.cs
namespace Neta.Tray;
internal static class Program
{
[STAThread]
static void Main(string[] args)
{
if (args.Contains("--shutdown"))
{
ShutdownCommand.Run(AppContext.BaseDirectory);
return;
}
if (!SingleInstance.TryAcquire())
{
ShutdownCommand.OpenExistingInstance();
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
}
}
```
- [ ] **Step 4: 重新运行 .NET 测试确认通过**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj
```
Expected: PASS状态解析、动态控制地址使用和 stop + spawn 编排测试通过。
- [ ] **Step 5: 提交可工作托盘控制器**
```bash
git add packages/windows-tray/Neta.Tray packages/windows-tray/Neta.Tray.Tests
git commit -m "feat: implement tray runtime attachment and shutdown"
```
### Task 5: 接入 Windows 打包链路与优雅卸载入口
**Files:**
- Modify: `packages/backend/scripts/build-windows-installer.js`
- Modify: `packages/backend/installer/setup.iss`
- Modify: `packages/backend/README.md`
- [ ] **Step 1: 先写构建断言,确保产物不再只有 backend.exe**
```js
// packages/backend/scripts/build-windows-installer.js
const trayOutputDir = path.join(backendDir, 'build', 'tray-output');
const trayExePath = path.join(trayOutputDir, 'tray.exe');
if (!fs.existsSync(trayExePath)) {
throw new Error(`缺少托盘产物: ${trayExePath}`);
}
```
- [ ] **Step 2: 运行安装器构建确认当前失败**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node scripts/build-windows-installer.js
```
Expected: FAIL提示缺少 `tray.exe`
- [ ] **Step 3: 补齐 `dotnet publish` 与优雅卸载入口**
```js
// packages/backend/scripts/build-windows-installer.js
const repoDir = path.resolve(backendDir, '..', '..');
const trayProject = path.join(repoDir, 'packages', 'windows-tray', 'Neta.Tray', 'Neta.Tray.csproj');
const trayOutputDir = path.join(backendDir, 'build', 'tray-output');
if (!fs.existsSync(path.join(outputDir, 'backend.exe'))) {
cp.execFileSync('node', ['scripts/pkg-build.js'], { cwd: backendDir, stdio: 'inherit' });
}
fs.rmSync(trayOutputDir, { recursive: true, force: true });
cp.execFileSync(
'dotnet',
[
'publish',
trayProject,
'-c', 'Release',
'-r', 'win-x64',
'--self-contained', 'true',
'/p:PublishSingleFile=true',
'/p:PublishTrimmed=false',
'-o', trayOutputDir,
],
{ cwd: repoDir, stdio: 'inherit' }
);
const publishedTrayExePath = path.join(trayOutputDir, 'Neta.Tray.exe');
if (!fs.existsSync(publishedTrayExePath)) {
throw new Error(`缺少托盘产物: ${publishedTrayExePath}`);
}
```
```iss
; packages/backend/installer/setup.iss
[Files]
Source: "..\build\pkg-output\backend.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\build\tray-output\Neta.Tray.exe"; DestDir: "{app}"; DestName: "tray.exe"; Flags: ignoreversion
Source: "config.default.yaml"; DestDir: "{app}"; DestName: "config.yaml"; Flags: onlyifdoesntexist
[Icons]
Name: "{autodesktop}\Neta"; Filename: "{app}\tray.exe"; WorkingDir: "{app}"
Name: "{group}\Neta"; Filename: "{app}\tray.exe"; WorkingDir: "{app}"
[Run]
Filename: "{app}\tray.exe"; Description: "启动 Neta"; Flags: nowait postinstall skipifsilent
[Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Neta"; ValueData: '"{app}\tray.exe"'; Flags: uninsdeletevalue
[UninstallRun]
Filename: "{app}\tray.exe"; Parameters: "--shutdown"; Flags: runhidden skipifdoesntexist
Filename: "taskkill"; Parameters: "/IM tray.exe /F"; Flags: runhidden skipifdoesntexist
Filename: "taskkill"; Parameters: "/IM backend.exe /F"; Flags: runhidden skipifdoesntexist
```
```md
<!-- packages/backend/README.md -->
### 4. .NET 8 SDK
生成 `tray.exe` 需要安装 .NET 8 SDK。
```bash
winget install Microsoft.DotNet.SDK.8
```
### Windows 托盘模式产物
完成构建后应同时看到:
```text
packages/backend/build/pkg-output/backend.exe
packages/backend/build/tray-output/Neta.Tray.exe
packages/backend/build/installer-output/neta-setup.exe
```
### Windows 托盘模式验证
```bash
npm run build:windows-installer
```
验证点:
- `tray.exe` 能读取 `config.yaml``runtime-info.json`
- 卸载时优先调用 `tray.exe --shutdown`
- 仅在优雅停机失败时才兜底 `taskkill`
```
- [ ] **Step 4: 运行打包命令确认新链路通过**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run build:windows-installer
```
Expected: PASS同时产出 `backend.exe``Neta.Tray.exe``neta-setup.exe`
- [ ] **Step 5: 提交打包与安装器改动**
```bash
git add packages/backend/scripts/build-windows-installer.js packages/backend/installer/setup.iss packages/backend/README.md
git commit -m "feat: package windows tray installer"
```
### Task 6: 最终验证与回归检查
**Files:**
- Test: `packages/backend/test/windows-tray/runtime-secret.test.ts`
- Test: `packages/backend/test/windows-tray/runtime-state.test.ts`
- Test: `packages/backend/test/windows-tray/runtime-info.test.ts`
- Test: `packages/backend/test/windows-tray/runtime-controller.test.ts`
- Test: `packages/windows-tray/Neta.Tray.Tests/BackendProcessManagerTests.cs`
- Test: `packages/windows-tray/Neta.Tray.Tests/RuntimeInfoStoreTests.cs`
- Test: `packages/windows-tray/Neta.Tray.Tests/StatusClientTests.cs`
- [ ] **Step 1: 跑 backend 相关单元测试**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-secret.test.ts test/windows-tray/runtime-state.test.ts test/windows-tray/runtime-info.test.ts test/windows-tray/runtime-controller.test.ts test/windows-installer/config-loader.test.ts test/windows-installer/data-dir.test.ts test/windows-installer/runtime-lock.test.ts -i
```
Expected: PASSWindows tray 与 installer 基础设施测试全部通过。
- [ ] **Step 2: 跑托盘 .NET 单元测试**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj
```
Expected: PASS托盘引导发现、状态轮询和进程编排测试全部通过。
- [ ] **Step 3: 做一次 Windows 手工 smoke**
Run:
```bash
cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run build:windows-installer
```
Manual check list:
```text
1. 安装 neta-setup.exe 后,桌面快捷方式指向 tray.exe
2. 双击快捷方式后,系统托盘出现图标
3. tray.exe 能通过 config.yaml + runtime-info.json 附着或拉起 backend.exe
4. “打开系统”能打开 backend 状态接口返回的真实 URL
5. “停止服务”后backend.exe 退出,托盘仍在
6. “重启服务”后backend.exe 被重新拉起
7. “打开日志目录 / 打开配置目录”路径正确
8. “退出程序”后tray.exe 与 backend.exe 都退出
9. 卸载时先尝试 tray.exe --shutdown再兜底强杀
10. 卸载后程序目录清理正常,数据目录按现有策略保留
```
Expected: 所有 10 项都满足。
- [ ] **Step 4: 做最终提交**
```bash
git add packages/backend packages/windows-tray
git commit -m "feat: add windows tray mode"
```
---
## Self-Review
### Spec coverage
- “backend 是唯一运行时真源” → Task 1、Task 2
- “最小控制面仅 status/stop” → Task 2
- “首次发现依赖 runtime-info.json” → Task 1、Task 3、Task 4
- “tray 负责 stop + spawn不承担 restart API” → Task 3、Task 4
- “状态判断优先级status > process > lock” → Task 4
- “installer 入口改为 tray.exe卸载优先 graceful shutdown” → Task 5
- “.NET 8 SDK 为新前置条件” → Task 5
- “数据库安全、ProgramData、Windows Service、自动更新不做” → 计划中未新增对应任务,保持收口
### Placeholder scan
-`TBD``TODO``implement later`
- 每个测试步骤都包含具体测试代码、命令和预期结果
- 每个代码步骤都给出目标文件与最小实现代码
### Type consistency
- backend 统一使用 `RuntimeService``buildRuntimeStatus``RuntimeInfo``triggerGracefulShutdown`
- tray 统一使用 `BackendProcessManager``RuntimeInfoStore``StatusClient``RuntimeBootstrapInfo`
- `restart` 只在 `TrayApplicationContext` 中作为 stop + spawn 编排动作出现,没有在 backend 中定义 restart API