chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,683 @@
/**
* 单元测试: dependencies
*
* 测试依赖管理相关函数
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import path from "path";
// Mock electron
vi.mock("electron", () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === "home") return "/mock/home";
return "/mock/appdata";
}),
},
}));
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock fs - will be configured in each test as needed
const mockExistsSync = vi.fn(() => true);
const mockReaddirSync = vi.fn(() => []);
vi.mock("fs", () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
}));
describe("dependencies", () => {
const { app } = require("electron");
beforeEach(() => {
vi.clearAllMocks();
// Reset module cache to get fresh module state
vi.resetModules();
// Reset fs mocks to default behavior
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
// Set up test environment
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
});
process.env.HOME = "/mock/home";
process.env.USER = "testuser";
process.env.USERNAME = "testuser";
process.env.LANG = "en_US.UTF-8";
process.env.PATH =
"/usr/bin:/bin:/usr/local/bin:/mock/home/.nvm/versions/node/v20.0.0/bin";
process.env.NVM_DIR = undefined;
});
afterEach(() => {
delete process.env.HOME;
delete process.env.USER;
delete process.env.USERNAME;
delete process.env.LANG;
delete process.env.PATH;
delete process.env.NVM_DIR;
});
describe("Mirror Config", () => {
it("should return default mirror config", async () => {
const { getMirrorConfig } = await import("../system/dependencies");
const config = getMirrorConfig();
expect(config).toEqual({
npmRegistry: "https://registry.npmmirror.com/",
uvIndexUrl: "https://mirrors.aliyun.com/pypi/simple/",
});
});
it("should update npm registry", async () => {
const { setMirrorConfig, getMirrorConfig } =
await import("../system/dependencies");
setMirrorConfig({ npmRegistry: "https://registry.npmjs.org/" });
const config = getMirrorConfig();
expect(config.npmRegistry).toBe("https://registry.npmjs.org/");
});
it("should update uv index url", async () => {
const { setMirrorConfig, getMirrorConfig } =
await import("../system/dependencies");
setMirrorConfig({ uvIndexUrl: "https://pypi.org/simple/" });
const config = getMirrorConfig();
expect(config.uvIndexUrl).toBe("https://pypi.org/simple/");
});
it("should update both registry configs", async () => {
const { setMirrorConfig, getMirrorConfig } =
await import("../system/dependencies");
setMirrorConfig({
npmRegistry: "https://npm.taobao.org/",
uvIndexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple/",
});
const config = getMirrorConfig();
expect(config.npmRegistry).toBe("https://npm.taobao.org/");
expect(config.uvIndexUrl).toBe(
"https://pypi.tuna.tsinghua.edu.cn/simple/",
);
});
});
describe("getAppEnv", () => {
it("should include app-specific paths in PATH", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
// 与实现一致getAppEnv 用 process.platform 决定分隔符darwin 在 beforeEach 已 mock
const pathSep = process.platform === "win32" ? ";" : ":";
const pathEntries = env.PATH?.split(pathSep) || [];
const home = path.join("/mock", "home");
const appData = path.join(home, ".qimingclaw");
expect(pathEntries).toContain(path.join(appData, "node_modules", ".bin"));
expect(pathEntries).toContain(path.join(appData, "bin"));
});
it("should set NODE_PATH to app node_modules", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const expected = path.join("/mock", "home", ".qimingclaw", "node_modules");
expect(env.NODE_PATH).toBe(expected);
});
it("should set npm config registry", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
expect(env.NPM_CONFIG_REGISTRY).toBe("https://registry.npmmirror.com/");
});
it("should set npm userconfig to app directory", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const expected = path.join("/mock", "home", ".qimingclaw", ".npmrc");
expect(env.NPM_CONFIG_USERCONFIG).toBe(expected);
});
it("should disable npm update notifier", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
expect(env.NO_UPDATE_NOTIFIER).toBe("true");
});
it("should set UV_INDEX_URL to mirror config", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
expect(env.UV_INDEX_URL).toBe("https://mirrors.aliyun.com/pypi/simple/");
});
it("should disable uv auto-install", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
expect(env.UV_NO_INSTALL).toBe("1");
});
it("should preserve HOME environment variable", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
expect(env.HOME).toBe("/mock/home");
});
it("should not include undefined values", async () => {
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
// All values should be strings, no undefined
Object.values(env).forEach((val) => {
expect(typeof val).toBe("string");
});
});
});
describe("getSetupRequiredDependencies", () => {
it("should have uv as bundled dependency", async () => {
const { getSetupRequiredDependencies } =
await import("../system/dependencies");
const deps = getSetupRequiredDependencies();
const uvDep = deps.find((d) => d.name === "uv");
expect(uvDep).toBeDefined();
expect(uvDep?.type).toBe("bundled");
expect(uvDep?.required).toBe(true);
expect(uvDep?.minVersion).toBe("0.5.0");
});
it("should have qiming-file-server as npm-local dependency", async () => {
const { getSetupRequiredDependencies } =
await import("../system/dependencies");
const deps = getSetupRequiredDependencies();
const fileServerDep = deps.find((d) => d.name === "qiming-file-server");
expect(fileServerDep).toBeDefined();
expect(fileServerDep?.type).toBe("npm-local");
expect(fileServerDep?.required).toBe(true);
expect(fileServerDep?.binName).toBe("qiming-file-server");
});
it("should have qimingcode as bundled dependency", async () => {
const { getSetupRequiredDependencies } =
await import("../system/dependencies");
const deps = getSetupRequiredDependencies();
const agentDep = deps.find((d) => d.name === "qimingcode");
expect(agentDep).toBeDefined();
expect(agentDep?.type).toBe("bundled");
expect(agentDep?.required).toBe(true);
});
it("should not require qiming-mcp-stdio-proxy as npm-local dependency", async () => {
const { getSetupRequiredDependencies } =
await import("../system/dependencies");
const deps = getSetupRequiredDependencies();
const mcpDep = deps.find((d) => d.name === "qiming-mcp-stdio-proxy");
expect(mcpDep).toBeUndefined();
});
it("should have all required dependencies", async () => {
const { getSetupRequiredDependencies } =
await import("../system/dependencies");
const deps = getSetupRequiredDependencies();
const requiredDeps = deps.filter((d) => d.required);
expect(requiredDeps.length).toBeGreaterThan(0);
requiredDeps.forEach((dep) => {
expect(dep.required).toBe(true);
});
});
});
describe("getSystemPaths", () => {
it("should filter out project-level node_modules paths", async () => {
process.env.PATH = "/usr/bin:/project/node_modules/.bin:/usr/local/bin";
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Should not contain project node_modules
expect(
pathEntries.find(
(p) => p.includes("node_modules/.bin") && !p.includes(".qimingclaw"),
),
).toBeUndefined();
// Should contain system paths
expect(
pathEntries.some((p) => p === "/usr/bin" || p === "/usr/local/bin"),
).toBe(true);
});
// macOS-specific test - skip on Windows
const testOnMacOS = process.platform === "darwin" ? it : it.skip;
testOnMacOS(
"should include NVM node versions in PATH on macOS",
async () => {
process.env.PATH = "/usr/bin";
process.env.NVM_DIR = "/mock/home/.nvm";
// Mock NVM versions directory
mockExistsSync.mockImplementation((p: string) => {
if (p.includes(".nvm/versions/node")) return true;
if (p.includes("v20.10.0/bin")) return true;
return true;
});
mockReaddirSync.mockImplementation((p: string) => {
if (p.includes(".nvm/versions/node")) {
return ["v18.0.0", "v20.10.0", "v16.5.0"];
}
return [];
});
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Should include the latest NVM version bin path
expect(pathEntries.some((p) => p.includes("v20.10.0/bin"))).toBe(true);
},
);
testOnMacOS(
"should include fnm node versions in PATH on macOS",
async () => {
process.env.PATH = "/usr/bin";
// Mock fnm directory
mockExistsSync.mockImplementation((p: string) => {
if (p.includes(".fnm")) return true;
if (p.includes("node-installations")) return true;
if (p.includes("v22.0.0/installation/bin")) return true;
return true;
});
mockReaddirSync.mockImplementation((p: string) => {
if (p.includes("node-installations")) {
return ["v20.0.0", "v22.0.0"];
}
return [];
});
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Should include the latest fnm version bin path
expect(
pathEntries.some((p) => p.includes("v22.0.0/installation/bin")),
).toBe(true);
},
);
it("should include Homebrew paths on macOS", async () => {
process.env.PATH = "/usr/bin";
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Should include common Homebrew paths
expect(pathEntries).toContain("/usr/local/bin");
expect(pathEntries).toContain("/opt/homebrew/bin");
});
it("should include Windows npm path on Windows", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
process.env.PATH = "C:\\Windows\\System32";
process.env.USERPROFILE = "C:\\Users\\testuser";
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(";") || [];
// Should include Windows npm path
expect(
pathEntries.some(
(p) =>
p.includes("AppData\\Roaming\\npm") ||
p.includes("AppData/Roaming/npm"),
),
).toBe(true);
});
it("should preserve system npm paths (not filter out NVM/fnm)", async () => {
process.env.PATH =
"/usr/bin:/mock/home/.nvm/versions/node/v20.0.0/bin:/mock/home/.fnm/node-installations/v18.0.0/installation/bin";
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// NVM and fnm paths should be preserved (not filtered out)
expect(pathEntries.some((p) => p.includes(".nvm/versions"))).toBe(true);
expect(pathEntries.some((p) => p.includes(".fnm"))).toBe(true);
});
it("should not include paths that do not exist", async () => {
process.env.PATH = "/usr/bin";
// Only some paths exist
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/local/bin") return false;
if (p === "/opt/homebrew/bin") return false;
return true;
});
mockReaddirSync.mockReturnValue([]);
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Non-existent paths should not be included
expect(pathEntries).not.toContain("/usr/local/bin");
expect(pathEntries).not.toContain("/opt/homebrew/bin");
});
testOnMacOS(
"should select latest semantic version from NVM versions",
async () => {
process.env.PATH = "/usr/bin";
process.env.NVM_DIR = "/mock/home/.nvm";
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((p: string) => {
if (p.includes(".nvm/versions/node")) {
// Test version sorting: v10.0.0 should not be selected over v9.0.0 by string sort
return ["v9.0.0", "v10.0.0", "v8.15.0"];
}
return [];
});
const { getAppEnv } = await import("../system/dependencies");
const env = getAppEnv();
const pathEntries = env.PATH?.split(":") || [];
// Should select v10.0.0 (highest version), not v9.0.0 (alphabetically last)
expect(pathEntries.some((p) => p.includes("v10.0.0/bin"))).toBe(true);
expect(pathEntries.some((p) => p.includes("v9.0.0/bin"))).toBe(false);
},
);
it("should cache system paths result", async () => {
process.env.PATH = "/usr/bin";
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
// Import module twice
const module1 = await import("../system/dependencies");
const env1 = module1.getAppEnv();
const module2 = await import("../system/dependencies");
const env2 = module2.getAppEnv();
// Both should return the same cached result
expect(env1.PATH).toBe(env2.PATH);
});
});
describe("installNpmPackage queue serialization", () => {
// Track spawn calls and allow controlling process behavior per call
let spawnCalls: Array<{ command: string; args: string[] }>;
let spawnResolvers: Array<{ resolve: (code: number) => void }>;
beforeEach(() => {
vi.resetModules();
spawnCalls = [];
spawnResolvers = [];
// Mock child_process.spawn to return controllable fake processes
vi.doMock("child_process", () => {
const { EventEmitter } = require("events");
return {
spawn: vi.fn((command: string, args: string[]) => {
spawnCalls.push({ command, args });
const proc = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = null;
proc.stdout = new EventEmitter();
const entry = {
resolve: (code: number) => {
proc.emit("close", code);
},
};
spawnResolvers.push(entry);
return proc;
}),
execSync: vi.fn(),
};
});
// Extend existing fs mock with methods needed by installNpmPackage
vi.doMock("fs", () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(() => "{}"),
rmSync: vi.fn(),
}));
});
it("should serialize concurrent installNpmPackage calls", async () => {
const { installNpmPackage } = await import("../system/dependencies");
// Fire 3 installs concurrently
const p1 = installNpmPackage("pkg-a");
const p2 = installNpmPackage("pkg-b");
const p3 = installNpmPackage("pkg-c");
// Only the first spawn should have been called (queue serializes)
await vi.waitFor(() => expect(spawnCalls.length).toBe(1));
expect(spawnCalls[0].args).toContain("pkg-a");
// pkg-b and pkg-c should NOT have spawned yet
expect(spawnCalls.length).toBe(1);
// Complete first install
spawnResolvers[0].resolve(0);
await vi.waitFor(() => expect(spawnCalls.length).toBe(2));
expect(spawnCalls[1].args).toContain("pkg-b");
// Complete second install
spawnResolvers[1].resolve(0);
await vi.waitFor(() => expect(spawnCalls.length).toBe(3));
expect(spawnCalls[2].args).toContain("pkg-c");
// Complete third install
spawnResolvers[2].resolve(0);
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
expect(r1.success).toBe(true);
expect(r2.success).toBe(true);
expect(r3.success).toBe(true);
});
it("should continue queue after a failed install", async () => {
const { installNpmPackage } = await import("../system/dependencies");
const p1 = installNpmPackage("fail-pkg");
const p2 = installNpmPackage("ok-pkg");
await vi.waitFor(() => expect(spawnCalls.length).toBe(1));
// Fail the first install (non-zero exit code)
spawnResolvers[0].resolve(1);
// Second install should still proceed
await vi.waitFor(() => expect(spawnCalls.length).toBe(2));
expect(spawnCalls[1].args).toContain("ok-pkg");
// Complete second install successfully
spawnResolvers[1].resolve(0);
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.success).toBe(false);
expect(r2.success).toBe(true);
});
});
describe("bundled resources", () => {
const mockLog = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
});
describe("getNodeBinPath", () => {
// 归一化路径分隔符,使断言在 Windows CI反斜杠与 macOS/Linux正斜杠下均通过
const normalized = (p: string | null) => (p ?? "").replace(/\\/g, "/");
it("should return correct path on macOS x64", async () => {
vi.resetModules();
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: "x64",
writable: true,
configurable: true,
});
const { getNodeBinPath } = await import("../system/dependencies");
const result = getNodeBinPath();
// Use toContain so both relative path (app) and absolute path (CI) pass
expect(result).toContain("darwin-x64");
expect(normalized(result)).toContain("bin/node");
expect(normalized(result)).not.toContain("node.exe");
});
it("should return correct path on macOS arm64", async () => {
vi.resetModules();
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: "arm64",
writable: true,
configurable: true,
});
const { getNodeBinPath } = await import("../system/dependencies");
const result = getNodeBinPath();
// Use toContain so both relative path (app) and absolute path (CI) pass
expect(result).toContain("darwin-arm64");
expect(normalized(result)).toContain("bin/node");
});
it("should return correct path on Windows x64", async () => {
vi.resetModules();
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: "x64",
writable: true,
configurable: true,
});
const { getNodeBinPath } = await import("../system/dependencies");
const result = getNodeBinPath();
// Use toContain so both relative path (app) and absolute path (CI) pass
expect(result).toContain("win32-x64");
expect(normalized(result)).toContain("bin/node.exe");
});
it("should return correct path on Linux x64", async () => {
vi.resetModules();
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
Object.defineProperty(process, "arch", {
value: "x64",
writable: true,
configurable: true,
});
const { getNodeBinPath } = await import("../system/dependencies");
const result = getNodeBinPath();
expect(result).toContain("linux-x64");
expect(normalized(result)).toContain("bin/node");
});
it("should return null when node binary does not exist", async () => {
mockExistsSync.mockReturnValue(false);
const { getNodeBinPath } = await import("../system/dependencies");
const result = getNodeBinPath();
expect(result).toBeNull();
});
it("should log warning when node binary does not exist", async () => {
mockExistsSync.mockReturnValue(false);
const { getNodeBinPath } = await import("../system/dependencies");
// Mock electron-log after importing
const log = await import("electron-log");
Object.assign(log.default, mockLog);
getNodeBinPath();
expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining("Bundled Node.js not found"),
);
expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining("prepare:node"),
);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
/**
* 单元测试: deviceId
*
* 测试设备 ID 生成、缓存与异常回退逻辑
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'crypto';
// Mock electron-log
vi.mock('electron-log', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock node-machine-id
const mockMachineIdSync = vi.fn();
vi.mock('node-machine-id', () => ({
machineIdSync: (...args: unknown[]) => mockMachineIdSync(...args),
}));
// Mock os.hostname
const mockHostname = vi.fn(() => 'mock-hostname');
vi.mock('os', () => ({
hostname: () => mockHostname(),
}));
describe('deviceId', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
mockMachineIdSync.mockReturnValue('mock-machine-id');
mockHostname.mockReturnValue('mock-hostname');
});
it('should return a 64-char hex string', async () => {
const { getDeviceId } = await import('./deviceId');
const id = getDeviceId();
expect(id).toMatch(/^[0-9a-f]{64}$/);
});
it('should hash machineId + salt with SHA-256', async () => {
const { getDeviceId } = await import('./deviceId');
const id = getDeviceId();
const expected = createHash('sha256')
.update('mock-machine-id' + 'qiming-agent')
.digest('hex');
expect(id).toBe(expected);
});
it('should call machineIdSync with original=true', async () => {
const { getDeviceId } = await import('./deviceId');
getDeviceId();
expect(mockMachineIdSync).toHaveBeenCalledWith(true);
});
it('should cache the result on subsequent calls', async () => {
const { getDeviceId } = await import('./deviceId');
const first = getDeviceId();
const second = getDeviceId();
expect(first).toBe(second);
expect(mockMachineIdSync).toHaveBeenCalledTimes(1);
});
it('should log the deviceId on first call', async () => {
const { getDeviceId } = await import('./deviceId');
const log = (await import('electron-log')).default;
const id = getDeviceId();
expect(log.info).toHaveBeenCalledWith(`[DeviceId] ${id}`);
});
it('should fallback to hostname when machineIdSync throws', async () => {
mockMachineIdSync.mockImplementation(() => {
throw new Error('no machine-id');
});
const { getDeviceId } = await import('./deviceId');
const log = (await import('electron-log')).default;
const id = getDeviceId();
const expected = createHash('sha256')
.update('mock-hostname' + 'qiming-agent')
.digest('hex');
expect(id).toBe(expected);
expect(log.warn).toHaveBeenCalledWith(
'[DeviceId] Failed to read machineId, using hostname fallback:',
expect.any(Error),
);
});
it('should produce different IDs for different machineIds', async () => {
mockMachineIdSync.mockReturnValue('machine-a');
const mod1 = await import('./deviceId');
const idA = mod1.getDeviceId();
vi.resetModules();
mockMachineIdSync.mockReturnValue('machine-b');
const mod2 = await import('./deviceId');
const idB = mod2.getDeviceId();
expect(idA).not.toBe(idB);
});
});

View File

@@ -0,0 +1,88 @@
import { machineIdSync } from "node-machine-id";
import { createHash } from "crypto";
import * as os from "os";
import log from "electron-log";
const APP_SALT = "qiming-agent";
let cachedDeviceId: string | null = null;
export function getDeviceId(): string {
if (cachedDeviceId) return cachedDeviceId;
let raw: string;
try {
raw = machineIdSync(true);
} catch (e) {
log.warn(
"[DeviceId] Failed to read machineId, using hostname fallback:",
e,
);
raw = os.hostname();
}
cachedDeviceId = createHash("sha256")
.update(raw + APP_SALT)
.digest("hex");
log.info(`[DeviceId] ${cachedDeviceId}`);
return cachedDeviceId;
}
export function logSystemInfo(): void {
// 固定字段尽量保持稳定输出,便于跨平台日志分析与检索。
const baseInfo = {
platform: process.platform,
arch: os.arch(),
release: os.release(),
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
// 保留毫秒级时间戳,方便日志系统或外部工具做数值排序/聚合分析。
timestamp: Date.now(),
// 额外输出 UTC 时间字符串ISO 8601便于人工阅读与跨时区排查问题。
// 例如2026-04-09T08:12:34.567Z,其中 `Z` 代表 UTC不受本地时区影响
timestampUtc: new Date().toISOString(),
};
try {
// `electron` 在极少数受限环境/初始化早期可能不可用;由外层 catch 统一降级。
const electron = require("electron");
const app = electron?.app;
const screen = electron?.screen;
const primary = screen?.getPrimaryDisplay?.();
const screenInfo =
primary?.size?.width && primary?.size?.height && primary?.scaleFactor
? `${primary.size.width}x${primary.size.height}@${primary.scaleFactor}x`
: "unknown";
const timezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
const cpuCount = os.cpus()?.length ?? 0;
const totalMemoryGB = Math.round(os.totalmem() / 1024 / 1024 / 1024);
const freeMemoryGB = Math.round(os.freemem() / 1024 / 1024 / 1024);
log.info("[System]", {
...baseInfo,
version: app?.getVersion?.() ?? "unknown",
locale: app?.getLocale?.() ?? "unknown",
timezone,
cpuCount,
totalMemoryGB,
freeMemoryGB,
screen: screenInfo,
});
} catch (e) {
// 系统信息采集仅用于诊断,任何异常都不应影响客户端启动和核心功能。
log.warn("[System] Failed to collect system info:", e);
// 即使进入降级路径,也要输出固定字段,避免日志结构缺失。
log.info("[System]", {
...baseInfo,
version: "unknown",
locale: "unknown",
timezone: "unknown",
cpuCount: 0,
totalMemoryGB: 0,
freeMemoryGB: 0,
screen: "unknown",
});
}
}

View File

@@ -0,0 +1,15 @@
// Main process system services
export { getDeviceId } from './deviceId';
export { getAppEnv, setMirrorConfig } from './dependencies';
export {
getAppDataDir,
getWorkspacesDir,
getDefaultWorkspace,
getSessionWorkspace,
deleteSessionWorkspace,
getSessionFiles,
saveWorkspaceConfig,
getWorkspaceConfig,
listWorkspaces,
cleanupOldWorkspaces,
} from './workspaceManager';

View File

@@ -0,0 +1,116 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createPlatformAdapter,
getCurrentPlatform,
isSupportedPlatform,
} from "./platformAdapter";
describe("platformAdapter", () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
});
});
it("should expose Windows probe and sandbox traits", () => {
const adapter = createPlatformAdapter("win32");
expect(adapter.isWindows).toBe(true);
expect(adapter.pathDelimiter).toBe(";");
expect(adapter.sandboxHelperName).toBe("qiming-sandbox-helper.exe");
expect(adapter.getCommandProbe("git")).toEqual({
command: "where",
args: ["git"],
});
expect(adapter.getRecommendedSandboxBackend()).toBe("windows-sandbox");
expect(adapter.getRecommendedSandboxType()).toBe("windows-sandbox");
});
it("should expose Unix probe and seatbelt traits for macOS", () => {
const adapter = createPlatformAdapter("darwin");
expect(adapter.isMacOS).toBe(true);
expect(adapter.pathDelimiter).toBe(":");
expect(adapter.sandboxHelperName).toBe("qiming-sandbox-helper");
expect(adapter.getCommandProbe("git")).toEqual({
command: "which",
args: ["git"],
});
expect(adapter.getSeatbeltPath()).toBe("/usr/bin/sandbox-exec");
expect(adapter.getRecommendedSandboxBackend()).toBe("macos-seatbelt");
});
it("should resolve Linux bundled bwrap path from candidates", () => {
const adapter = createPlatformAdapter("linux");
const resourcesRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "platform-adapter-test-"),
);
const bundledBwrap = path.join(
resourcesRoot,
"sandbox-runtime",
"linux",
"bwrap",
);
try {
fs.mkdirSync(path.dirname(bundledBwrap), { recursive: true });
fs.writeFileSync(bundledBwrap, "");
const resolved = adapter.resolveBundledLinuxBwrapPath(resourcesRoot);
expect(resolved).toBe(bundledBwrap);
} finally {
fs.rmSync(resourcesRoot, { recursive: true, force: true });
}
});
it("should include Windows helper candidates with windows-specific path priority", () => {
const adapter = createPlatformAdapter("win32");
const candidates =
adapter.getBundledSandboxHelperCandidates("/mock/resources");
expect(candidates).toEqual([
path.join(
"/mock/resources",
"sandbox-runtime",
"bin",
"qiming-sandbox-helper.exe",
),
path.join(
"/mock/resources",
"sandbox-runtime",
"windows",
"qiming-sandbox-helper.exe",
),
path.join(
"/mock/resources",
"sandbox-helper",
"qiming-sandbox-helper.exe",
),
]);
});
it("should classify supported platforms and fallback current platform", () => {
expect(isSupportedPlatform("darwin")).toBe(true);
expect(isSupportedPlatform("linux")).toBe(true);
expect(isSupportedPlatform("win32")).toBe(true);
expect(isSupportedPlatform("freebsd")).toBe(false);
Object.defineProperty(process, "platform", {
value: "freebsd",
writable: true,
configurable: true,
});
expect(getCurrentPlatform()).toBe("linux");
});
});

View File

@@ -0,0 +1,139 @@
import * as fs from "fs";
import * as path from "path";
import type {
Platform as SandboxPlatform,
SandboxBackend,
SandboxType,
} from "@shared/types/sandbox";
export type Platform = SandboxPlatform;
type CommandProbe = {
command: string;
args: string[];
};
export interface PlatformAdapter {
readonly platform: Platform;
readonly isWindows: boolean;
readonly isMacOS: boolean;
readonly isLinux: boolean;
readonly pathDelimiter: ":" | ";";
readonly commandLocator: "which" | "where";
readonly sandboxHelperName: string;
getCommandProbe(command: string): CommandProbe;
getRecommendedSandboxBackend(): SandboxBackend;
getRecommendedSandboxType(): SandboxType;
getSeatbeltPath(): string | null;
getBundledLinuxBwrapCandidates(resourcesPath: string): string[];
resolveBundledLinuxBwrapPath(resourcesPath: string): string | null;
getBundledSandboxHelperCandidates(resourcesPath: string): string[];
resolveBundledSandboxHelperPath(resourcesPath: string): string | null;
}
export function isSupportedPlatform(value: string): value is Platform {
return value === "darwin" || value === "linux" || value === "win32";
}
export function getCurrentPlatform(): Platform {
return isSupportedPlatform(process.platform) ? process.platform : "linux";
}
class RuntimePlatformAdapter implements PlatformAdapter {
readonly isWindows: boolean;
readonly isMacOS: boolean;
readonly isLinux: boolean;
readonly pathDelimiter: ":" | ";";
readonly commandLocator: "which" | "where";
readonly sandboxHelperName: string;
constructor(readonly platform: Platform) {
this.isWindows = platform === "win32";
this.isMacOS = platform === "darwin";
this.isLinux = platform === "linux";
this.pathDelimiter = this.isWindows ? ";" : ":";
this.commandLocator = this.isWindows ? "where" : "which";
this.sandboxHelperName = this.isWindows
? "qiming-sandbox-helper.exe"
: "qiming-sandbox-helper";
}
getCommandProbe(command: string): CommandProbe {
return {
command: this.commandLocator,
args: [command],
};
}
getRecommendedSandboxBackend(): SandboxBackend {
if (this.isWindows) return "windows-sandbox";
if (this.isMacOS) return "macos-seatbelt";
return "linux-bwrap";
}
getRecommendedSandboxType(): SandboxType {
if (this.isWindows) return "windows-sandbox";
if (this.isMacOS) return "macos-seatbelt";
return "linux-bwrap";
}
getSeatbeltPath(): string | null {
return this.isMacOS ? "/usr/bin/sandbox-exec" : null;
}
getBundledLinuxBwrapCandidates(resourcesPath: string): string[] {
const runtimeDir = path.join(resourcesPath, "sandbox-runtime");
return [
path.join(runtimeDir, "bin", "bwrap"),
path.join(runtimeDir, "linux", "bwrap"),
];
}
resolveBundledLinuxBwrapPath(resourcesPath: string): string | null {
for (const candidate of this.getBundledLinuxBwrapCandidates(
resourcesPath,
)) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
getBundledSandboxHelperCandidates(resourcesPath: string): string[] {
const runtimeDir = path.join(resourcesPath, "sandbox-runtime");
const helperRoot = path.join(resourcesPath, "sandbox-helper");
const candidates = [
path.join(runtimeDir, "bin", this.sandboxHelperName),
path.join(helperRoot, this.sandboxHelperName),
];
if (this.isWindows) {
candidates.splice(
1,
0,
path.join(runtimeDir, "windows", this.sandboxHelperName),
);
}
return candidates;
}
resolveBundledSandboxHelperPath(resourcesPath: string): string | null {
for (const candidate of this.getBundledSandboxHelperCandidates(
resourcesPath,
)) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
}
export function createPlatformAdapter(
platform: NodeJS.Platform = process.platform,
): PlatformAdapter {
const resolvedPlatform = isSupportedPlatform(platform) ? platform : "linux";
return new RuntimePlatformAdapter(resolvedPlatform);
}

View File

@@ -0,0 +1,382 @@
/**
* 单元测试: ProcessRegistry
*
* 测试进程注册、注销、定期扫描和孤儿清理逻辑
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock killProcessTreeGraceful
const mockKillProcessTreeGraceful = vi.fn().mockResolvedValue(undefined);
vi.mock("../utils/processTree", () => ({
killProcessTreeGraceful: (...args: unknown[]) =>
mockKillProcessTreeGraceful(...args),
}));
// Mock process.kill for isProcessAlive checks
const originalProcessKill = process.kill;
describe("ProcessRegistry", () => {
let processRegistry: Awaited<
typeof import("./processRegistry")
>["processRegistry"];
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
vi.useFakeTimers();
// Re-import to get a fresh singleton
const mod = await import("./processRegistry");
processRegistry = mod.processRegistry;
});
afterEach(() => {
// Ensure sweep timers are stopped
processRegistry.stopPeriodicSweep();
vi.useRealTimers();
process.kill = originalProcessKill;
});
// === Register / Unregister ===
describe("register", () => {
it("should register a process and increment size", () => {
expect(processRegistry.size).toBe(0);
processRegistry.register(1234, {
engineId: "acp-123-abc",
engineType: "claude-code",
purpose: "engine",
});
expect(processRegistry.size).toBe(1);
});
it("should overwrite an existing entry for the same PID", () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
processRegistry.register(1234, {
engineId: "acp-2",
engineType: "qimingcode",
purpose: "engine",
});
expect(processRegistry.size).toBe(1);
});
it("should register multiple different PIDs", () => {
processRegistry.register(100, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
processRegistry.register(200, {
engineId: "acp-2",
engineType: "qimingcode",
purpose: "engine",
});
expect(processRegistry.size).toBe(2);
});
});
describe("unregister", () => {
it("should remove a registered process", () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
expect(processRegistry.size).toBe(1);
processRegistry.unregister(1234);
expect(processRegistry.size).toBe(0);
});
it("should be idempotent for non-existent PID", () => {
processRegistry.unregister(9999);
expect(processRegistry.size).toBe(0);
});
it("should be idempotent when called twice", () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
processRegistry.unregister(1234);
processRegistry.unregister(1234);
expect(processRegistry.size).toBe(0);
});
});
// === sweepNow ===
describe("sweepNow", () => {
it("should return zeroes when registry is empty", async () => {
const result = await processRegistry.sweepNow();
expect(result).toEqual({ scanned: 0, orphans: 0, killed: 0 });
});
it("should skip processes within grace period", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
// Don't advance time — still within grace period
const result = await processRegistry.sweepNow();
expect(result.scanned).toBe(1);
expect(result.orphans).toBe(0);
expect(processRegistry.size).toBe(1);
});
it("should remove dead processes after grace period", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
// Advance past grace period
vi.advanceTimersByTime(31_000);
// Mock process as dead
process.kill = vi.fn(() => {
throw new Error("ESRCH");
}) as unknown as typeof process.kill;
const result = await processRegistry.sweepNow();
expect(result.scanned).toBe(1);
expect(result.orphans).toBe(0); // Dead, not orphan
expect(processRegistry.size).toBe(0); // Removed
});
it("should detect and kill orphan processes", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
// Advance past grace period
vi.advanceTimersByTime(31_000);
// Mock process as alive
process.kill = vi.fn() as unknown as typeof process.kill;
// No active PIDs bound (or bound with empty set)
processRegistry.bindActivePidsFn(() => new Set());
const result = await processRegistry.sweepNow();
expect(result.scanned).toBe(1);
expect(result.orphans).toBe(1);
expect(result.killed).toBe(1);
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
expect(processRegistry.size).toBe(0);
});
it("should not kill active processes", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
vi.advanceTimersByTime(31_000);
// Mock process as alive
process.kill = vi.fn() as unknown as typeof process.kill;
// PID 1234 is active
processRegistry.bindActivePidsFn(() => new Set([1234]));
const result = await processRegistry.sweepNow();
expect(result.scanned).toBe(1);
expect(result.orphans).toBe(0);
expect(result.killed).toBe(0);
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
expect(processRegistry.size).toBe(1); // Still registered
});
it("should handle killProcessTreeGraceful failure gracefully", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
vi.advanceTimersByTime(31_000);
process.kill = vi.fn() as unknown as typeof process.kill;
processRegistry.bindActivePidsFn(() => new Set());
mockKillProcessTreeGraceful.mockRejectedValueOnce(
new Error("kill failed"),
);
const result = await processRegistry.sweepNow();
expect(result.scanned).toBe(1);
expect(result.orphans).toBe(1);
expect(result.killed).toBe(0); // Failed to kill
expect(processRegistry.size).toBe(0); // Still removed from registry
});
});
// === killOrphans (no grace period) ===
describe("killOrphans", () => {
it("should skip grace period and kill orphans immediately", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
// Do NOT advance time — process was just registered
process.kill = vi.fn() as unknown as typeof process.kill;
processRegistry.bindActivePidsFn(() => new Set());
const result = await processRegistry.killOrphans();
expect(result.orphans).toBe(1);
expect(result.killed).toBe(1);
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
});
it("should not kill active processes even without grace period", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
process.kill = vi.fn() as unknown as typeof process.kill;
processRegistry.bindActivePidsFn(() => new Set([1234]));
const result = await processRegistry.killOrphans();
expect(result.orphans).toBe(0);
expect(processRegistry.size).toBe(1);
});
});
// === killAll ===
describe("killAll", () => {
it("should do nothing when registry is empty", async () => {
await processRegistry.killAll();
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
});
it("should kill all registered processes", async () => {
processRegistry.register(100, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
processRegistry.register(200, {
engineId: "acp-2",
engineType: "qimingcode",
purpose: "engine",
});
// Mock both as alive
process.kill = vi.fn() as unknown as typeof process.kill;
await processRegistry.killAll();
expect(mockKillProcessTreeGraceful).toHaveBeenCalledTimes(2);
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(100, 3000);
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(200, 3000);
expect(processRegistry.size).toBe(0);
});
it("should skip already-dead processes", async () => {
processRegistry.register(100, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
// Mock process as dead
process.kill = vi.fn(() => {
throw new Error("ESRCH");
}) as unknown as typeof process.kill;
await processRegistry.killAll();
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
expect(processRegistry.size).toBe(0); // Still cleared
});
});
// === Periodic Sweep ===
describe("startPeriodicSweep / stopPeriodicSweep", () => {
it("should not start duplicate timers", () => {
processRegistry.startPeriodicSweep(60_000);
processRegistry.startPeriodicSweep(60_000); // Should be a no-op
processRegistry.stopPeriodicSweep();
});
it("should run sweep on interval", async () => {
// Register an orphan process past grace period
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
processRegistry.bindActivePidsFn(() => new Set());
// Mock process as alive
process.kill = vi.fn() as unknown as typeof process.kill;
processRegistry.startPeriodicSweep(1000);
// Advance past grace period + one sweep interval
vi.advanceTimersByTime(31_000);
// Let async sweep complete
await vi.advanceTimersByTimeAsync(1000);
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
});
it("stopPeriodicSweep should be safe when no sweep is running", () => {
processRegistry.stopPeriodicSweep(); // Should not throw
});
});
// === bindActivePidsFn ===
describe("bindActivePidsFn", () => {
it("should use empty set when no function is bound", async () => {
processRegistry.register(1234, {
engineId: "acp-1",
engineType: "claude-code",
purpose: "engine",
});
vi.advanceTimersByTime(31_000);
process.kill = vi.fn() as unknown as typeof process.kill;
// No bindActivePidsFn called — should treat all as orphans
const result = await processRegistry.sweepNow();
expect(result.orphans).toBe(1);
});
});
});

View File

@@ -0,0 +1,195 @@
/**
* ProcessRegistry — 进程注册中心 + 孤儿进程清理
*
* Layer 1: Register/Unregister — ACP 进程 spawn 时注册 PIDdestroy 时注销
* Layer 2: Periodic Sweep — 每 60s 扫描注册表,对比活跃引擎,杀掉孤儿进程
*/
import log from "electron-log";
import { killProcessTreeGraceful } from "../utils/processTree";
export interface RegisteredProcess {
pid: number;
engineId: string;
engineType: "claude-code" | "qimingcode";
purpose: "engine";
registeredAt: number;
}
export interface SweepResult {
scanned: number;
orphans: number;
killed: number;
}
class ProcessRegistry {
private registry = new Map<number, RegisteredProcess>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;
private getActivePids: (() => Set<number>) | null = null;
/** Grace period: skip processes registered less than this many ms ago */
private static readonly GRACE_PERIOD_MS = 30_000;
// === Register / Unregister ===
register(
pid: number,
info: Omit<RegisteredProcess, "pid" | "registeredAt">,
): void {
const entry: RegisteredProcess = {
...info,
pid,
registeredAt: Date.now(),
};
this.registry.set(pid, entry);
log.info(
`[ProcessRegistry] Registered pid=${pid} engine=${info.engineId} type=${info.engineType} purpose=${info.purpose}`,
);
}
unregister(pid: number): void {
const existed = this.registry.delete(pid);
if (existed) {
log.info(`[ProcessRegistry] Unregistered pid=${pid}`);
}
}
// === Active PID binding ===
bindActivePidsFn(fn: () => Set<number>): void {
this.getActivePids = fn;
}
// === Periodic Sweep ===
startPeriodicSweep(intervalMs = 300_000): void {
if (this.sweepTimer) return;
this.sweepTimer = setInterval(() => {
this.sweepNow().catch((e) => {
log.warn("[ProcessRegistry] Sweep error:", e);
});
}, intervalMs);
log.info(
`[ProcessRegistry] Periodic sweep started (interval=${intervalMs}ms)`,
);
}
stopPeriodicSweep(): void {
if (this.sweepTimer) {
clearInterval(this.sweepTimer);
this.sweepTimer = null;
log.info("[ProcessRegistry] Periodic sweep stopped");
}
}
async sweepNow(skipGracePeriod = false): Promise<SweepResult> {
const activePids = this.getActivePids?.() ?? new Set<number>();
const now = Date.now();
let scanned = 0;
let orphans = 0;
let killed = 0;
const toRemove: number[] = [];
for (const [pid, entry] of this.registry) {
scanned++;
// Grace period: skip recently registered processes (unless explicitly skipped)
if (
!skipGracePeriod &&
now - entry.registeredAt < ProcessRegistry.GRACE_PERIOD_MS
) {
continue;
}
// Check if process is still alive
if (!isProcessAlive(pid)) {
toRemove.push(pid);
continue;
}
// Alive but not in active set → orphan
if (!activePids.has(pid)) {
orphans++;
log.warn(
`[ProcessRegistry] Orphan detected: pid=${pid} engine=${entry.engineId} type=${entry.engineType} purpose=${entry.purpose}`,
);
try {
await killProcessTreeGraceful(pid, 5000);
killed++;
log.info(`[ProcessRegistry] Killed orphan pid=${pid}`);
} catch (e) {
log.warn(`[ProcessRegistry] Failed to kill orphan pid=${pid}:`, e);
}
toRemove.push(pid);
}
}
// Clean up dead / killed entries
for (const pid of toRemove) {
this.registry.delete(pid);
}
if (orphans > 0) {
log.info(
`[ProcessRegistry] Sweep complete: scanned=${scanned}, orphans=${orphans}, killed=${killed}`,
);
} else {
log.debug(
`[ProcessRegistry] Sweep complete: scanned=${scanned}, orphans=0`,
);
}
return { scanned, orphans, killed };
}
// === Kill All (app exit) ===
async killAll(): Promise<void> {
const pids = [...this.registry.keys()];
if (pids.length === 0) return;
log.info(
`[ProcessRegistry] killAll: cleaning up ${pids.length} registered processes`,
);
await Promise.all(
pids.map(async (pid) => {
if (isProcessAlive(pid)) {
try {
await killProcessTreeGraceful(pid, 3000);
log.info(`[ProcessRegistry] killAll: killed pid=${pid}`);
} catch (e) {
log.warn(
`[ProcessRegistry] killAll: failed to kill pid=${pid}:`,
e,
);
}
}
}),
);
this.registry.clear();
log.info("[ProcessRegistry] killAll: registry cleared");
}
/** Kill orphans only (registered but not in active set, no grace period) */
async killOrphans(): Promise<SweepResult> {
return this.sweepNow(true);
}
/** Get the number of registered processes (for diagnostics) */
get size(): number {
return this.registry.size;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export const processRegistry = new ProcessRegistry();

View File

@@ -0,0 +1,471 @@
/**
* Shell Environment Manager - 跨平台 Shell 环境管理
*
* 功能:
* - 检测可用 shell
* - 工具路径管理
* - PATH 配置
* - Windows/macOS/Linux 兼容
*/
import * as path from "path";
import * as fs from "fs";
import { spawn } from "child_process";
import log from "electron-log";
import {
createPlatformAdapter,
getCurrentPlatform,
type Platform,
} from "./platformAdapter";
// ==================== Types ====================
export type { Platform } from "./platformAdapter";
export interface ShellInfo {
name: string;
path: string;
version?: string;
isAvailable: boolean;
}
export interface ToolInfo {
name: string;
path?: string;
version?: string;
isAvailable: boolean;
}
export interface ShellEnvironment {
platform: Platform;
shell: ShellInfo;
tools: Map<string, ToolInfo>;
path: string[];
homeDir: string;
}
// ==================== Platform Detection ====================
/**
* 获取当前平台
*/
export function getPlatform(): Platform {
return getCurrentPlatform();
}
/**
* 检测是否为 Windows
*/
export function isWindows(): boolean {
return createPlatformAdapter().isWindows;
}
/**
* 检测是否为 macOS
*/
export function isMacOS(): boolean {
return createPlatformAdapter().isMacOS;
}
/**
* 检测是否为 Linux
*/
export function isLinux(): boolean {
return createPlatformAdapter().isLinux;
}
// ==================== Shell Detection ====================
/**
* 检测可用的 shell
*/
export async function detectShell(): Promise<ShellInfo> {
const platform = getPlatform();
const adapter = createPlatformAdapter(platform);
const shellCandidates: Array<{
name: string;
path: string;
args?: string[];
}> = [];
if (adapter.isMacOS) {
// macOS: zsh (默认), bash
shellCandidates.push(
{ name: "zsh", path: "/bin/zsh", args: ["--version"] },
{ name: "bash", path: "/bin/bash", args: ["--version"] },
);
} else if (adapter.isLinux) {
// Linux: bash
shellCandidates.push(
{ name: "bash", path: "/bin/bash", args: ["--version"] },
{ name: "sh", path: "/bin/sh" },
);
} else if (adapter.isWindows) {
// Windows: PowerShell, cmd, Git Bash
shellCandidates.push(
{
name: "powershell",
path: "powershell.exe",
args: ["-Command", "$PSVersionTable.PSVersion.ToString()"],
},
{ name: "cmd", path: "cmd.exe", args: ["/c", "ver"] },
);
// 检查 Git Bash
const gitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe";
if (fs.existsSync(gitBashPath)) {
shellCandidates.push({
name: "bash",
path: gitBashPath,
args: ["--version"],
});
}
// 检查 WSL
try {
const wslResult = await checkCommand("wsl.exe");
if (wslResult) {
shellCandidates.push({ name: "wsl", path: "wsl.exe" });
}
} catch {}
}
// 尝试找到可用的 shell
for (const shell of shellCandidates) {
try {
const version = await getCommandVersion(shell.path, shell.args);
return {
name: shell.name,
path: shell.path,
version: version ?? undefined,
isAvailable: true,
};
} catch {
continue;
}
}
// 返回默认
return {
name: adapter.isWindows ? "powershell" : "bash",
path: "",
isAvailable: false,
};
}
/**
* 检测命令是否存在
*/
export async function checkCommand(cmd: string): Promise<boolean> {
return new Promise((resolve) => {
const adapter = createPlatformAdapter();
const { command: checkCmd, args } = adapter.getCommandProbe(cmd);
const proc = spawn(checkCmd, args, {
stdio: ["ignore", "pipe", "ignore"],
shell: true,
});
proc.on("close", (code) => {
resolve(code === 0);
});
proc.on("error", () => resolve(false));
});
}
/**
* 获取命令版本
*/
export async function getCommandVersion(
cmd: string,
args?: string[],
): Promise<string | null> {
return new Promise((resolve) => {
const proc = spawn(cmd, args || ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
shell: true,
});
let stdout = "";
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.on("close", () => {
const match = stdout.match(/(\d+\.\d+\.\d+[\d.]*)/);
resolve(match ? match[1] : stdout.trim() || null);
});
proc.on("error", () => resolve(null));
});
}
// ==================== Essential Tools ====================
/**
* 必需的工具列表
*/
export const ESSENTIAL_TOOLS = [
// 文件操作
"ls",
"cd",
"pwd",
"mkdir",
"rm",
"cp",
"mv",
"cat",
"echo",
// 文本处理
"grep",
"sed",
"awk",
"sort",
"uniq",
"head",
"tail",
"wc",
// 进程/网络
"ps",
"kill",
"find",
"xargs",
"curl",
"wget",
// Git (可选)
"git",
// Node.js
"node",
"npm",
"npx",
];
const ESSENTIAL_TOOLS_WINDOWS = [
"dir",
"cd",
"type",
"echo",
"mkdir",
"del",
"copy",
"move",
"findstr", // Windows grep
"where",
];
/**
* 检测必需工具
*/
export async function detectTools(): Promise<Map<string, ToolInfo>> {
const adapter = createPlatformAdapter();
const tools = new Map<string, ToolInfo>();
const toolsToCheck = adapter.isWindows
? ESSENTIAL_TOOLS_WINDOWS
: ESSENTIAL_TOOLS;
for (const tool of toolsToCheck) {
const isAvailable = await checkCommand(tool);
let version: string | undefined;
if (isAvailable) {
version = (await getCommandVersion(tool)) || undefined;
}
tools.set(tool, {
name: tool,
isAvailable,
version,
});
}
return tools;
}
// ==================== PATH Management ====================
/**
* 获取系统 PATH
*/
export function getSystemPath(): string[] {
const adapter = createPlatformAdapter();
const pathEnv = process.env.PATH || "";
return pathEnv.split(adapter.pathDelimiter).filter(Boolean);
}
/**
* 获取用户 Home 目录
*/
export function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || "";
}
/**
* 获取默认工作目录
*/
export function getDefaultWorkspace(): string {
const home = getHomeDir();
return path.join(home, "QimingAgent", "workspace");
}
// ==================== Environment Builder ====================
/**
* 构建 Agent 运行环境
*/
export async function buildAgentEnvironment(options?: {
workspaceDir?: string;
includeTools?: boolean;
}): Promise<{
env: Record<string, string>;
shell: ShellInfo;
workspace: string;
}> {
const platform = getPlatform();
const adapter = createPlatformAdapter(platform);
const home = getHomeDir();
const workspace = options?.workspaceDir || getDefaultWorkspace();
// 确保工作目录存在
if (!fs.existsSync(workspace)) {
fs.mkdirSync(workspace, { recursive: true });
}
// 检测 shell
const shell = await detectShell();
// 构建环境变量
const env: Record<string, string> = {
...process.env,
// Home 目录
HOME: home,
// 工作目录
WORKSPACE: workspace,
PWD: workspace,
// 语言环境
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
};
// 平台特定配置
if (adapter.isMacOS) {
// macOS
env.SHELL = "/bin/zsh";
env.EDITOR = "vim";
env.VISUAL = "vim";
} else if (adapter.isLinux) {
// Linux
env.SHELL = "/bin/bash";
env.EDITOR = "vim";
env.VISUAL = "vim";
} else if (adapter.isWindows) {
// Windows
env.SHELL = "powershell.exe";
env.EDITOR = "notepad";
}
// 可选: 检测并添加工具路径
if (options?.includeTools) {
const tools = await detectTools();
// 添加找到的工具路径
const toolPaths: string[] = [];
for (const [name, info] of tools) {
if (info.isAvailable && info.path) {
const dir = path.dirname(info.path);
if (!toolPaths.includes(dir)) {
toolPaths.push(dir);
}
}
}
// 添加到 PATH 前面
if (toolPaths.length > 0) {
const currentPath = env.PATH || "";
env.PATH =
toolPaths.join(adapter.pathDelimiter) +
adapter.pathDelimiter +
currentPath;
}
}
log.info(
`[Shell] Agent environment: platform=${platform}, shell=${shell.name}, workspace=${workspace}`,
);
return { env, shell, workspace };
}
/**
* 检查环境就绪状态
*/
export async function checkEnvironmentReady(): Promise<{
ready: boolean;
issues: string[];
}> {
const issues: string[] = [];
const adapter = createPlatformAdapter();
// 检查 shell
const shell = await detectShell();
if (!shell.isAvailable) {
issues.push(`Shell not found`);
}
// 检查核心工具
const criticalTools = adapter.isWindows
? ["where", "dir", "type"]
: ["ls", "cat", "grep"];
for (const tool of criticalTools) {
const isAvailable = await checkCommand(tool);
if (!isAvailable) {
issues.push(`Missing tool: ${tool}`);
}
}
// 检查工作目录
const workspace = getDefaultWorkspace();
if (!fs.existsSync(workspace)) {
try {
fs.mkdirSync(workspace, { recursive: true });
} catch (error) {
issues.push(`Cannot create workspace: ${error}`);
}
}
return {
ready: issues.length === 0,
issues,
};
}
export default {
// Platform
getPlatform,
isWindows,
isMacOS,
isLinux,
// Shell
detectShell,
checkCommand,
getCommandVersion,
// Tools
detectTools,
ESSENTIAL_TOOLS,
// PATH
getSystemPath,
getHomeDir,
getDefaultWorkspace,
// Environment
buildAgentEnvironment,
checkEnvironmentReady,
};

