# 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: PASS,3 个测试文件全部通过。 - [ ] **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) | null = null; let shuttingDown = false; export function registerGracefulShutdown(handler: () => Promise) { 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) | null = null; registerStopHandler(handler: () => Promise) { 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 WinExe net8.0-windows true enable enable ``` ```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(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 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(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 net8.0 enable enable false ``` - [ ] **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 callback) : HttpMessageHandler { protected override Task 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 ### 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: PASS,Windows 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