1222 lines
42 KiB
Markdown
1222 lines
42 KiB
Markdown
# 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<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: 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
|