View File

@@ -0,0 +1,141 @@
/**
* Webview / iframe 浏览器策略统一管理
*
* 集中处理:
* 1. 权限请求(剪贴板、媒体、全屏等)
* 2. window.open 拦截(统一由系统浏览器打开)
* 3. 文件下载(导出等场景,支持进度条)
*/
import {
app,
session as electronSession,
shell,
BrowserWindow,
} from "electron";
import log from "electron-log";
// ---------- 权限白名单 ----------
const ALLOWED_PERMISSIONS = new Set([
"clipboard-read",
"clipboard-sanitized-write",
"media",
"mediaKeySystem",
"notifications",
"fullscreen",
"pointerLock",
"openExternal",
]);
// ---------- 权限 ----------
function setupPermissions(): void {
electronSession.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback) => {
if (ALLOWED_PERMISSIONS.has(permission)) {
callback(true);
} else {
log.warn(`[WebviewPolicy] Denied permission request: ${permission}`);
callback(false);
}
},
);
electronSession.defaultSession.setPermissionCheckHandler(
(_webContents, permission) => {
return ALLOWED_PERMISSIONS.has(permission);
},
);
}
// ---------- window.open ----------
function isHttpUrl(url: string): boolean {
return url.startsWith("http:") || url.startsWith("https:");
}
function setupWindowOpen(): void {
app.on("web-contents-created", (_event, contents) => {
// <webview> tag 内部的 window.open
contents.on("did-attach-webview", (_event, webContents) => {
webContents.setWindowOpenHandler(({ url }) => {
if (url && isHttpUrl(url)) {
shell.openExternal(url);
}
return { action: "deny" };
});
// Webview captures keyboard events — they don't bubble to the host page.
// Intercept Ctrl/Cmd+Shift+I here to open webview DevTools.
webContents.on("before-input-event", (event, input) => {
if (
input.type === "keyDown" &&
input.shift &&
(input.control || input.meta) &&
input.key.toLowerCase() === "i"
) {
event.preventDefault();
webContents.openDevTools();
}
});
});
// BrowserWindow 内部的 window.open独立 webview 窗口等)
if (contents.getType() === "window") {
contents.setWindowOpenHandler(({ url }) => {
if (url && isHttpUrl(url)) {
shell.openExternal(url);
}
return { action: "deny" };
});
}
});
}
// ---------- 文件下载 ----------
function setupDownloads(getMainWindow: () => BrowserWindow | null): void {
electronSession.defaultSession.on("will-download", (_event, item) => {
const filename = item.getFilename();
log.info(
`[WebviewPolicy] Download started: ${filename} (${item.getTotalBytes()} bytes)`,
);
item.on("updated", (_event, state) => {
if (state === "progressing" && !item.isPaused()) {
const received = item.getReceivedBytes();
const total = item.getTotalBytes();
if (total > 0) {
getMainWindow()?.setProgressBar(received / total);
}
}
});
item.once("done", (_event, state) => {
getMainWindow()?.setProgressBar(-1);
if (state === "completed") {
log.info(
`[WebviewPolicy] Download completed: ${filename}${item.getSavePath()}`,
);
} else {
log.warn(`[WebviewPolicy] Download failed: ${filename} (${state})`);
}
});
});
}
// ---------- 统一入口 ----------
/**
* 初始化 webview / iframe 浏览器策略。
* 应在 app.whenReady() 且 createWindow() 之后调用。
*/
export function initWebviewPolicy(
getMainWindow: () => BrowserWindow | null,
): void {
setupPermissions();
setupWindowOpen();
setupDownloads(getMainWindow);
log.info("[WebviewPolicy] Initialized (permissions, window.open, downloads)");
}

