GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-25-windows-tray-mode.md

1222 lines
42 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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