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

42 KiB
Raw Permalink Blame History

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.yamlruntime-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.yamlruntime-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.tsautoOpenBrowser 读取 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 的失败测试

// 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:

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 契约
// 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');
}
// 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,
    },
  };
}
// 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 });
}
// 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';
}
// 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:

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: 提交本轮基础契约改动
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: 先写本机控制面与统一退出路径的失败测试

// 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-controller.test.ts -i

Expected: FAIL提示 RuntimeServicegraceful-shutdown.ts 不存在。

  • Step 3: 写最小实现,按现有 app 控制器风格提供 /base/runtime/status/base/runtime/stop
// 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();
}
// 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();
  }
}
// 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);
  }
}
// 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npx jest test/windows-tray/runtime-controller.test.ts -i

Expected: PASSRuntimeService 的状态读取与 stop 调用通过。

  • Step 5: 再跑一轮已有 Windows 基础设施测试,防止回归

Run:

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: 提交本机控制面改动
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”的启动边界

// 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj

Expected: FAIL提示工程或类型不存在。

  • Step 3: 写最小托盘工程,实现引导文件发现、状态请求和单实例骨架
<!-- 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>
// 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;
    }
}
// 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;
        }
    }
}
// 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;
}
// 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());
    }
}
<!-- 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj

Expected: PASSBuildBackendStartInfo 与引导文件解析测试通过。

  • Step 5: 提交托盘工程骨架
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 的失败测试

// 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo && dotnet test packages/windows-tray/Neta.Tray.Tests/Neta.Tray.Tests.csproj

Expected: FAIL提示状态请求尚未通过 runtime-info.jsonControlBaseUrl 驱动。

  • Step 3: 实现附着已有 backend、动态控制地址、无界面 shutdown 和菜单动作
// 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);
}
// 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:

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: 提交可工作托盘控制器
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

// 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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && node scripts/build-windows-installer.js

Expected: FAIL提示缺少 tray.exe

  • Step 3: 补齐 dotnet publish 与优雅卸载入口
// 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}`);
}
; 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
<!-- packages/backend/README.md -->
### 4. .NET 8 SDK

生成 `tray.exe` 需要安装 .NET 8 SDK。

```bash
winget install Microsoft.DotNet.SDK.8

Windows 托盘模式产物

完成构建后应同时看到:

packages/backend/build/pkg-output/backend.exe
packages/backend/build/tray-output/Neta.Tray.exe
packages/backend/build/installer-output/neta-setup.exe

Windows 托盘模式验证

npm run build:windows-installer

验证点:

  • tray.exe 能读取 config.yamlruntime-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.exeNeta.Tray.exeneta-setup.exe

  • Step 5: 提交打包与安装器改动
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:

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:

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:

cd /c/Users/Administrator/Desktop/code/Neta-monorepo/packages/backend && npm run build:windows-installer

Manual check list:

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: 做最终提交
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

  • TBDTODOimplement later
  • 每个测试步骤都包含具体测试代码、命令和预期结果
  • 每个代码步骤都给出目标文件与最小实现代码

Type consistency

  • backend 统一使用 RuntimeServicebuildRuntimeStatusRuntimeInfotriggerGracefulShutdown
  • tray 统一使用 BackendProcessManagerRuntimeInfoStoreStatusClientRuntimeBootstrapInfo
  • restart 只在 TrayApplicationContext 中作为 stop + spawn 编排动作出现,没有在 backend 中定义 restart API