View File

@@ -0,0 +1,297 @@
/**
* Workspace Manager - Session 与 Workspace 映射
*
* 每个会话对应一个独立的工作目录
* 与 Tauri 客户端保持一致
*/
import * as path from 'path';
import * as fs from 'fs';
import log from 'electron-log';
import { APP_DATA_DIR_NAME } from '../constants';
// ==================== Paths ====================
/**
* 获取应用数据目录
*/
export function getAppDataDir(): string {
const home = process.env.HOME || process.env.USERPROFILE || '';
return path.join(home, APP_DATA_DIR_NAME);
}
/**
* 获取工作空间根目录
*/
export function getWorkspacesDir(): string {
return path.join(getAppDataDir(), 'workspaces');
}
/**
* 获取默认工作目录 (Setup 中配置)
*/
export function getDefaultWorkspace(): string {
// 从设置中读取
// TODO: 从 SQLite 读取
return path.join(getAppDataDir(), 'default-workspace');
}
// ==================== Session Workspace ====================
/**
* 获取会话的工作目录
*
* 规则:
* - 用户指定的工作目录 → 使用用户指定的目录
* - 无指定 → 无工作目录
*
* 注意: 工作目录由用户在创建会话时指定,不自动生成
*/
export function getSessionWorkspace(sessionId: string, userSpecifiedDir?: string): string {
// 用户指定的工作目录优先
if (userSpecifiedDir) {
return userSpecifiedDir;
}
// 没有指定则返回空或默认目录
return '';
}
/**
* 验证工作目录是否存在
*/
export function validateWorkspaceDir(dir: string): {
valid: boolean;
error?: string;
} {
if (!dir) {
return { valid: false, error: 'No workspace directory specified' };
}
if (!fs.existsSync(dir)) {
return { valid: false, error: 'Directory does not exist' };
}
if (!fs.statSync(dir).isDirectory()) {
return { valid: false, error: 'Path is not a directory' };
}
// 检查读写权限
try {
fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
} catch {
return { valid: false, error: 'No read/write permission' };
}
return { valid: true };
}
/**
* 确保工作目录有效 (可选创建子目录)
*/
export async function ensureWorkspaceReady(workspaceDir: string, createSubDirs: boolean = false): Promise<{
ready: boolean;
error?: string;
}> {
const validation = validateWorkspaceDir(workspaceDir);
if (!validation.valid) {
return { ready: false, error: validation.error };
}
// 可选创建子目录
if (createSubDirs) {
const subDirs = ['files', 'logs', 'temp'];
for (const subDir of subDirs) {
const dir = path.join(workspaceDir, subDir);
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (error) {
return { ready: false, error: `Failed to create ${subDir}: ${error}` };
}
}
}
}
return { ready: true };
}
/**
* 删除会话工作目录
*/
export async function deleteSessionWorkspace(sessionId: string): Promise<void> {
const workspace = path.join(getWorkspacesDir(), sessionId);
if (fs.existsSync(workspace)) {
fs.rmSync(workspace, { recursive: true, force: true });
log.info(`[Workspace] Deleted session workspace: ${workspace}`);
}
}
/**
* 获取会话工作目录中的文件
*/
export function getSessionFiles(sessionId: string): string[] {
const workspace = path.join(getWorkspacesDir(), sessionId, 'files');
if (!fs.existsSync(workspace)) {
return [];
}
try {
return fs.readdirSync(workspace);
} catch {
return [];
}
}
// ==================== Workspace Config ====================
export interface WorkspaceConfig {
sessionId: string;
projectDir?: string; // 自定义项目目录
engine?: string; // 使用的引擎
model?: string; // 使用的模型
createdAt: number;
updatedAt: number;
}
/**
* 保存会话工作空间配置
*/
export function saveWorkspaceConfig(sessionId: string, config: Partial<WorkspaceConfig>): void {
const configPath = path.join(getWorkspacesDir(), sessionId, 'config.json');
const workspace = path.join(getWorkspacesDir(), sessionId);
if (!fs.existsSync(workspace)) {
fs.mkdirSync(workspace, { recursive: true });
}
let existingConfig: WorkspaceConfig = {
sessionId,
createdAt: Date.now(),
updatedAt: Date.now(),
};
if (fs.existsSync(configPath)) {
try {
existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {}
}
const newConfig: WorkspaceConfig = {
...existingConfig,
...config,
updatedAt: Date.now(),
};
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
}
/**
* 读取会话工作空间配置
*/
export function getWorkspaceConfig(sessionId: string): WorkspaceConfig | null {
const configPath = path.join(getWorkspacesDir(), sessionId, 'config.json');
if (!fs.existsSync(configPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
return null;
}
}
// ==================== List Workspaces ====================
/**
* 列出所有会话工作空间
*/
export function listWorkspaces(): Array<{
sessionId: string;
path: string;
hasProject: boolean;
lastUsed: number;
}> {
const workspacesDir = getWorkspacesDir();
if (!fs.existsSync(workspacesDir)) {
return [];
}
const workspaces: Array<{
sessionId: string;
path: string;
hasProject: boolean;
lastUsed: number;
}> = [];
try {
const entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const sessionId = entry.name;
const workspacePath = path.join(workspacesDir, sessionId);
const config = getWorkspaceConfig(sessionId);
workspaces.push({
sessionId,
path: workspacePath,
hasProject: !!config?.projectDir,
lastUsed: config?.updatedAt || 0,
});
}
}
} catch (error) {
log.error('[Workspace] Failed to list workspaces:', error);
}
// 按最后使用时间排序
workspaces.sort((a, b) => b.lastUsed - a.lastUsed);
return workspaces;
}
// ==================== Cleanup ====================
/**
* 清理过期的工作空间
*/
export function cleanupOldWorkspaces(maxAgeDays: number = 30): number {
const workspaces = listWorkspaces();
const now = Date.now();
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000;
let deleted = 0;
for (const workspace of workspaces) {
if (now - workspace.lastUsed > maxAge) {
try {
fs.rmSync(workspace.path, { recursive: true, force: true });
deleted++;
log.info(`[Workspace] Deleted old workspace: ${workspace.sessionId}`);
} catch (error) {
log.error(`[Workspace] Failed to delete workspace:`, error);
}
}
}
return deleted;
}
export default {
getAppDataDir,
getWorkspacesDir,
getDefaultWorkspace,
getSessionWorkspace,
deleteSessionWorkspace,
getSessionFiles,
saveWorkspaceConfig,
getWorkspaceConfig,
listWorkspaces,
cleanupOldWorkspaces,
};