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 @@
export * from './agents/agentRunner';

View File

@@ -0,0 +1,235 @@
import {
LOCAL_HOST_URL,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_LANPROXY_PORT,
DEFAULT_ANTHROPIC_API_URL,
DEFAULT_AI_MODEL,
} from '@shared/constants';
export interface AgentRunnerConfig {
enabled: boolean;
binPath: string;
backendPort: number;
proxyPort: number;
apiKey: string;
apiBaseUrl: string;
defaultModel: string;
}
export const defaultAgentRunnerConfig: AgentRunnerConfig = {
enabled: false,
binPath: 'qiming-agent-core',
backendPort: DEFAULT_AGENT_RUNNER_PORT,
proxyPort: DEFAULT_LANPROXY_PORT,
apiKey: '',
apiBaseUrl: DEFAULT_ANTHROPIC_API_URL,
defaultModel: DEFAULT_AI_MODEL,
};
export interface AgentRunnerStatus {
running: boolean;
pid?: number;
backendUrl?: string;
proxyUrl?: string;
error?: string;
}
export interface ChatRequest {
messages: { role: 'user' | 'assistant'; content: string }[];
model?: string;
maxTokens?: number;
}
export interface ChatResponse {
content: string;
type: 'message' | 'error';
}
class AgentRunnerManager {
private config: AgentRunnerConfig = { ...defaultAgentRunnerConfig };
private status: AgentRunnerStatus = { running: false };
getConfig(): AgentRunnerConfig {
return { ...this.config };
}
setConfig(config: Partial<AgentRunnerConfig>) {
this.config = { ...this.config, ...config };
}
getStatus(): AgentRunnerStatus {
return { ...this.status };
}
getBackendUrl(): string {
return `${LOCAL_HOST_URL}:${this.config.backendPort}`;
}
async loadConfig(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('agent_runner_config');
if (saved) {
this.config = { ...defaultAgentRunnerConfig, ...(saved as AgentRunnerConfig) };
}
} catch (error) {
console.error('Failed to load agent runner config:', error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set('agent_runner_config', this.config);
} catch (error) {
console.error('Failed to save agent runner config:', error);
}
}
// Start agent runner via IPC
async start(): Promise<{ success: boolean; error?: string }> {
if (this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.agentRunner.start({
binPath: this.config.binPath,
backendPort: this.config.backendPort,
proxyPort: this.config.proxyPort,
apiKey: this.config.apiKey,
apiBaseUrl: this.config.apiBaseUrl,
defaultModel: this.config.defaultModel,
});
if (result?.success) {
this.status.running = true;
this.status.backendUrl = this.getBackendUrl();
}
return result || { success: false, error: 'IPC failed' };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Stop agent runner via IPC
async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.agentRunner.stop();
if (result?.success) {
this.status.running = false;
}
return result || { success: false, error: 'IPC failed' };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Check status via IPC
async checkStatus(): Promise<AgentRunnerStatus> {
try {
const status = await window.electronAPI?.agentRunner.status();
this.status = status || { running: false };
return this.status;
} catch (error) {
return { running: false, error: String(error) };
}
}
// Send chat request to the agent runner HTTP API
async chat(request: ChatRequest): Promise<ChatResponse> {
if (!this.status.running) {
return { content: 'Agent Runner is not running', type: 'error' };
}
try {
const response = await fetch(`${this.getBackendUrl()}/computer/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
messages: request.messages,
model: request.model || this.config.defaultModel,
max_tokens: request.maxTokens || 4096,
}),
});
if (!response.ok) {
const error = await response.text();
return { content: `Error: ${response.status} - ${error}`, type: 'error' };
}
const data = await response.json();
return { content: data.content || data.message?.content || '', type: 'message' };
} catch (error) {
return { content: `Error: ${error}`, type: 'error' };
}
}
// Stream chat via SSE
async *streamChat(request: ChatRequest): AsyncGenerator<string> {
if (!this.status.running) {
yield 'Error: Agent Runner is not running';
return;
}
const response = await fetch(`${this.getBackendUrl()}/computer/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
messages: request.messages,
model: request.model || this.config.defaultModel,
max_tokens: request.maxTokens || 4096,
stream: true,
}),
});
if (!response.ok) {
yield `Error: ${response.status}`;
return;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
if (parsed.content) {
yield parsed.content;
}
} catch {
// Not JSON, might be plain text
yield data;
}
}
}
}
} finally {
reader.releaseLock();
}
}
}
export const agentRunnerManager = new AgentRunnerManager();

View File

@@ -0,0 +1,385 @@
/**
* Permissions Service - 权限管理服务
*
* 功能:
* - 权限请求管理
* - 权限规则配置
* - 权限持久化
* - 会话级别授权
*/
import * as fs from "fs";
import * as path from "path";
import { APP_DATA_DIR_NAME } from "@shared/constants";
import { t } from "../core/i18n";
export type PermissionType =
| "tool"
| "command"
| "file"
| "network"
| "sandbox";
export type PermissionAction = "allow" | "deny" | "prompt";
export interface PermissionRequest {
id: string;
type: PermissionType;
title: string;
description: string;
details: {
tool?: string;
command?: string;
file?: string;
url?: string;
args?: string[];
env?: Record<string, string>;
sandbox?: boolean;
};
sessionId: string;
timestamp: number;
status: "pending" | "approved" | "denied";
}
export interface PermissionRule {
id: string;
name: string;
pattern: string;
action: PermissionAction;
type?: PermissionType;
expiresAt?: number;
createdAt: number;
}
export interface PermissionConfig {
defaultAction: PermissionAction;
sessionTimeout: number; // 分钟
rules: PermissionRule[];
}
// 默认权限配置
const DEFAULT_CONFIG: PermissionConfig = {
defaultAction: "prompt",
sessionTimeout: 30,
rules: [
// 允许的工具
{
id: "1",
name: t("Claw.PermissionRules.defaultToolRead"),
pattern: "tool:read",
action: "allow",
type: "tool",
createdAt: 0,
},
{
id: "2",
name: t("Claw.PermissionRules.defaultToolEdit"),
pattern: "tool:edit",
action: "prompt",
type: "tool",
createdAt: 0,
},
{
id: "3",
name: t("Claw.PermissionRules.defaultBash"),
pattern: "command:bash",
action: "prompt",
type: "command",
createdAt: 0,
},
{
id: "4",
name: t("Claw.PermissionRules.defaultNetwork"),
pattern: "network:http",
action: "prompt",
type: "network",
createdAt: 0,
},
{
id: "5",
name: t("Claw.PermissionRules.defaultFileRead"),
pattern: "file:read",
action: "allow",
type: "file",
createdAt: 0,
},
{
id: "6",
name: t("Claw.PermissionRules.defaultFileWrite"),
pattern: "file:write",
action: "prompt",
type: "file",
createdAt: 0,
},
],
};
class PermissionManager {
private config: PermissionConfig;
private pendingRequests: Map<string, PermissionRequest> = new Map();
private sessionApprovedTools: Map<string, Map<string, boolean>> = new Map();
private configPath: string;
constructor() {
// 配置文件路径
const home = process.env.HOME || process.env.USERPROFILE || "";
this.configPath = path.join(home, APP_DATA_DIR_NAME, "permissions.json");
this.config = this.loadConfig();
}
/**
* 加载配置
*/
private loadConfig(): PermissionConfig {
try {
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
}
} catch (error) {
console.error("[Permissions] Load config failed:", error);
}
return { ...DEFAULT_CONFIG };
}
/**
* 保存配置
*/
private saveConfig(): void {
try {
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error("[Permissions] Save config failed:", error);
}
}
/**
* 检查权限
*/
checkPermission(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): {
allowed: boolean;
requiresPrompt: boolean;
rule?: PermissionRule;
} {
const key = this.getRuleKey(request);
// 1. 检查规则
for (const rule of this.config.rules) {
if (this.matchPattern(key, rule.pattern)) {
if (rule.action === "allow") {
return { allowed: true, requiresPrompt: false, rule };
}
if (rule.action === "deny") {
return { allowed: false, requiresPrompt: false, rule };
}
// prompt: 继续检查
}
}
// 2. 检查会话级别授权
const sessionApproved = this.sessionApprovedTools.get(request.sessionId);
if (sessionApproved?.has(key)) {
return { allowed: true, requiresPrompt: false };
}
// 3. 默认动作
if (this.config.defaultAction === "allow") {
return { allowed: true, requiresPrompt: false };
}
if (this.config.defaultAction === "deny") {
return { allowed: false, requiresPrompt: false };
}
// 4. 需要提示用户
return { allowed: false, requiresPrompt: true };
}
/**
* 生成规则 Key
*/
private getRuleKey(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): string {
const { type, details } = request;
switch (type) {
case "tool":
return `tool:${details.tool || "*"}`;
case "command":
return `command:${details.command || "*"}`;
case "file":
return `file:${details.file || "*"}`;
case "network":
return `network:${details.url || "*"}`;
case "sandbox":
return "sandbox:execute";
default:
return `${type}:*`;
}
}
/**
* 匹配模式
*/
private matchPattern(key: string, pattern: string): boolean {
// 支持通配符
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
return regex.test(key);
}
/**
* 创建权限请求
*/
createRequest(
request: Omit<PermissionRequest, "id" | "timestamp" | "status">,
): PermissionRequest {
const fullRequest: PermissionRequest = {
...request,
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
status: "pending",
};
this.pendingRequests.set(fullRequest.id, fullRequest);
return fullRequest;
}
/**
* 批准请求
*/
approveRequest(requestId: string, alwaysAllow: boolean = false): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) return false;
request.status = "approved";
// 如果选择"总是允许",添加到会话授权
if (alwaysAllow) {
const key = this.getRuleKey(request);
if (!this.sessionApprovedTools.has(request.sessionId)) {
this.sessionApprovedTools.set(request.sessionId, new Map());
}
this.sessionApprovedTools.get(request.sessionId)!.set(key, true);
}
return true;
}
/**
* 拒绝请求
*/
denyRequest(requestId: string): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) return false;
request.status = "denied";
return true;
}
/**
* 获取待处理请求
*/
getPendingRequests(sessionId?: string): PermissionRequest[] {
const requests = Array.from(this.pendingRequests.values()).filter(
(r) => r.status === "pending",
);
if (sessionId) {
return requests.filter((r) => r.sessionId === sessionId);
}
return requests;
}
/**
* 添加规则
*/
addRule(rule: Omit<PermissionRule, "id" | "createdAt">): void {
const newRule: PermissionRule = {
...rule,
id: Date.now().toString(36),
createdAt: Date.now(),
};
this.config.rules.push(newRule);
this.saveConfig();
}
/**
* 删除规则
*/
removeRule(ruleId: string): void {
this.config.rules = this.config.rules.filter((r) => r.id !== ruleId);
this.saveConfig();
}
/**
* 获取规则列表
*/
getRules(): PermissionRule[] {
return [...this.config.rules];
}
/**
* 更新默认动作
*/
setDefaultAction(action: PermissionAction): void {
this.config.defaultAction = action;
this.saveConfig();
}
/**
* 清除会话授权
*/
clearSessionApproval(sessionId: string): void {
this.sessionApprovedTools.delete(sessionId);
}
/**
* 获取权限配置
*/
getConfig(): PermissionConfig {
return { ...this.config };
}
/**
* 更新权限配置
*/
updateConfig(config: Partial<PermissionConfig>): void {
this.config = { ...this.config, ...config };
this.saveConfig();
}
/**
* 导出配置
*/
exportConfig(): string {
return JSON.stringify(this.config, null, 2);
}
/**
* 导入配置
*/
importConfig(configJson: string): boolean {
try {
const imported = JSON.parse(configJson);
this.config = { ...DEFAULT_CONFIG, ...imported };
this.saveConfig();
return true;
} catch {
return false;
}
}
}
export const permissionManager = new PermissionManager();
export default permissionManager;

View File

@@ -0,0 +1,548 @@
/**
* Sandbox Manager - 跨平台沙箱执行
*
* 支持:
* - macOS: 应用程序隔离
* - Windows: Hyper-V/WSL 隔离
* - Linux: Docker/容器隔离
*/
import * as path from 'path';
import * as fs from 'fs';
import { spawn, ChildProcess } from 'child_process';
import log from 'electron-log';
import { APP_NAME_IDENTIFIER } from '@shared/constants';
export type Platform = 'darwin' | 'win32' | 'linux';
export interface SandboxConfig {
enabled: boolean;
type: 'none' | 'macos-app-sandbox' | 'docker' | 'wsl' | 'firejail';
image?: string; // Docker 镜像
workspaceDir: string;
}
export interface SandboxStatus {
ready: boolean;
type: string;
containerId?: string;
error?: string;
}
export interface SandboxRuntime {
id: string;
pid: number;
cwd: string;
startedAt: number;
}
// ==================== Platform Detection ====================
export function getPlatform(): Platform {
return process.platform as Platform;
}
export function isMacOS(): boolean {
return process.platform === 'darwin';
}
export function isWindows(): boolean {
return process.platform === 'win32';
}
export function isLinux(): boolean {
return process.platform === 'linux';
}
// ==================== Docker Detection ====================
export async function checkDocker(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('docker', ['--version'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
export async function checkWSL(): Promise<boolean> {
if (!isWindows()) return false;
return new Promise((resolve) => {
const proc = spawn('wsl', ['--status'], {
stdio: ['ignore', 'pipe', 'ignore'],
});
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
// ==================== Sandbox Manager ====================
class SandboxManager {
private config: SandboxConfig;
private runtime: SandboxRuntime | null = null;
private process: ChildProcess | null = null;
constructor() {
this.config = {
enabled: false,
type: 'none',
workspaceDir: '',
};
}
/**
* 初始化沙箱配置
*/
async init(config: Partial<SandboxConfig>): Promise<void> {
this.config = {
enabled: false,
type: 'none',
workspaceDir: config.workspaceDir || '',
...config,
};
// 自动检测可用沙箱类型
if (this.config.enabled) {
await this.detectAvailableSandbox();
}
}
/**
* 检测可用沙箱
*/
async detectAvailableSandbox(): Promise<string> {
const platform = getPlatform();
if (platform === 'darwin') {
// macOS: 尝试 Docker
const hasDocker = await checkDocker();
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
// 回退到应用沙箱
this.config.type = 'macos-app-sandbox';
return 'macos-app-sandbox';
}
if (platform === 'win32') {
// Windows: 尝试 WSL 或 Docker
const hasWSL = await checkWSL();
const hasDocker = await checkDocker();
if (hasWSL) {
this.config.type = 'wsl';
return 'wsl';
}
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
this.config.type = 'none';
return 'none';
}
if (platform === 'linux') {
// Linux: Docker 或 Firejail
const hasDocker = await checkDocker();
if (hasDocker) {
this.config.type = 'docker';
return 'docker';
}
// 尝试 firejail
const hasFirejail = await this.checkCommand('firejail');
if (hasFirejail) {
this.config.type = 'firejail';
return 'firejail';
}
this.config.type = 'none';
return 'none';
}
return 'none';
}
private async checkCommand(cmd: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCmd = process.platform === 'win32' ? 'where' : 'which';
const proc = spawn(checkCmd, [cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
/**
* 启动沙箱
*/
async start(image?: string): Promise<{ success: boolean; error?: string }> {
if (!this.config.enabled || this.config.type === 'none') {
return { success: true };
}
const platform = getPlatform();
try {
switch (this.config.type) {
case 'docker':
return await this.startDocker(image || this.config.image || 'alpine');
case 'wsl':
return await this.startWSL();
case 'firejail':
return await this.startFirejail();
case 'macos-app-sandbox':
return { success: true }; // macOS 应用沙箱由系统管理
default:
return { success: true };
}
} catch (error) {
log.error('[Sandbox] Start failed:', error);
return { success: false, error: String(error) };
}
}
/**
* 启动 Docker 沙箱
*/
private async startDocker(image: string): Promise<{ success: boolean; error?: string }> {
const containerName = `${APP_NAME_IDENTIFIER}-${Date.now()}`;
const workspace = this.config.workspaceDir;
return new Promise((resolve) => {
// 启动 Docker 容器
const proc = spawn('docker', [
'run',
'-d',
'--name', containerName,
'-v', `${workspace}:/workspace`,
'-w', '/workspace',
image,
'sleep', 'infinity',
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stderr = '';
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
proc.on('close', (code) => {
if (code === 0) {
this.runtime = {
id: containerName,
pid: proc.pid || 0,
cwd: workspace,
startedAt: Date.now(),
};
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || 'Docker start failed' });
}
});
});
}
/**
* 启动 WSL 沙箱
*/
private async startWSL(): Promise<{ success: boolean; error?: string }> {
const workspace = this.config.workspaceDir;
return new Promise((resolve) => {
// 在 WSL 中启动
const proc = spawn('wsl', [
'bash', '-c',
`cd "${workspace}" && sleep infinity`
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
proc.on('close', (code) => {
resolve({ success: code === 0 });
});
// 等待启动
setTimeout(() => {
if (proc.pid) {
this.runtime = {
id: 'wsl',
pid: proc.pid,
cwd: workspace,
startedAt: Date.now(),
};
resolve({ success: true });
}
}, 2000);
});
}
/**
* 启动 Firejail 沙箱
*/
private async startFirejail(): Promise<{ success: boolean; error?: string }> {
// Firejail 需要在运行命令时使用,不需要预先启动
return { success: true };
}
/**
* 在沙箱中执行命令
*/
async execute(
command: string,
args: string[] = [],
options?: { env?: Record<string, string>; cwd?: string }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const platform = getPlatform();
const cwd = options?.cwd || this.config.workspaceDir;
// 如果未启用沙箱,直接执行
if (!this.config.enabled || this.config.type === 'none') {
return this.executeDirect(command, args, options);
}
try {
switch (this.config.type) {
case 'docker':
return await this.executeInDocker(command, args, { cwd, ...options });
case 'wsl':
return await this.executeInWSL(command, args, { cwd, ...options });
case 'firejail':
return await this.executeInFirejail(command, args, { cwd, ...options });
default:
return this.executeDirect(command, args, options);
}
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* 直接执行
*/
private executeDirect(
command: string,
args: string[],
options?: { env?: Record<string, string>; cwd?: string }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd: options?.cwd,
env: { ...process.env, ...options?.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({
success: code === 0,
stdout,
stderr,
});
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 Docker 中执行
*/
private executeInDocker(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
if (!this.runtime?.id) {
return { success: false, error: 'Sandbox not running' };
}
const dockerArgs = [
'exec',
'-i',
this.runtime.id,
'sh', '-c',
`${command} ${args.join(' ')}`
];
return new Promise((resolve) => {
const proc = spawn('docker', dockerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 WSL 中执行
*/
private executeInWSL(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const cwd = options?.cwd || this.config.workspaceDir;
const fullCommand = `${command} ${args.join(' ')}`;
return new Promise((resolve) => {
const proc = spawn('wsl', [
'bash', '-c',
`cd "${cwd}" && ${fullCommand}`
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 在 Firejail 中执行
*/
private executeInFirejail(
command: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> }
): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const cwd = options?.cwd || this.config.workspaceDir;
return new Promise((resolve) => {
const proc = spawn('firejail', [
'--noprofile',
`--directory=${cwd}`,
'--',
command,
...args
], {
cwd,
env: { ...process.env, ...options?.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
resolve({ success: code === 0, stdout, stderr });
});
proc.on('error', (error) => {
resolve({ success: false, error: error.message });
});
});
}
/**
* 停止沙箱
*/
async stop(): Promise<void> {
if (!this.runtime) return;
try {
if (this.config.type === 'docker' && this.runtime.id) {
spawn('docker', ['stop', this.runtime.id], {
stdio: 'ignore',
windowsHide: true,
});
spawn('docker', ['rm', '-f', this.runtime.id], {
stdio: 'ignore',
windowsHide: true,
});
}
if (this.process) {
this.process.kill();
}
} catch (error) {
log.error('[Sandbox] Stop error:', error);
}
this.runtime = null;
this.process = null;
}
/**
* 获取状态
*/
getStatus(): SandboxStatus {
return {
ready: !!this.runtime,
type: this.config.type,
containerId: this.runtime?.id,
};
}
/**
* 获取配置
*/
getConfig(): SandboxConfig {
return { ...this.config };
}
/**
* 是否启用沙箱
*/
isEnabled(): boolean {
return this.config.enabled;
}
}
export const sandboxManager = new SandboxManager();
export default sandboxManager;

View File

@@ -0,0 +1 @@
export * from './core/ai';

View File

@@ -0,0 +1 @@
export * from './core/api';

View File

@@ -0,0 +1 @@
export * from './core/auth';

View File

@@ -0,0 +1,86 @@
import Anthropic from '@anthropic-ai/sdk';
export interface AIConfig {
apiKey: string;
model: string;
maxTokens?: number;
}
export interface Message {
role: 'user' | 'assistant';
content: string;
}
export interface ChatOptions {
messages: Message[];
systemPrompt?: string;
model?: string;
maxTokens?: number;
onChunk?: (text: string) => void;
}
class AIService {
private client: Anthropic | null = null;
private config: AIConfig | null = null;
configure(config: AIConfig) {
this.config = config;
this.client = new Anthropic({
apiKey: config.apiKey,
});
}
isConfigured(): boolean {
return this.client !== null;
}
async chat(options: ChatOptions): Promise<string> {
if (!this.client || !this.config) {
throw new Error('AI service not configured');
}
const model = options.model || this.config.model;
const system = options.systemPrompt || 'You are a helpful AI assistant.';
const response = await this.client.messages.create({
model,
max_tokens: options.maxTokens || this.config.maxTokens || 4096,
system,
messages: options.messages.map((m) => ({
role: m.role,
content: m.content,
})),
});
return response.content[0]?.type === 'text' ? response.content[0].text : '';
}
async *streamChat(options: ChatOptions): AsyncGenerator<string> {
if (!this.client || !this.config) {
throw new Error('AI service not configured');
}
const model = options.model || this.config.model;
const system = options.systemPrompt || 'You are a helpful AI assistant.';
const response = await this.client.messages.create({
model,
max_tokens: options.maxTokens || this.config.maxTokens || 4096,
system,
messages: options.messages.map((m) => ({
role: m.role,
content: m.content,
})),
stream: true,
});
// @ts-ignore - streaming response
for await (const chunk of response) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
yield chunk.delta.text;
}
}
}
}
export const aiService = new AIService();

View File

@@ -0,0 +1,191 @@
/**
* 单元测试: api (请求封装)
*
* 覆盖场景:
* - fetch 超时AbortSignal.timeout 触发后抛出可读错误P0-3 修复验证)
* - 正常请求:成功返回 data
* - HTTP 错误:非 2xx 响应正确抛出
* - API 业务错误码:非 0000 code 正确抛出
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ==================== Mocks ====================
// Mock antd message避免 DOM 依赖)
vi.mock('antd', () => ({
message: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(),
info: vi.fn(),
},
}));
// Mock @shared/constants
vi.mock('@shared/constants', () => ({
DEFAULT_SERVER_HOST: 'https://default.example.com',
DEFAULT_API_TIMEOUT: 30000,
}));
// ==================== Helpers ====================
/** 构造一个标准成功响应 */
function makeSuccessResponse<T>(data: T): Response {
return new Response(
JSON.stringify({ code: '0000', message: 'ok', success: true, data }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
/** 构造一个业务错误响应HTTP 200 但 code != 0000 */
function makeApiErrorResponse(code: string, msg: string): Response {
return new Response(
JSON.stringify({ code, message: msg, success: false, data: null }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// 每次需要 fresh module避免 vi.mock 缓存影响)
async function loadApi() {
vi.resetModules();
return import('./api');
}
// ==================== Tests ====================
describe('apiRequest', () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
// ---------- 正常请求 ----------
it('请求成功时应返回 data 字段', async () => {
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse({ id: 1, name: 'test' }));
const { apiRequest } = await loadApi();
const result = await apiRequest<{ id: number; name: string }>('/test', {
method: 'POST',
showError: false,
});
expect(result).toEqual({ id: 1, name: 'test' });
});
it('应将 AbortSignal.timeout 传入 fetchP0-3 修复验证)', async () => {
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse({}));
const { apiRequest } = await loadApi();
await apiRequest('/test', { showError: false });
const [, fetchOptions] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
// 验证 signal 字段存在AbortSignal.timeout 已挂载)
expect(fetchOptions.signal).toBeDefined();
expect(fetchOptions.signal).toBeInstanceOf(AbortSignal);
});
// ---------- 超时处理P0-3----------
it('fetch 超时TimeoutError→ 应抛出包含"请求超时"的错误,不暴露原始 AbortErrorP0-3 修复验证)', async () => {
// 模拟 AbortSignal.timeout 触发后 fetch 抛出 TimeoutError
const timeoutError = Object.assign(new Error('The operation timed out.'), {
name: 'TimeoutError',
});
global.fetch = vi.fn().mockRejectedValue(timeoutError);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow(/请求超时/);
});
it('fetch AbortError → 同样转为可读超时错误P0-3 修复验证)', async () => {
const abortError = Object.assign(new Error('The user aborted a request.'), {
name: 'AbortError',
});
global.fetch = vi.fn().mockRejectedValue(abortError);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow(/请求超时/);
});
it('超时时 showError=false 不弹 toast', async () => {
const { message } = await import('antd');
const timeoutError = Object.assign(new Error('timeout'), { name: 'TimeoutError' });
global.fetch = vi.fn().mockRejectedValue(timeoutError);
const { apiRequest } = await loadApi();
await expect(apiRequest('/test', { showError: false })).rejects.toThrow();
expect(message.error).not.toHaveBeenCalled();
});
// ---------- HTTP 错误 ----------
it('HTTP 非 2xx → 应抛出 HTTP 错误', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }),
);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow('HTTP 500');
});
// ---------- 业务错误码 ----------
it('业务错误码非 0000 → 应抛出业务错误', async () => {
global.fetch = vi.fn().mockResolvedValue(
makeApiErrorResponse('4010', '用户未登录,请重新登录'),
);
const { apiRequest } = await loadApi();
await expect(
apiRequest('/test', { showError: false }),
).rejects.toThrow('用户未登录,请重新登录');
});
// ---------- registerClient ----------
it('registerClient 应正确透传参数并返回响应', async () => {
const mockData = {
id: 1, scope: 'default', userId: 1, name: 'Bot',
configKey: 'ck-abc', configValue: {}, description: '',
isActive: true, online: true, created: '', modified: '',
};
global.fetch = vi.fn().mockResolvedValue(makeSuccessResponse(mockData));
const { registerClient } = await loadApi();
const result = await registerClient(
{
username: 'user1',
password: 'pass1',
sandboxConfigValue: { agentPort: 4000, vncPort: 0, fileServerPort: 60000 },
},
{ suppressToast: true },
);
expect(result.configKey).toBe('ck-abc');
// 验证请求 body 包含正确参数
const [, fetchOptions] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchOptions.body);
expect(body.username).toBe('user1');
expect(body.password).toBe('pass1');
});
});

View File

@@ -0,0 +1,240 @@
/**
* QimingClaw API 请求封装
* 统一处理请求、响应、错误码
*/
import { message } from "antd";
import { DEFAULT_SERVER_HOST, DEFAULT_API_TIMEOUT } from "@shared/constants";
import { logger } from "../utils/logService";
import { t } from "./i18n";
// 错误码定义
const SUCCESS_CODE = "0000";
/**
* 错误码 → i18n key不在模块加载时调用 t
*
* 说明i18n.ts 会 import apiRequest若此处在顶层执行 t(),会与 i18n 形成循环依赖,
* 此时 t 尚未完成初始化,运行时报 “Cannot access 't' before initialization”。
* 仅在 apiRequest 执行时再 t(key),此时模块图已就绪。
*/
const ERROR_MESSAGE_KEYS: Record<string, string> = {
"0000": "Claw.Api.success",
"4010": "Claw.Api.notLoggedIn",
"4011": "Claw.Api.loginExpired",
"1001": "Claw.Api.clientNotFound",
"9999": "Claw.Api.systemError",
};
/** 按错误码取已翻译的兜底文案(无映射时返回 undefined */
function translatedErrorForCode(code: string): string | undefined {
const key = ERROR_MESSAGE_KEYS[code];
return key ? t(key) : undefined;
}
// 响应类型定义(内部使用)
interface ApiResponse<T = any> {
code: string;
displayCode?: string;
message: string;
success: boolean;
data: T;
tid?: string;
}
// 请求配置(内部使用)
interface RequestConfig {
baseUrl?: string;
timeout?: number;
headers?: Record<string, string>;
cache?: RequestCache;
}
// 默认配置
const DEFAULT_CONFIG: RequestConfig = {
baseUrl: DEFAULT_SERVER_HOST,
timeout: DEFAULT_API_TIMEOUT,
};
/**
* 统一的 API 请求函数
*/
export async function apiRequest<T>(
url: string,
options: {
method?: "GET" | "POST" | "PUT" | "DELETE";
data?: any;
params?: Record<string, any>;
headers?: Record<string, string>;
showError?: boolean;
baseUrl?: string;
cache?: RequestCache;
} = {},
): Promise<T> {
const config = { ...DEFAULT_CONFIG, ...options };
const fullUrl = `${config.baseUrl}${url}`;
// 使用 AbortSignal.timeout 实现请求超时,避免网络挂起时永久阻塞。
// 运行时要求Electron 40+Chromium 120+)支持 AbortSignal.timeout若需兼容更旧版本需 polyfill如 setTimeout + AbortController
const timeoutMs = config.timeout ?? DEFAULT_API_TIMEOUT;
const fetchOptions: RequestInit = {
method: options.method || "POST",
headers: {
"Content-Type": "application/json",
...config.headers,
},
signal: AbortSignal.timeout(timeoutMs),
...(config.cache ? { cache: config.cache } : {}),
};
if (options.data) {
fetchOptions.body = JSON.stringify(options.data);
}
let finalUrl = fullUrl;
if (options.params) {
const searchParams = new URLSearchParams();
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
if (queryString) {
finalUrl = `${fullUrl}?${queryString}`;
}
}
try {
const response = await fetch(finalUrl, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: ApiResponse<T> = await response.json();
// 统一错误处理
if (result.code !== SUCCESS_CODE) {
const errorMsg =
result.message ||
translatedErrorForCode(result.code) ||
`请求失败 (错误码: ${result.code})`;
logger.error("API Error", "API", {
code: result.code,
message: result.message,
});
if (options.showError !== false) {
message.error(errorMsg);
}
throw new Error(errorMsg);
}
return result.data;
} catch (error: any) {
// AbortSignal.timeout 超时后抛出 TimeoutErrorname === 'TimeoutError'
// 或 AbortError某些环境统一转为可读错误
if (error.name === "TimeoutError" || error.name === "AbortError") {
const timeoutMsg = t("Claw.Api.timeout", timeoutMs);
logger.error("Request Timeout", "API", { url: finalUrl });
if (options.showError !== false) {
message.error(timeoutMsg);
}
throw new Error(timeoutMsg);
}
logger.error("Request Error", "API", error);
// 检测是否是重定向到登录页面的情况(后端返回 HTML
const isLoginRedirect =
error.message?.includes("/login") || error.message?.includes("redirect");
// 生成用户友好的错误信息
let userMessage: string = "";
if (isLoginRedirect) {
userMessage = t("Claw.Errors.loginRedirect");
} else if (options.showError !== false && error.message) {
userMessage = error.message;
}
if (options.showError !== false && userMessage) {
message.error(userMessage);
}
throw error;
}
}
// ========== 客户端注册接口 ==========
/**
* 沙盒配置值
*/
export interface SandboxValue {
hostWithScheme?: string;
agentPort: number;
vncPort: number;
fileServerPort: number;
guiMcpPort: number;
adminServerPort: number;
apiKey?: string;
maxUsers?: number;
}
/**
* 客户端注册请求参数
*/
export interface ClientRegisterParams {
username: string;
password: string;
savedKey?: string;
deviceId?: string;
sandboxConfigValue: SandboxValue;
}
/**
* 客户端注册响应数据 (SandboxConfigDto)
*/
export interface ClientRegisterResponse {
id: number;
scope: string;
userId: number;
name: string;
configKey: string;
configValue: SandboxValue;
description: string;
isActive: boolean;
online: boolean;
created: string;
modified: string;
/** 服务器地址(客户端连接用) */
serverHost?: string;
/** 服务器端口(客户端连接用) */
serverPort?: number;
/** 登录态 token用于 webview cookie 同步 */
token?: string;
}
/**
* 注册客户端
*
* @param params 注册参数
* @returns 注册响应数据
*/
export async function registerClient(
params: ClientRegisterParams,
options?: {
baseUrl?: string;
suppressToast?: boolean;
},
): Promise<ClientRegisterResponse> {
return apiRequest<ClientRegisterResponse>("/api/sandbox/config/reg", {
method: "POST",
data: params,
showError: !options?.suppressToast,
baseUrl: options?.baseUrl,
});
}

View File

@@ -0,0 +1,370 @@
/**
* 单元测试: auth (快捷登录 / savedKey 认证)
*
* 覆盖场景:
* - savedKey 认证下 username/password 为空的登录判断
* - syncConfigToServer / reRegisterClient 的 guard 条件
* - logout 后用账号密码重新登录
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ==================== Mocks ====================
// In-memory settings store (模拟 SQLite)
let store: Record<string, unknown> = {};
const mockSettingsGet = vi.fn(async (key: string) => store[key] ?? null);
const mockSettingsSet = vi.fn(async (key: string, value: unknown) => {
if (value === null || value === undefined) {
delete store[key];
} else {
store[key] = value;
}
});
// Mock window.electronAPI
vi.stubGlobal("window", {
electronAPI: {
app: {
getDeviceId: vi.fn(async () => "mock-device-id"),
},
settings: {
get: mockSettingsGet,
set: mockSettingsSet,
},
},
});
// Mock antd message
vi.mock("antd", () => ({
message: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
// Mock registerClient API
const mockRegisterClient = vi.fn();
vi.mock("./api", () => ({
registerClient: (...args: unknown[]) => mockRegisterClient(...args),
}));
// ==================== Helpers ====================
const DOMAIN = "https://testagent.xspaceagi.com";
const SAVED_KEY = "test-saved-key-abc123";
const CONFIG_KEY_FROM_SERVER = "server-returned-config-key";
function makeRegisterResponse(overrides?: Partial<Record<string, unknown>>) {
return {
id: 1,
scope: "default",
userId: 1,
name: "TestUser",
configKey: CONFIG_KEY_FROM_SERVER,
configValue: {},
description: "",
isActive: true,
online: true,
created: "",
modified: "",
serverHost: "proxy.example.com",
serverPort: 4900,
...overrides,
};
}
// ==================== Tests ====================
describe("auth - savedKey 认证 (快捷登录)", () => {
beforeEach(() => {
vi.clearAllMocks();
store = {};
mockRegisterClient.mockResolvedValue(makeRegisterResponse());
});
// 每次需要 fresh module避免内部状态缓存
async function loadAuth() {
vi.resetModules();
return import("./auth");
}
// ---------- isLoggedIn ----------
describe("isLoggedIn", () => {
it("应以 configKey 为准,不依赖 username", async () => {
store["auth.config_key"] = "some-config-key";
// username 不存在
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(true);
});
it("configKey 为空时应返回 false", async () => {
store["auth.username"] = "user1";
// configKey 不存在
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(false);
});
it("username 为空字符串 + configKey 存在 → 已登录", async () => {
store["auth.username"] = "";
store["auth.config_key"] = "key";
const { isLoggedIn } = await loadAuth();
expect(await isLoggedIn()).toBe(true);
});
});
// ---------- getCurrentAuth ----------
describe("getCurrentAuth", () => {
it("应以 configKey 判断 isLoggedIn与 username 无关", async () => {
store["auth.config_key"] = "key";
store["auth.user_info"] = { username: "", displayName: "Bot" };
const { getCurrentAuth } = await loadAuth();
const auth = await getCurrentAuth();
expect(auth.isLoggedIn).toBe(true);
expect(auth.username).toBeNull(); // username key 未设置
});
});
// ---------- syncConfigToServer ----------
describe("syncConfigToServer", () => {
it("username 和 password 均为空字符串 + 无 savedKey → 应拒绝P1-5 修复验证)", async () => {
// 修复前:空字符串不等于 null会绕过 guard 向后端发空凭证请求
// 修复后:!'' === true正确拦截
store["auth.username"] = "";
store["auth.password"] = "";
// 不设置 savedKey
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("username/password 为空字符串 + 有 savedKey → 应正常同步", async () => {
store["auth.username"] = "";
store["auth.password"] = "";
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).not.toBeNull();
expect(result!.configKey).toBe(CONFIG_KEY_FROM_SERVER);
expect(mockRegisterClient).toHaveBeenCalledTimes(1);
// 验证请求参数
const [params] = mockRegisterClient.mock.calls[0];
expect(params.username).toBe("");
expect(params.password).toBe("");
expect(params.savedKey).toBe(SAVED_KEY);
});
it("username/password 为 null + 有 savedKey → 应正常同步", async () => {
// username/password 未设置null
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).not.toBeNull();
const [params] = mockRegisterClient.mock.calls[0];
expect(params.username).toBe("");
expect(params.password).toBe("");
expect(params.savedKey).toBe(SAVED_KEY);
});
it("无 username/password/savedKey → 应拒绝", async () => {
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("有 username 但无 savedKey → 应拒绝(密码不再持久化,必须依赖 savedKey", async () => {
store["auth.username"] = "user1";
// 密码不再持久化保存,不设置 savedKey 时无法同步
store["step1_config"] = { serverHost: DOMAIN };
const { syncConfigToServer } = await loadAuth();
const result = await syncConfigToServer({ suppressToast: true });
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
});
// ---------- reRegisterClient ----------
describe("reRegisterClient", () => {
it("username/password 为 null + 有 savedKey → 应正常重注册", async () => {
store["auth.saved_key"] = SAVED_KEY;
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).not.toBeNull();
expect(mockRegisterClient).toHaveBeenCalledTimes(1);
});
it("无任何凭证 → 应拒绝", async () => {
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("有 domain+username → 应使用域名级 savedKey而非全局 keyP0-1 修复验证)", async () => {
// 域名级 key 与全局 key 故意不同,验证使用的是域名级
store["auth.username"] = "user1";
store["lanproxy.server_host"] = DOMAIN; // AUTH_KEYS.LANPROXY_SERVER_HOST
store["auth.saved_keys.testagent.xspaceagi.com_user1"] =
"domain-specific-key";
store["auth.saved_key"] = "global-key-different";
const { reRegisterClient } = await loadAuth();
await reRegisterClient();
const [params] = mockRegisterClient.mock.calls[0];
// 应使用域名级 savedKey不应使用全局 key
expect(params.savedKey).toBe("domain-specific-key");
});
it("多账号切换:当前用户无专属 savedKey 时,应拒绝(不应使用全局 key 中其他账号的凭证)", async () => {
// 模拟用户A之前登录全局 key 被覆盖为 A 的凭证
store["auth.saved_keys.testagent.xspaceagi.com_userA"] = "key-for-userA";
store["auth.saved_key"] = "key-for-userA"; // 全局 key 指向 A
// 当前切换为用户B没有域名级专属 key
store["auth.username"] = "userB";
store["lanproxy.server_host"] = DOMAIN; // AUTH_KEYS.LANPROXY_SERVER_HOST
// 不设置 auth.saved_keys.*.userB
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
// 用户B无专属 key应拒绝重注册不应使用用户A的 key
expect(result).toBeNull();
expect(mockRegisterClient).not.toHaveBeenCalled();
});
it("无 domain 信息时 → 应回退到全局 savedKey", async () => {
// 没有 domain 配置,只有全局 key
store["auth.username"] = "user1";
// 不设置 lanproxy_server_host 和 step1_config
store["auth.saved_key"] = SAVED_KEY;
const { reRegisterClient } = await loadAuth();
const result = await reRegisterClient();
expect(result).not.toBeNull();
const [params] = mockRegisterClient.mock.calls[0];
// 无 domain 时 fallback 到全局 key
expect(params.savedKey).toBe(SAVED_KEY);
});
});
// ---------- loginAndRegister ----------
describe("loginAndRegister", () => {
it("空 username + 空 password + savedKey → 应正常登录", async () => {
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
const { loginAndRegister } = await loadAuth();
const result = await loginAndRegister("", "", {
suppressToast: true,
domain: DOMAIN,
});
expect(result.configKey).toBe(CONFIG_KEY_FROM_SERVER);
// 验证登录后存储了 configKey
expect(store["auth.config_key"]).toBe(CONFIG_KEY_FROM_SERVER);
// savedKey 更新为服务端返回的 configKey
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
});
it("正常 username + password → 应正常登录(密码不持久化)", async () => {
store["step1_config"] = { serverHost: DOMAIN };
const { loginAndRegister } = await loadAuth();
const result = await loginAndRegister("zhangsan", "abc123", {
suppressToast: true,
domain: DOMAIN,
});
expect(result.configKey).toBe(CONFIG_KEY_FROM_SERVER);
expect(store["auth.username"]).toBe("zhangsan");
// 密码不再持久化保存
expect(store["auth.password"]).toBeUndefined();
// savedKey 应保存
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
});
});
// ---------- 完整场景: 快捷登录 → 退出 → 重新登录 ----------
describe("完整流程: 快捷登录 → logout → 账号密码登录", () => {
it("退出后用账号密码重新登录应成功", async () => {
const auth = await loadAuth();
// 1. 快捷登录(模拟 QuickInit: 空 username + savedKey
store["auth.saved_key"] = SAVED_KEY;
store["step1_config"] = { serverHost: DOMAIN };
await auth.loginAndRegister("", "", {
suppressToast: true,
domain: DOMAIN,
});
expect(await auth.isLoggedIn()).toBe(true);
// 2. 退出登录
await auth.logout();
expect(await auth.isLoggedIn()).toBe(false);
// savedKey 应保留
expect(store["auth.saved_key"]).toBe(CONFIG_KEY_FROM_SERVER);
// configKey 应清除
expect(store["auth.config_key"]).toBeUndefined();
// 3. 用新账号密码登录
const newConfigKey = "new-config-key-for-zhangsan";
mockRegisterClient.mockResolvedValueOnce(
makeRegisterResponse({ configKey: newConfigKey }),
);
await auth.loginAndRegister("zhangsan", "dynamic-code", {
suppressToast: true,
domain: DOMAIN,
});
expect(await auth.isLoggedIn()).toBe(true);
expect(store["auth.username"]).toBe("zhangsan");
expect(store["auth.config_key"]).toBe(newConfigKey);
// 域名级 savedKey 应保存
expect(store["auth.saved_keys.testagent.xspaceagi.com_zhangsan"]).toBe(
newConfigKey,
);
// 4. syncConfigToServer 应能正常工作
mockRegisterClient.mockResolvedValueOnce(
makeRegisterResponse({ configKey: newConfigKey }),
);
const syncResult = await auth.syncConfigToServer({ suppressToast: true });
expect(syncResult).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,674 @@
/**
* 认证服务 (Electron 版)
* 管理用户登录状态、ConfigKey/SavedKey 存储
* 使用 window.electronAPI.settings 替代 Tauri Store
*/
import { message } from "antd";
import {
registerClient,
ClientRegisterParams,
ClientRegisterResponse,
SandboxValue,
} from "./api";
import {
AUTH_KEYS,
LOCAL_HOST_URL,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_FILE_SERVER_PORT,
DEFAULT_GUI_MCP_PORT,
DEFAULT_ADMIN_SERVER_PORT,
} from "@shared/constants";
import { syncSessionCookie } from "../utils/sessionUrl";
import { logger } from "../utils/logService";
import {
getDomainTokenKey,
normalizeDomainForTokenKey,
} from "@shared/utils/domain";
import { t } from "./i18n";
// ========== 类型定义 ===
export interface AuthUserInfo {
id?: number;
username: string;
displayName?: string;
avatar?: string;
email?: string;
phone?: string;
currentDomain?: string;
}
// ========== 存储辅助函数 ===
async function settingsGet<T>(key: string): Promise<T | null> {
try {
const value = await window.electronAPI?.settings.get(key);
return (value as T) ?? null;
} catch {
return null;
}
}
async function settingsSet(key: string, value: unknown): Promise<void> {
await window.electronAPI?.settings.set(key, value);
}
// 域名标准化使用共享函数 normalizeDomainForTokenKey
// 保留别名以兼容现有调用
const normalizeDomain = normalizeDomainForTokenKey;
// ========== 存储操作 ===
async function getUsername(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.USERNAME);
}
async function setUsername(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.USERNAME, value);
}
async function getPassword(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.PASSWORD);
}
async function setPassword(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.PASSWORD, value);
}
async function getConfigKey(): Promise<string | null> {
return settingsGet<string>(AUTH_KEYS.CONFIG_KEY);
}
async function setConfigKey(value: string): Promise<void> {
await settingsSet(AUTH_KEYS.CONFIG_KEY, value);
}
async function getSavedKey(
domain?: string,
username?: string,
): Promise<string | null> {
if (domain && username) {
const key = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${normalizeDomain(domain)}_${username}`;
return settingsGet<string>(key);
}
return settingsGet<string>(AUTH_KEYS.SAVED_KEY);
}
async function setSavedKey(
value: string,
domain?: string,
username?: string,
): Promise<void> {
if (domain && username) {
const key = `${AUTH_KEYS.SAVED_KEYS_PREFIX}${normalizeDomain(domain)}_${username}`;
await settingsSet(key, value);
}
await settingsSet(AUTH_KEYS.SAVED_KEY, value);
}
async function getUserInfo(): Promise<AuthUserInfo | null> {
return settingsGet<AuthUserInfo>(AUTH_KEYS.USER_INFO);
}
async function setUserInfo(value: AuthUserInfo): Promise<void> {
await settingsSet(AUTH_KEYS.USER_INFO, value);
}
async function setOnlineStatus(value: boolean): Promise<void> {
await settingsSet(AUTH_KEYS.ONLINE_STATUS, value);
}
async function saveServerConfig(
serverHost: string,
serverPort: number,
): Promise<void> {
await settingsSet(AUTH_KEYS.LANPROXY_SERVER_HOST, serverHost);
await settingsSet(AUTH_KEYS.LANPROXY_SERVER_PORT, serverPort);
// 同步到 lanproxy_configLanproxySettings 可编辑的配置)
// clientKey 不存入 lanproxy_config始终从 auth.saved_key 读取(参考 Tauri 客户端)
const existing =
await settingsGet<Record<string, unknown>>("lanproxy_config");
await settingsSet("lanproxy_config", {
...existing,
serverIp: serverHost.replace(/^https?:\/\//, ""),
serverPort,
enabled: true,
});
logger.info("Lanproxy server config saved", "Auth", {
serverHost,
serverPort,
});
}
async function clearAuthInfo(): Promise<void> {
await settingsSet(AUTH_KEYS.USERNAME, null);
await settingsSet(AUTH_KEYS.PASSWORD, null);
await settingsSet(AUTH_KEYS.CONFIG_KEY, null);
await settingsSet(AUTH_KEYS.USER_INFO, null);
await settingsSet(AUTH_KEYS.ONLINE_STATUS, null);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
// 不清除 savedKey跨登录会话持久化
}
// ========== Token 缓存辅助函数 ===
/**
* 缓存登录 token 到 one-shot 和域名级别存储
* 尝试立即同步到 webview cookie成功后清除 one-shot token
*/
async function cacheAndSyncToken(
domain: string,
token: string,
logTag: string = "Auth",
): Promise<void> {
// 写入 one-shot token给后续打开 webview 用)
await settingsSet(AUTH_KEYS.AUTH_TOKEN, token);
// 写入域名级别缓存(兜底)
const domainTokenKey = getDomainTokenKey(domain);
await settingsSet(domainTokenKey, token);
logger.info("Login token cache written", logTag, { domain, domainTokenKey });
// 尝试立即同步到 webview cookie
try {
await syncSessionCookie(domain, token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", logTag);
} catch (e) {
logger.warn("Token sync failed, keeping local cache", logTag, e);
}
}
// ========== 获取本地沙箱配置 ===
async function getLocalSandboxValue(): Promise<SandboxValue> {
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
agentPort?: number;
fileServerPort?: number;
guiMcpPort?: number;
adminServerPort?: number;
} | null;
return {
hostWithScheme: LOCAL_HOST_URL,
agentPort: step1Config?.agentPort ?? DEFAULT_AGENT_RUNNER_PORT,
vncPort: 0, // vncPort 未启用
fileServerPort: step1Config?.fileServerPort ?? DEFAULT_FILE_SERVER_PORT,
guiMcpPort: step1Config?.guiMcpPort ?? DEFAULT_GUI_MCP_PORT,
// Admin Server 已合并到 Computer Server端口与 agentPort 相同
adminServerPort: step1Config?.agentPort ?? DEFAULT_AGENT_RUNNER_PORT,
apiKey: "",
maxUsers: 1,
};
}
// ========== 错误处理 ===
/**
* 获取友好的错误信息
*/
export function getAuthErrorMessage(error: any): string {
if (error?.message) {
return error.message;
}
if (error?.data?.message) {
return error.data.message;
}
const errorCodeMessages: Record<string, string> = {
"1001": t("Claw.Auth.error.userNotFound"),
"1002": t("Claw.Auth.error.wrongPassword"),
"1003": t("Claw.Auth.error.accountDisabled"),
"2001": t("Claw.Auth.error.clientNotFound"),
"2002": t("Claw.Auth.error.clientDisabled"),
"2003": t("Claw.Auth.error.configNotFound"),
"4010": t("Claw.Auth.error.loginExpired"),
"4011": t("Claw.Auth.error.loginExpired"),
"9999": t("Claw.Auth.error.systemError"),
};
if (error?.data?.code && errorCodeMessages[error.data.code]) {
return errorCodeMessages[error.data.code];
}
if (error?.status === 403) return t("Claw.Auth.error.forbidden");
if (error?.status === 404) return t("Claw.Auth.error.notFound");
if (error?.status === 500) return t("Claw.Auth.error.serverError");
return t("Claw.Auth.error.loginFailed");
}
// ========== 域名标准化 ===
export function normalizeServerHost(input: string): string {
let value = input.trim();
if (!value) return value;
value = value.replace(/\/+$/, "");
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
// ========== 核心认证函数 ===
/**
* 登录并注册客户端
*/
export async function loginAndRegister(
username: string,
password: string,
options?: { suppressToast?: boolean; domain?: string },
): Promise<ClientRegisterResponse> {
const suppressToast = options?.suppressToast === true;
// 获取并规范化域名
// 优先级:用户显式传入 > lanproxy.server_host > step1_config.serverHost
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
let rawDomain = options?.domain || "";
if (!rawDomain) {
rawDomain =
(await settingsGet<string>(AUTH_KEYS.LANPROXY_SERVER_HOST)) || "";
}
if (!rawDomain) {
rawDomain = step1Config?.serverHost || "";
}
const domain = normalizeServerHost(rawDomain);
// 域名变更时写回设置
if (domain && step1Config && domain !== step1Config.serverHost) {
await window.electronAPI?.settings.set("step1_config", {
...step1Config,
serverHost: domain,
});
}
// 获取保存的 savedKey
const savedKey = await getSavedKey(domain, username);
// 构建注册参数
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username,
password,
savedKey: savedKey || undefined,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const loadingKey = "loginLoading";
if (!suppressToast) {
message.loading({
content: t("Claw.Auth.loggingIn"),
key: loadingKey,
duration: 0,
});
}
try {
const response = await registerClient(params, {
baseUrl: domain,
suppressToast: true,
});
// 保存认证信息(不保存密码,后续认证使用 savedKey
await setUsername(username);
// 密码不持久化保存savedKeyconfigKey用于后续自动认证
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username);
await setUserInfo({
id: response.id,
username,
displayName: response.name,
currentDomain: domain,
});
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domainTokenKey) {
await settingsSet(domainTokenKey, response.token);
}
logger.info("Login token cache written", "Auth", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "Auth");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "Auth", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"Auth",
{
domain,
},
);
}
// 保存 lanproxy 服务器配置
logger.info("API returned lanproxy config", "Auth", {
serverHost: response.serverHost,
serverPort: response.serverPort,
});
if (response.serverHost && response.serverPort) {
await saveServerConfig(response.serverHost, response.serverPort);
} else {
logger.warn(
"API did not return serverHost/serverPort, lanproxy config not updated",
"Auth",
);
}
// Electron 版:不自动重启服务,登录完成后由 onComplete 触发主界面初始化
if (!suppressToast) {
message.success({
content: t("Claw.Setup.login.success"),
key: loadingKey,
});
}
// 不记录 configKey 全文,避免敏感信息写入控制台/日志
logger.info("Login successful", "Auth", {
configKeySet: !!response.configKey,
name: response.name,
online: response.online,
serverHost: response.serverHost,
serverPort: response.serverPort,
isNewUser: !savedKey,
});
return response;
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
// 仅记录安全信息,避免将含 password/request 的 error 对象写入控制台
logger.error("Login failed", "Auth", errorMessage);
if (!suppressToast) {
message.error({ content: errorMessage, key: loadingKey });
}
throw error;
}
}
/**
* 检查是否已登录
* savedKey 认证场景下 username/password 可为空,以 configKey 为准
*/
export async function isLoggedIn(): Promise<boolean> {
const configKey = await getConfigKey();
return !!configKey;
}
/**
* 获取当前登录信息
*/
export async function getCurrentAuth(): Promise<{
username: string | null;
configKey: string | null;
userInfo: AuthUserInfo | null;
isLoggedIn: boolean;
}> {
const username = await getUsername();
const configKey = await getConfigKey();
const userInfo = await getUserInfo();
const isLogged = !!configKey;
return {
username,
configKey,
userInfo,
isLoggedIn: isLogged,
};
}
/**
* 重新注册客户端(使用已保存的 savedKey
*
* 修复:使用 domain + username 级别的 savedKey避免多账号切换时读取到错误账号的凭证
* 注意:密码不持久化,仅依赖 savedKey 进行认证
*/
export async function reRegisterClient(): Promise<ClientRegisterResponse | null> {
const username = await getUsername();
// 读取 domain优先级step1_config.serverHost > lanproxy.server_host
// 因为 savedKey 是用 step1_config.serverHost 保存的
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
const lanproxyHost = await settingsGet<string>(
AUTH_KEYS.LANPROXY_SERVER_HOST,
);
const rawDomain = step1Config?.serverHost || lanproxyHost || "";
const domain = normalizeServerHost(rawDomain);
// 按 domain + username 取对应账号的 savedKey而非读全局 key避免多账号混淆
const savedKey =
domain && username
? await getSavedKey(domain, username)
: await getSavedKey();
// 必须有 savedKey 才能重新注册(密码不持久化)
if (!savedKey) {
logger.warn("No savedKey, cannot re-register, please login again", "Auth");
return null;
}
try {
logger.info("Re-registering client (using savedKey)...", "Auth");
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username: username || "",
password: "", // 密码不持久化,使用 savedKey 认证
savedKey,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const response = await registerClient(params, {
baseUrl: domain || undefined,
suppressToast: true,
});
// 更新 savedKey服务端可能返回新的
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username || undefined);
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domainTokenKey) {
await settingsSet(domainTokenKey, response.token);
}
logger.info("Login token cache written", "Auth", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "Auth");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "Auth", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"Auth",
{
domain,
},
);
}
logger.info("Re-registration successful", "Auth");
return response;
} catch (error) {
logger.error("Re-registration failed", "Auth", error);
return null;
}
}
/**
* 退出登录
*/
export async function logout(): Promise<void> {
await clearAuthInfo();
message.info(t("Claw.Auth.loggedOut"));
}
/**
* 同步本地配置到后端(调用 reg 接口)。
* reg 返回内容可能会变化(如 serverHost、serverPort 等),本函数会将本次返回的最新值写入配置并返回,调用方应在 reg 成功后再启动服务,以使用最新配置。
* 注意:密码不持久化,仅依赖 savedKey 进行认证
*/
export async function syncConfigToServer(options?: {
suppressToast?: boolean;
}): Promise<ClientRegisterResponse | null> {
const suppressToast = options?.suppressToast === true;
const username = await getUsername();
// 读取 domain优先级step1_config.serverHost > lanproxy.server_host。
// 说明:
// 1) step1_config.serverHost 表示"用户访问业务系统的域名"(登录页输入的域名);
// 2) lanproxy.server_host 表示"代理链路连接地址"reg 返回的 serverHost
// 3) 这里仍保留旧优先级用于请求 reg 与读取 savedKey避免影响既有登录/同步流程。
const step1Config = (await window.electronAPI?.settings.get(
"step1_config",
)) as {
serverHost?: string;
} | null;
const lanproxyHost = await settingsGet<string>(
AUTH_KEYS.LANPROXY_SERVER_HOST,
);
const rawDomain = step1Config?.serverHost || lanproxyHost || "";
const domain = normalizeServerHost(rawDomain);
// 使用持久化的 savedKey参考 Tauri 客户端:退出登录不清除,跨会话持久化)
const savedKey = await getSavedKey(domain, username || undefined);
// 必须有 savedKey 才能同步(密码不持久化)
if (!savedKey) {
logger.warn(
"No savedKey, cannot sync config, please login again",
"SyncConfig",
);
return null;
}
const deviceId = await window.electronAPI?.app.getDeviceId();
const params: ClientRegisterParams = {
username: username || "",
password: "", // 密码不持久化,使用 savedKey 认证
savedKey,
deviceId: deviceId || undefined,
sandboxConfigValue: await getLocalSandboxValue(),
};
const loadingKey = "syncConfigLoading";
if (!suppressToast) {
message.loading({
content: t("Claw.Auth.syncingConfig"),
key: loadingKey,
duration: 0,
});
}
try {
const response = await registerClient(params, {
baseUrl: domain,
suppressToast: true,
});
await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username || undefined);
await setOnlineStatus(response.online);
// 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
if (response.token) {
await settingsSet(AUTH_KEYS.AUTH_TOKEN, response.token);
const domainTokenKey = domain ? getDomainTokenKey(domain) : null;
if (domain) {
await settingsSet(domainTokenKey!, response.token);
}
logger.info("Login token cache written", "SyncConfig", {
domain,
domainTokenKey,
});
try {
await syncSessionCookie(domain, response.token);
await settingsSet(AUTH_KEYS.AUTH_TOKEN, null);
logger.info("Token synced to webview cookie", "SyncConfig");
} catch (e) {
logger.warn("Token sync failed, keeping local cache", "SyncConfig", e);
}
} else {
logger.warn(
"reg did not return token, cannot sync webview login state",
"SyncConfig",
{
domain,
},
);
}
// 使用本次 reg 返回的最新 serverHost/serverPort 覆盖本地"代理配置"。
// 注意serverHost 是 lanproxy 链路地址,不应回写为 UI 展示/跳转使用的业务域名。
if (response.serverHost && response.serverPort) {
await saveServerConfig(response.serverHost, response.serverPort);
}
const currentUserInfo = await getUserInfo();
// 关键修复:
// - currentDomain 只代表"业务域名"(用于 UI 展示、会话跳转、后续登录目标);
// - reg 返回的 serverHost 仅用于代理配置,不参与 currentDomain 的决定;
// - 当本次同步拿不到明确业务域名domain 为空)时,保留已有 currentDomain避免被代理地址"间接覆盖"。
const preservedCurrentDomain =
domain || currentUserInfo?.currentDomain || undefined;
await setUserInfo({
...currentUserInfo,
id: response.id,
username: username || "",
displayName: response.name,
currentDomain: preservedCurrentDomain,
} as AuthUserInfo);
if (!suppressToast) {
message.success({
content: t("Claw.Auth.configSyncedSuccess"),
key: loadingKey,
});
}
logger.info("Config sync successful", "SyncConfig", {
configKey: response.configKey,
online: response.online,
});
return response;
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
logger.error("Config sync failed", "SyncConfig", error);
if (!suppressToast) {
message.error({ content: errorMessage, key: loadingKey });
}
return null;
}
}

View File

@@ -0,0 +1,533 @@
/**
* 渲染进程 i18n 服务
* 后端接口驱动,自动根据系统语言选择翻译
*
* Key 规范:{Client}.{Scope}.{Domain}.{key}
* Client: Claw (Electron 客户端)
*
* 语言文件位于 @shared/locales/
*/
import { apiRequest } from "./api";
// ========== 类型定义 ==========
export type SystemLangMap = Record<string, string>;
export interface I18nLangDto {
id: number;
name: string;
lang: string;
status: number;
isDefault: number;
sort: number;
modified: string;
created: string;
}
// ========== 常量 ==========
const DEFAULT_I18N_LANG = "en-us";
const I18N_STORAGE_KEYS = {
ACTIVE_LANG: "i18n.active_lang",
LANG_MAP_CACHE: "i18n.lang_map_cache",
LANG_MAP_CACHE_AT: "i18n.lang_map_cache_at",
LANG_MAP_CACHE_LANG: "i18n.lang_map_cache_lang",
LANG_FORCE_REFRESH_ON_INIT: "i18n.lang_force_refresh_on_init",
} as const;
const I18N_MAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时
// ========== 导入语言文件Vite 支持 JSON import ==========
import enUS from "@shared/locales/en-US.json";
import zhCN from "@shared/locales/zh-CN.json";
import zhTW from "@shared/locales/zh-TW.json";
import zhHK from "@shared/locales/zh-HK.json";
const LOCALE_MAPS: Record<string, SystemLangMap> = {
en: enUS as SystemLangMap,
"en-us": enUS as SystemLangMap,
zh: zhCN as SystemLangMap,
"zh-cn": zhCN as SystemLangMap,
"zh-tw": zhTW as SystemLangMap,
"zh-hk": zhHK as SystemLangMap,
};
const isLocaleSupported = (lang: string): boolean => {
const normalized = lang.toLowerCase();
return normalized in LOCALE_MAPS;
};
const getLocaleMap = (lang: string): SystemLangMap => {
const normalized = lang.toLowerCase();
// 精确匹配
if (LOCALE_MAPS[normalized]) {
return LOCALE_MAPS[normalized];
}
// 前缀匹配
for (const [key, map] of Object.entries(LOCALE_MAPS)) {
if (normalized.startsWith(key)) {
return map;
}
}
// 中文泛匹配
if (normalized.startsWith("zh")) {
return zhCN as SystemLangMap;
}
return enUS as SystemLangMap;
};
const getLocalBaseMap = (lang: string): SystemLangMap => {
const normalized = normalizeLang(lang);
return getLocaleMap(normalized);
};
// ========== 状态 ==========
let currentLang = DEFAULT_I18N_LANG;
let langMap: SystemLangMap = { ...(enUS as SystemLangMap) };
let isCurrentLangSupported_ = true;
let zhBaseMap: SystemLangMap = { ...(zhCN as SystemLangMap) };
let zhValueToKeyMap: Record<string, string> = {};
let initPromise: Promise<void> | null = null;
const warnedLegacyKeys = new Set<string>();
const warnedInvalidKeys = new Set<string>();
const warnedMissingKeys = new Set<string>();
// ========== 工具函数 ==========
const normalizeLangStrict = (lang?: string | null): string =>
String(lang || "")
.trim()
.toLowerCase();
const normalizeLang = (lang?: string | null): string =>
normalizeLangStrict(lang) || DEFAULT_I18N_LANG;
const isZhLang = (lang?: string | null): boolean =>
normalizeLang(lang).startsWith("zh");
const isLegacySystemKey = (key: string): boolean => key.startsWith("System.");
// Key 格式验证正则
// 格式: {Client}.{Scope}.{Domain}.{key} 或 {Client}.{Scope}.{key}
// Client: Claw|PC|Mobile
// Scope: 任意大写字母开头的标识符(如 Menu, Service, Agent, Client, App 等)
// Domain: 可选的点分隔路径(如 Status
// key: 任意字母开头的标识符(支持大小写)
const I18N_KEY_REGEX =
/^(Claw|PC|Mobile)\.[A-Z][A-Za-z0-9]*\.([A-Za-z0-9]+\.)*[A-Za-z][A-Za-z0-9]*$/;
const isValidI18nKey = (key: string): boolean => I18N_KEY_REGEX.test(key);
const warnOnce = (
cache: Set<string>,
key: string,
logger: (k: string) => void,
): void => {
if (cache.has(key)) return;
cache.add(key);
logger(key);
};
type I18nValues = (
| string
| number
| undefined
| Record<string, string | number | undefined>
)[];
const formatText = (template: string, values: I18nValues): string => {
if (!values.length) return template;
let text = template;
// 命名占位符t(key, { error: "xxx" }) → 替换 {error}
const namedValues = values.find(
(v): v is Record<string, string | number | undefined> =>
typeof v === "object" && v !== null,
);
if (namedValues) {
Object.entries(namedValues).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), String(v ?? ""));
});
return text;
}
// 位置占位符t(key, "a", "b") → 替换 {0} {1} 和 {}
const stringValues = values.map((v) => String(v ?? ""));
stringValues.forEach((value, index) => {
text = text.replace(new RegExp(`\\{${index}\\}`, "g"), value);
});
let cursor = 0;
text = text.replace(/\{\}/g, () => stringValues[cursor++] ?? "");
return text;
};
// ========== Electron Settings 存储 ==========
const getBrowserLang = (): string => {
if (typeof navigator === "undefined") {
return DEFAULT_I18N_LANG;
}
return normalizeLang(navigator.language);
};
const readFromSettings = async (key: string): Promise<string | null> => {
try {
const value = await window.electronAPI?.settings.get(key);
return value as string | null;
} catch {
return null;
}
};
const writeToSettings = async (key: string, value: string): Promise<void> => {
try {
await window.electronAPI?.settings.set(key, value);
} catch {
// ignore cache failures
}
};
/**
* 获取用户配置的服务器域名
* 优先使用 step1_config.serverHost用户登录时配置的域名
*/
const getUserDomain = async (): Promise<string | null> => {
try {
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return step1?.serverHost || null;
} catch {
return null;
}
};
const readMapFromCache = async (
lang: string,
): Promise<SystemLangMap | null> => {
const cacheAtStr = await readFromSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_AT,
);
const cacheText = await readFromSettings(I18N_STORAGE_KEYS.LANG_MAP_CACHE);
const cacheLangRaw = await readFromSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_LANG,
);
const cacheAt = Number(cacheAtStr);
const cacheLang = cacheLangRaw ? normalizeLang(cacheLangRaw) : "";
if (!cacheText || !cacheAt) return null;
if (Date.now() - cacheAt > I18N_MAP_CACHE_TTL) return null;
if (cacheLang && cacheLang !== normalizeLang(lang)) return null;
try {
const cacheValue = JSON.parse(cacheText) as SystemLangMap;
if (cacheValue && typeof cacheValue === "object") {
return cacheValue;
}
} catch {
// ignore invalid cache
}
return null;
};
const persistMapCache = async (
lang: string,
map: SystemLangMap,
): Promise<void> => {
await writeToSettings(I18N_STORAGE_KEYS.LANG_MAP_CACHE, JSON.stringify(map));
await writeToSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_AT,
String(Date.now()),
);
await writeToSettings(
I18N_STORAGE_KEYS.LANG_MAP_CACHE_LANG,
normalizeLang(lang),
);
};
const readLangFromCache = async (): Promise<string | null> => {
return readFromSettings(I18N_STORAGE_KEYS.ACTIVE_LANG);
};
const readForceRefreshLangOnInit = async (): Promise<string> => {
const lang = await readFromSettings(
I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT,
);
return normalizeLangStrict(lang);
};
// ========== API ==========
const buildZhValueToKeyMap = (map: SystemLangMap): void => {
const nextMap: Record<string, string> = {};
Object.entries(map).forEach(([key, value]) => {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) return;
if (!(normalizedValue in nextMap)) {
nextMap[normalizedValue] = key;
}
});
zhValueToKeyMap = nextMap;
};
const fetchAndApplyLangMap = async (
lang?: string,
options?: { forceRefresh?: boolean },
): Promise<boolean> => {
const targetLang = normalizeLang(lang || currentLang);
const userDomain = await getUserDomain();
try {
const result = await apiRequest<SystemLangMap>("/api/i18n/query", {
method: "GET",
params: { lang: targetLang, side: "Claw" },
headers: {
"Accept-Language": targetLang,
},
cache: options?.forceRefresh ? "no-store" : undefined,
showError: false,
...(userDomain ? { baseUrl: userDomain } : {}),
});
const mergedMap = {
...getLocalBaseMap(targetLang),
...result,
};
if (normalizeLang(currentLang) === targetLang) {
langMap = mergedMap;
}
await persistMapCache(targetLang, mergedMap);
return true;
} catch {
return false;
}
};
/**
* 预拉取目标语言翻译并缓存到 DB。
* 用于语言切换 reload 前,确保 reload 后 readMapFromCache 能命中。
*/
export const prefetchLangMap = (lang: string): Promise<boolean> =>
fetchAndApplyLangMap(lang);
// ========== 导出函数 ==========
export const getCurrentLang = (): string => currentLang;
export const getCurrentLangMap = (): SystemLangMap => ({ ...langMap });
export const isCurrentLangSupported = (): boolean => isCurrentLangSupported_;
export const setCurrentLang = async (lang?: string | null): Promise<void> => {
const resolvedLang = normalizeLang(lang || getBrowserLang());
currentLang = resolvedLang;
isCurrentLangSupported_ = isLocaleSupported(resolvedLang);
langMap = { ...getLocalBaseMap(resolvedLang) };
await writeToSettings(I18N_STORAGE_KEYS.ACTIVE_LANG, resolvedLang);
};
export const initI18n = (): Promise<void> => {
if (initPromise) return initPromise;
initPromise = _doInitI18n();
return initPromise;
};
const _doInitI18n = async (): Promise<void> => {
const cachedLang = await readLangFromCache();
const resolvedLang = normalizeLang(cachedLang || getBrowserLang());
const forceRefreshLang = await readForceRefreshLangOnInit();
const shouldForceRefresh = forceRefreshLang === resolvedLang;
if (forceRefreshLang) {
await writeToSettings(I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT, "");
}
await setCurrentLang(resolvedLang);
// 启动时同步主进程语言,避免主进程弹窗(如自动更新)与渲染进程语言不一致
try {
await window.electronAPI?.i18n?.setLang(resolvedLang);
} catch {
// ignore sync failures
}
const baseMap = getLocalBaseMap(resolvedLang);
langMap = { ...baseMap };
const cachedMap = await readMapFromCache(resolvedLang);
if (cachedMap) {
langMap = {
...baseMap,
...cachedMap,
};
}
if (isZhLang(resolvedLang)) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
} else if (!Object.keys(zhValueToKeyMap).length) {
buildZhValueToKeyMap(getLocaleMap("zh-cn"));
}
// 远端翻译改为后台刷新,避免首屏/切换语言时阻塞。
void (async () => {
const fetched = await fetchAndApplyLangMap(resolvedLang, {
forceRefresh: shouldForceRefresh,
});
if (isZhLang(resolvedLang)) {
if (normalizeLang(currentLang) === resolvedLang) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
}
}
// 非中文语言:使用本地 zh-CN.json 构建反向映射即可,无需额外请求服务端
if (!fetched && !cachedMap && normalizeLang(currentLang) === resolvedLang) {
langMap = { ...baseMap };
}
})().catch(() => {
// ignore background refresh failures
});
};
/**
* 标记下次初始化时强制 no-store 刷新当前语言翻译。
* 用于语言切换后刷新页面,避免切换流程被网络阻塞。
*/
export const scheduleLangMapRefreshOnNextInit = async (
lang?: string | null,
): Promise<void> => {
const targetLang = normalizeLangStrict(lang || currentLang);
if (!targetLang) return;
await writeToSettings(
I18N_STORAGE_KEYS.LANG_FORCE_REFRESH_ON_INIT,
targetLang,
);
};
/**
* 切换语言后强制刷新翻译映射(绕过浏览器缓存)。
* 中文语言同步更新反向映射;非中文语言的反向映射由本地 zh-CN.json 提供。
* 返回值表示是否成功从服务端拉取到最新翻译。
*/
export const refreshLangMap = async (
lang?: string | null,
): Promise<boolean> => {
const targetLang = normalizeLang(lang || currentLang);
await setCurrentLang(targetLang);
const fetched = await fetchAndApplyLangMap(targetLang, {
forceRefresh: true,
});
if (isZhLang(targetLang)) {
zhBaseMap = { ...langMap };
buildZhValueToKeyMap(zhBaseMap);
}
// 非中文语言:本地 zh-CN.json 反向映射已由 _doInitI18n 构建,无需额外请求
if (!Object.keys(zhValueToKeyMap).length) {
buildZhValueToKeyMap(getLocaleMap("zh-cn"));
}
return fetched;
};
/**
* 多语言翻译函数
* @param key 翻译 key格式{Client}.{Scope}.{Domain}.{key}
* @param values 替换参数:
* - 位置占位符t(key, "a", "b") → 替换 {0}/{1} 或 {}
* - 命名占位符t(key, { error: "xxx" }) → 替换 {error}
*/
export const dict = (key: string, ...values: I18nValues): string => {
const normalizedKey = String(key || "").trim();
if (!normalizedKey) return "";
if (isLegacySystemKey(normalizedKey)) {
warnOnce(warnedLegacyKeys, normalizedKey, (k) => {
console.error(
`[i18n] Legacy key is not supported anymore and should be migrated: ${k}`,
);
});
return normalizedKey;
}
if (!isValidI18nKey(normalizedKey)) {
warnOnce(warnedInvalidKeys, normalizedKey, (k) => {
console.error(
`[i18n] Invalid key format. Expected {Client}.{Scope}.{Domain}.{key}: ${k}`,
);
});
return normalizedKey;
}
let template = langMap[normalizedKey];
if (!template && isCurrentLangSupported()) {
template =
getLocaleMap("en")[normalizedKey] || getLocaleMap("zh-cn")[normalizedKey];
}
if (!template) {
warnOnce(warnedMissingKeys, normalizedKey, (k) => {
console.error(`[i18n] Missing translation entry for key: ${k}`);
});
return normalizedKey;
}
return formatText(template, values);
};
/**
* dict 的别名
*/
export const t = (key: string, ...values: I18nValues): string =>
dict(key, ...values);
/**
* 获取语言列表Promise 缓存,避免 SettingsPage useEffect 多次触发重复请求)
*/
let langListPromise: Promise<I18nLangDto[]> | null = null;
export async function fetchI18nLangList(): Promise<I18nLangDto[]> {
if (langListPromise) return langListPromise;
langListPromise = (async () => {
const userDomain = await getUserDomain();
const result = await apiRequest<I18nLangDto[]>("/api/i18n/lang/list", {
method: "GET",
showError: false,
...(userDomain ? { baseUrl: userDomain } : {}),
});
return result || [];
})();
return langListPromise;
}
/**
* 翻译原始中文文本(基于中文到 key 的反向映射)
*/
export const translateLiteralText = (rawText: string): string => {
const originalText = String(rawText || "");
const trimmedText = originalText.trim();
if (!trimmedText) return originalText;
// 直接支持新规范 key 文本
if (isValidI18nKey(trimmedText)) {
return originalText.replace(trimmedText, dict(trimmedText));
}
if (isLegacySystemKey(trimmedText)) {
dict(trimmedText);
return originalText;
}
// 中文界面无需替换
if (getCurrentLang().startsWith("zh")) {
return originalText;
}
const key = zhValueToKeyMap[trimmedText];
if (!key) return originalText;
const translated = dict(key);
if (!translated || translated === key) return originalText;
return originalText.replace(trimmedText, translated);
};

View File

@@ -0,0 +1,396 @@
/**
* Setup & Auth Service for QimingClaw
*
* Manages:
* - Setup Wizard (first launch)
* - Login/Logout
* - Service configuration
* - Persistence
*/
import { message } from "antd";
import {
DEFAULT_SERVER_HOST,
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_FILE_SERVER_PORT,
DEFAULT_GUI_MCP_PORT,
DEFAULT_ADMIN_SERVER_PORT,
STORAGE_KEYS,
AUTH_KEYS,
DEFAULT_AI_ENGINE,
} from "@shared/constants";
import { logger } from "../utils/logService";
// ==================== Types =============
export interface Step1Config {
serverHost: string;
agentPort: number;
fileServerPort: number;
guiMcpPort: number;
guiMcpEnabled: boolean;
adminServerPort?: number;
workspaceDir: string;
}
export interface AuthUserInfo {
id?: number;
username: string;
displayName?: string;
token?: string;
userId?: string;
email?: string;
currentDomain?: string;
}
export interface SetupState {
completed: boolean;
currentStep: number;
step1Completed: boolean;
step2Completed: boolean;
}
export interface ServiceStatus {
agent: {
running: boolean;
pid?: number;
port?: number;
};
fileServer: {
running: boolean;
port?: number;
};
}
// ==================== Default Values =============
export const DEFAULT_STEP1_CONFIG: Step1Config = {
serverHost: DEFAULT_SERVER_HOST,
agentPort: DEFAULT_AGENT_RUNNER_PORT,
fileServerPort: DEFAULT_FILE_SERVER_PORT,
guiMcpPort: DEFAULT_GUI_MCP_PORT,
guiMcpEnabled: false,
adminServerPort: DEFAULT_ADMIN_SERVER_PORT,
workspaceDir: "",
};
export const DEFAULT_SETUP_STATE: SetupState = {
completed: false,
currentStep: 1,
step1Completed: false,
step2Completed: false,
};
// ==================== Auth Keys (Re-export) =============
export { AUTH_KEYS };
// ==================== Storage Keys (Re-export) =============
export { STORAGE_KEYS };
// ==================== Setup Service =============
class SetupService {
/**
* Check if setup is completed
*/
async isSetupCompleted(): Promise<boolean> {
try {
const state = await this.getSetupState();
return state.completed;
} catch (error) {
console.error("[Setup] Check failed:", error);
logger.error("Check failed", "Setup", error);
return false;
}
}
/**
* Get current setup state
*/
async getSetupState(): Promise<SetupState> {
try {
const state = await window.electronAPI?.settings.get(
STORAGE_KEYS.SETUP_STATE,
);
return state
? { ...DEFAULT_SETUP_STATE, ...(state as SetupState) }
: DEFAULT_SETUP_STATE;
} catch (error) {
console.error("[Setup] Get state failed:", error);
logger.error("Get state failed", "Setup", error);
return DEFAULT_SETUP_STATE;
}
}
/**
* Get Step 1 config
*/
async getStep1Config(): Promise<Step1Config> {
try {
const config = await window.electronAPI?.settings.get(
STORAGE_KEYS.STEP1_CONFIG,
);
return config
? { ...DEFAULT_STEP1_CONFIG, ...(config as Step1Config) }
: DEFAULT_STEP1_CONFIG;
} catch (error) {
console.error("[Setup] Get Step1 failed:", error);
logger.error("Get Step1 failed", "Setup", error);
return DEFAULT_STEP1_CONFIG;
}
}
/**
* Save Step 1 config
*/
async saveStep1Config(config: Step1Config): Promise<void> {
try {
await window.electronAPI?.settings.set(STORAGE_KEYS.STEP1_CONFIG, config);
// Mark step 1 as completed
const state = await this.getSetupState();
state.step1Completed = true;
state.currentStep = 2;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
console.log("[Setup] Step 1 saved:", config);
logger.info("Step 1 saved", "Setup", config);
} catch (error) {
console.error("[Setup] Save Step1 failed:", error);
logger.error("Save Step1 failed", "Setup", error);
throw error;
}
}
/**
* Complete Step 2 (login)
*/
async completeStep2(): Promise<void> {
try {
const state = await this.getSetupState();
state.step2Completed = true;
state.currentStep = 3;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
logger.info("Step 2 completed", "Setup");
} catch (error) {
console.error("[Setup] Complete Step2 failed:", error);
logger.error("Complete Step2 failed", "Setup", error);
throw error;
}
}
/**
* Complete entire setup
*/
async completeSetup(): Promise<void> {
try {
const state = await this.getSetupState();
state.completed = true;
state.currentStep = 0;
await window.electronAPI?.settings.set(STORAGE_KEYS.SETUP_STATE, state);
logger.info("Setup completed", "Setup");
} catch (error) {
console.error("[Setup] Complete failed:", error);
logger.error("Complete failed", "Setup", error);
throw error;
}
}
/**
* Reset setup (for logout/re-setup)
* 清除所有设置向导状态和配置,恢复到初始状态
*/
async resetSetup(): Promise<void> {
try {
await window.electronAPI?.settings.set(
STORAGE_KEYS.SETUP_STATE,
DEFAULT_SETUP_STATE,
);
await window.electronAPI?.settings.set(STORAGE_KEYS.STEP1_CONFIG, null);
await window.electronAPI?.settings.set(STORAGE_KEYS.AGENT_CONFIG, null);
await window.electronAPI?.settings.set(
STORAGE_KEYS.LANPROXY_CONFIG,
null,
);
await window.electronAPI?.settings.set(STORAGE_KEYS.MCP_CONFIG, null);
logger.info("Reset completed", "Setup");
} catch (error) {
console.error("[Setup] Reset failed:", error);
logger.error("Reset failed", "Setup", error);
throw error;
}
}
}
// ==================== Auth Service =============
class AuthService {
/**
* Get saved user info (from auth.user_info, set by auth.ts)
*/
async getAuthUser(): Promise<AuthUserInfo | null> {
try {
const user = await window.electronAPI?.settings.get("auth.user_info");
return user as AuthUserInfo | null;
} catch (error) {
console.error("[Auth] Get user failed:", error);
logger.error("Get user failed", "Auth", error);
return null;
}
}
/**
* Clear auth (logout) — delegates to auth.ts logout()
*/
async clearAuth(): Promise<void> {
try {
await window.electronAPI?.settings.set("auth.username", null);
await window.electronAPI?.settings.set("auth.password", null);
await window.electronAPI?.settings.set("auth.config_key", null);
await window.electronAPI?.settings.set("auth.user_info", null);
await window.electronAPI?.settings.set("auth.online_status", null);
logger.info("Cleared", "Auth");
} catch (error) {
console.error("[Auth] Clear failed:", error);
logger.error("Clear failed", "Auth", error);
throw error;
}
}
/**
* Logout and optionally reset setup
*/
async logout(resetSetupState: boolean = false): Promise<void> {
await this.clearAuth();
if (resetSetupState) {
await setupService.resetSetup();
}
}
}
// ==================== Service Manager =============
class ServiceManager {
/**
* Get all services status
*/
async getStatus(): Promise<ServiceStatus> {
const status: ServiceStatus = {
agent: { running: false },
fileServer: { running: false },
};
try {
// Check Agent via SDK serviceStatus
const agentStatus = await window.electronAPI?.agent.serviceStatus();
if (agentStatus) {
status.agent.running = agentStatus.running;
}
// Check File Server (if implemented)
// const fsStatus = await window.electronAPI?.fileServer.status();
} catch (error) {
console.error("[Service] Get status failed:", error);
logger.error("Get status failed", "Service", error);
}
return status;
}
/**
* Start Agent service via SDK init
*/
async startAgent(config: {
engine?: string;
apiKey?: string;
baseUrl?: string;
model?: string;
workspaceDir?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.agent.init({
engine: (config.engine || DEFAULT_AI_ENGINE) as AgentEngineType,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
model: config.model,
workspaceDir: config.workspaceDir || "",
// mcpServers auto-injected by agent:init handler
})) || { success: false, error: "IPC failed" }
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Stop Agent service via SDK destroy
*/
async stopAgent(): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.agent.destroy()) || {
success: false,
error: "IPC failed",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Start File Server
*/
async startFileServer(
port: number = 60000,
): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.fileServer?.start?.(port)) || {
success: false,
error: "Not implemented",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Stop File Server
*/
async stopFileServer(): Promise<{ success: boolean; error?: string }> {
try {
return (
(await window.electronAPI?.fileServer?.stop?.()) || {
success: false,
error: "Not implemented",
}
);
} catch (error) {
return { success: false, error: String(error) };
}
}
}
// ==================== Exports =============
export const setupService = new SetupService();
export const authService = new AuthService();
export const serviceManager = new ServiceManager();
export default {
setup: setupService,
auth: authService,
services: serviceManager,
};

View File

@@ -0,0 +1 @@
export * from './integrations/fileServer';

View File

@@ -0,0 +1,116 @@
/**
* i18n 配置
* 启动时从后端 /api/i18n/lang/list 拉取语言列表,动态扩展 i18next supportedLngs
*
* 注意:此文件仅用于初始化 i18next
* 实际翻译使用 @/services/core/i18n 中的 dict() 函数
*/
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { DEFAULT_SERVER_HOST } from "@shared/constants";
const STATIC_SUPPORTED_LNGS = [
"en",
"en-us",
"zh",
"zh-cn",
"zh-tw",
"zh-hk",
"en-US",
"zh-CN",
"zh-TW",
"zh-HK",
] as const;
interface I18nLangListItem {
lang?: string;
status?: number;
}
interface LangListResponse<T = unknown> {
code: string;
success?: boolean;
data: T;
}
function normalizeLangCode(lang: string): string {
return String(lang || "").trim();
}
function mergeSupportedLngs(extraLangs: string[]): string[] {
const merged = new Set<string>(STATIC_SUPPORTED_LNGS);
for (const lang of extraLangs) {
const raw = normalizeLangCode(lang);
if (!raw) continue;
merged.add(raw);
merged.add(raw.toLowerCase());
}
return [...merged];
}
async function getUserDomain(): Promise<string> {
try {
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return step1?.serverHost || DEFAULT_SERVER_HOST;
} catch {
return DEFAULT_SERVER_HOST;
}
}
async function fetchSupportedLangsFromServer(): Promise<string[]> {
try {
const baseUrl = await getUserDomain();
const response = await fetch(`${baseUrl}/api/i18n/lang/list`, {
method: "GET",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return [];
const payload = (await response.json()) as LangListResponse<
I18nLangListItem[]
>;
if (payload?.code !== "0000" || !Array.isArray(payload.data)) return [];
return payload.data
.filter((item) => (item?.status ?? 0) === 1)
.map((item) => normalizeLangCode(item?.lang || ""))
.filter(Boolean);
} catch {
return [];
}
}
i18n.init({
lng: "en",
fallbackLng: "en",
supportedLngs: [...STATIC_SUPPORTED_LNGS],
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
let initSupportedLangsPromise: Promise<void> | null = null;
export function initSupportedLangs(): Promise<void> {
if (initSupportedLangsPromise) return initSupportedLangsPromise;
initSupportedLangsPromise = (async () => {
const dynamicLangs = await fetchSupportedLangsFromServer();
const nextSupportedLngs = mergeSupportedLngs(dynamicLangs);
i18n.options.supportedLngs = nextSupportedLngs;
})();
return initSupportedLangsPromise;
}
export default i18n;

View File

@@ -0,0 +1 @@
export * from './integrations/im';

View File

@@ -0,0 +1,21 @@
// Renderer process services
export { setupService, type Step1Config, DEFAULT_STEP1_CONFIG } from './core/setup';
export {
loginAndRegister,
logout,
getCurrentAuth,
getAuthErrorMessage,
syncConfigToServer,
isLoggedIn,
type AuthUserInfo,
} from './core/auth';
export { aiService } from './core/ai';
export { fileServerService } from './integrations/fileServer';
export { lanproxyManager } from './integrations/lanproxy';
export { agentRunnerManager } from './agents/agentRunner';
export { sandboxManager } from './agents/sandbox';
export { permissionManager } from './agents/permissions';
export { skillsService } from './integrations/skills';
export { imService } from './integrations/im';
export { taskScheduler } from './integrations/scheduler';
export { logService, exportLogs } from './utils/logService';

View File

@@ -0,0 +1,846 @@
import {
LOCALHOST_HOSTNAME,
DEFAULT_FILE_SERVER_PORT,
} from "@shared/constants";
/** 发送 PERF 日志到主进程写入专属日志文件,不可用时降级到 console.log */
function perfLog(msg: string): void {
if (window.electronAPI?.perf) {
window.electronAPI.perf.log(msg);
} else {
console.log(msg);
}
}
export interface FileServerConfig {
baseUrl: string;
apiKey?: string;
}
export interface WorkspaceResult {
success: boolean;
message: string;
workspaceRoot?: string;
}
export interface FileInfo {
name: string;
path: string;
isDirectory: boolean;
size?: number;
modifiedTime?: number;
}
export interface FileListResult {
success: boolean;
files: FileInfo[];
}
// ==================== Chat SSE Types ====================
export interface ChatMessage {
sessionId: string;
messageType: string;
subType: string;
data: any;
timestamp: string;
}
export interface ChatRequest {
user_id: string;
project_id?: string;
prompt: string;
session_id?: string;
attachments?: any[];
data_source_attachments?: string[];
model_provider?: ModelProviderConfig;
request_id?: string;
system_prompt?: string;
user_prompt?: string;
agent_config?: any;
}
export interface ModelProviderConfig {
provider: "anthropic" | "openai" | "google" | "azure";
api_key?: string;
base_url?: string;
model?: string;
max_tokens?: number;
temperature?: number;
}
export interface ChatResponse {
success: boolean;
project_id: string;
session_id: string;
message?: string;
error?: string;
}
export interface AgentStatusRequest {
user_id: string;
project_id?: string;
session_id?: string;
}
export interface AgentStatusResponse {
success: boolean;
status: "Idle" | "Busy";
session_id?: string;
project_id?: string;
}
export interface AgentStopRequest {
user_id: string;
project_id?: string;
session_id?: string;
}
export interface AgentStopResponse {
success: boolean;
message: string;
}
// ==================== File Server Service ====================
class FileServerService {
private config: FileServerConfig = {
baseUrl: `http://${LOCALHOST_HOSTNAME}:${DEFAULT_FILE_SERVER_PORT}`,
};
setConfig(config: Partial<FileServerConfig>) {
this.config = { ...this.config, ...config };
}
getConfig(): FileServerConfig {
return { ...this.config };
}
private getHeaders(): HeadersInit {
return this.config.apiKey
? { Authorization: `Bearer ${this.config.apiKey}` }
: {};
}
async loadConfig(): Promise<void> {
try {
const saved =
await window.electronAPI?.settings.get("file_server_config");
if (saved) {
this.config = { ...this.config, ...(saved as FileServerConfig) };
}
} catch (error) {
console.error("Failed to load file server config:", error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set("file_server_config", this.config);
} catch (error) {
console.error("Failed to save file server config:", error);
}
}
// ==================== Chat/SSE API (Agent Runner) ====================
// POST /computer/chat - 发送聊天消息
// 注:当前 UI 为 embedded webviewJava 前端 → Java 后端 → Electron
// chat() / streamChat() 不经过此路径,[PERF][Frontend] 日志在实际链路中不触发。
// 代码保留供 Electron renderer 直连场景使用(如未来去除 webview 的方案)。
async chat(request: ChatRequest): Promise<ChatResponse> {
const t0 = Date.now();
// 优先通过 IPCAcpEngine 直接处理,返回 HttpResult<ComputerChatResponse>
if (window.electronAPI?.computer) {
perfLog(
`[PERF][Frontend][chat] request sent: project_id=${request.project_id}, t=${t0}`,
);
const result = await window.electronAPI.computer.chat(request);
const t1 = Date.now();
perfLog(
`[PERF][Frontend][chat] IPC response: session_id=${result.data?.session_id}, duration=${t1 - t0}ms`,
);
// 从 HttpResult 中提取 data映射到 fileServer 本地 ChatResponse 格式
return {
success: result.success,
project_id: result.data?.project_id || "",
session_id: result.data?.session_id || "",
error:
result.data?.error || (result.success ? undefined : result.message),
};
}
// 回退到 HTTPrcoder 返回 HttpResult<ChatResponse> 格式)
perfLog(
`[PERF][Frontend][chat] HTTP request sent: project_id=${request.project_id}, t=${t0}`,
);
const response = await fetch(`${this.config.baseUrl}/computer/chat`, {
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Chat failed: ${response.statusText}`);
}
// rcoder 返回 HttpResult 格式,提取 data
const httpResult = await response.json();
const t1 = Date.now();
perfLog(
`[PERF][Frontend][chat] HTTP response: session_id=${httpResult.data?.session_id}, duration=${t1 - t0}ms`,
);
return {
success: httpResult.success ?? false,
project_id: httpResult.data?.project_id || "",
session_id: httpResult.data?.session_id || "",
error:
httpResult.data?.error ||
(httpResult.success ? undefined : httpResult.message),
};
}
// GET /computer/progress/{session_id} - SSE 流式进度
// 注:同 chat(),当前链路不经过此路径,[PERF][Frontend][streamChat] 日志在实际链路中不触发。
async *streamChat(sessionId: string): AsyncGenerator<ChatMessage> {
const t0 = Date.now();
perfLog(
`[PERF][Frontend][streamChat] connection initiated: session_id=${sessionId}, t=${t0}`,
);
const response = await fetch(
`${this.config.baseUrl}/computer/progress/${sessionId}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(
`Failed to connect to progress stream: ${response.statusText}`,
);
}
if (!response.body) {
throw new Error("No response body");
}
perfLog(
`[PERF][Frontend][streamChat] SSE connected: session_id=${sessionId}, duration=${Date.now() - t0}ms`,
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let firstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) {
perfLog(
`[PERF][Frontend][streamChat] SSE ended: session_id=${sessionId}, total_duration=${Date.now() - t0}ms`,
);
break;
}
if (firstChunk) {
perfLog(
`[PERF][Frontend][streamChat] SSE first chunk: session_id=${sessionId}, duration=${Date.now() - t0}ms`,
);
firstChunk = false;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
// Skip heartbeat events
if (data.includes('"ping"') || data.includes("heartbeat")) {
continue;
}
try {
const message = JSON.parse(data);
yield message;
} catch {
// Not JSON, might be plain text
yield {
data,
messageType: "text",
subType: "message",
timestamp: new Date().toISOString(),
};
}
}
// Handle event types
if (line.startsWith("event: ")) {
const eventType = line.slice(7);
// Event type in 'event:' header
}
}
}
}
// POST /computer/agent/status - 获取 Agent 状态
async getAgentStatus(
request: AgentStatusRequest,
): Promise<AgentStatusResponse> {
// 优先通过 IPC返回 HttpResult<ComputerAgentStatusResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.agentStatus(request);
return {
success: result.success,
status: (result.data?.status === "Busy" ? "Busy" : "Idle") as
| "Idle"
| "Busy",
session_id: result.data?.session_id ?? undefined,
project_id: result.data?.project_id ?? request.project_id,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(
`${this.config.baseUrl}/computer/agent/status`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new Error(`Failed to get status: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.success ?? false,
status: (httpResult.data?.status === "Busy" ? "Busy" : "Idle") as
| "Idle"
| "Busy",
session_id: httpResult.data?.session_id,
project_id: httpResult.data?.project_id,
};
}
// POST /computer/agent/stop - 停止 Agent
async stopAgent(request: AgentStopRequest): Promise<AgentStopResponse> {
// 优先通过 IPC返回 HttpResult<ComputerAgentStopResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.agentStop(request);
return {
success: result.data?.success ?? result.success,
message: result.data?.message ?? result.message,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(`${this.config.baseUrl}/computer/agent/stop`, {
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to stop agent: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.data?.success ?? httpResult.success,
message: httpResult.data?.message ?? httpResult.message ?? "Stopped",
};
}
// POST /computer/agent/session/cancel - 取消会话
async cancelSession(request: {
user_id: string;
session_id: string;
}): Promise<{ success: boolean; message: string }> {
// 优先通过 IPC返回 HttpResult<ComputerAgentCancelResponse>
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.cancelSession(request);
return {
success: result.data?.success ?? result.success,
message: result.success ? "Cancelled" : result.message,
};
}
// 回退到 HTTPrcoder 返回 HttpResult 格式)
const response = await fetch(
`${this.config.baseUrl}/computer/agent/session/cancel`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new Error(`Failed to cancel session: ${response.statusText}`);
}
const httpResult = await response.json();
return {
success: httpResult.data?.success ?? httpResult.success,
message: httpResult.success
? "Cancelled"
: (httpResult.message ?? "Failed"),
};
}
// ==================== Computer Routes ====================
// POST /computer/create-workspace - 创建工作空间支持skills同步
async createWorkspace(
userId: string,
cId: string,
zipFile?: File,
): Promise<WorkspaceResult> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
if (zipFile) {
formData.append("file", zipFile);
}
const response = await fetch(
`${this.config.baseUrl}/computer/create-workspace`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to create workspace: ${response.statusText}`);
}
return response.json();
}
// GET /computer/get-file-list - 获取文件列表
async getFileList(
userId: string,
cId: string,
proxyPath?: string,
): Promise<FileListResult> {
const params = new URLSearchParams({ userId, cId });
if (proxyPath) {
params.append("proxyPath", proxyPath);
}
const response = await fetch(
`${this.config.baseUrl}/computer/get-file-list?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(`Failed to get file list: ${response.statusText}`);
}
return response.json();
}
// POST /computer/files-update - 批量更新文件
async updateFiles(
userId: string,
cId: string,
files: { name: string; contents: string }[],
): Promise<{ success: boolean }> {
const response = await fetch(
`${this.config.baseUrl}/computer/files-update`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, cId, files }),
},
);
if (!response.ok) {
throw new Error(`Failed to update files: ${response.statusText}`);
}
return response.json();
}
// POST /computer/upload-file - 上传单个文件
async uploadFile(
userId: string,
cId: string,
file: File,
filePath: string,
): Promise<any> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
formData.append("filePath", filePath);
formData.append("file", file);
const response = await fetch(
`${this.config.baseUrl}/computer/upload-file`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to upload file: ${response.statusText}`);
}
return response.json();
}
// POST /computer/upload-files - 批量上传文件
async uploadFiles(
userId: string,
cId: string,
files: File[],
filePaths: string[],
): Promise<any> {
const formData = new FormData();
formData.append("userId", userId);
formData.append("cId", cId);
formData.append("filePaths", JSON.stringify(filePaths));
files.forEach((file, i) => {
formData.append("files", file);
});
const response = await fetch(
`${this.config.baseUrl}/computer/upload-files`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok) {
throw new Error(`Failed to upload files: ${response.statusText}`);
}
return response.json();
}
// GET /computer/download-all-files - 下载所有文件
async downloadAllFiles(userId: string, cId: string): Promise<Blob> {
const params = new URLSearchParams({ userId, cId });
const response = await fetch(
`${this.config.baseUrl}/computer/download-all-files?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok) {
throw new Error(`Failed to download files: ${response.statusText}`);
}
return response.blob();
}
// ==================== Build Routes ====================
async startDev(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/start-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to start dev: ${response.statusText}`);
return response.json();
}
async stopDev(projectId: string, pid: string): Promise<any> {
const params = new URLSearchParams({ projectId, pid });
const response = await fetch(
`${this.config.baseUrl}/build/stop-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to stop dev: ${response.statusText}`);
return response.json();
}
async build(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/build?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to build: ${response.statusText}`);
return response.json();
}
async restartDev(projectId: string): Promise<any> {
const params = new URLSearchParams({ projectId });
const response = await fetch(
`${this.config.baseUrl}/build/restart-dev?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to restart dev: ${response.statusText}`);
return response.json();
}
async listDev(): Promise<{ success: boolean; list: any[] }> {
const response = await fetch(`${this.config.baseUrl}/build/list-dev`, {
method: "GET",
headers: this.getHeaders(),
});
if (!response.ok)
throw new Error(`Failed to list dev: ${response.statusText}`);
return response.json();
}
async getDevLog(
projectId: string,
startIndex: number = 1,
logType: string = "temp",
): Promise<any> {
const params = new URLSearchParams({
projectId,
startIndex: String(startIndex),
logType,
});
const response = await fetch(
`${this.config.baseUrl}/build/get-dev-log?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to get dev log: ${response.statusText}`);
return response.json();
}
// ==================== Code Routes ====================
async updateAllFiles(
projectId: string,
codeVersion: string,
files: any[],
basePath?: string,
pid?: string,
): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/code/all-files-update`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, files, basePath, pid }),
},
);
if (!response.ok)
throw new Error(`Failed to update files: ${response.statusText}`);
return response.json();
}
async uploadSingleCodeFile(
projectId: string,
codeVersion: string,
file: File,
filePath: string,
): Promise<any> {
const formData = new FormData();
formData.append("projectId", projectId);
formData.append("codeVersion", codeVersion);
formData.append("filePath", filePath);
formData.append("file", file);
const response = await fetch(
`${this.config.baseUrl}/code/upload-single-file`,
{
method: "POST",
headers: this.getHeaders(),
body: formData,
},
);
if (!response.ok)
throw new Error(`Failed to upload code file: ${response.statusText}`);
return response.json();
}
async rollbackVersion(
projectId: string,
codeVersion: string,
rollbackTo: string,
): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/code/rollback-version`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, rollbackTo }),
},
);
if (!response.ok)
throw new Error(`Failed to rollback: ${response.statusText}`);
return response.json();
}
// ==================== Project Routes ====================
async createProject(projectId: string): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/project/create-project`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId }),
},
);
if (!response.ok)
throw new Error(`Failed to create project: ${response.statusText}`);
return response.json();
}
async getProjectContent(
projectId: string,
command?: string,
proxyPath?: string,
): Promise<any> {
const params = new URLSearchParams({ projectId });
if (command) params.append("command", command);
if (proxyPath) params.append("proxyPath", proxyPath);
const response = await fetch(
`${this.config.baseUrl}/project/get-project-content?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to get project content: ${response.statusText}`);
return response.json();
}
async backupVersion(projectId: string, codeVersion: string): Promise<any> {
const response = await fetch(
`${this.config.baseUrl}/project/backup-current-version`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion }),
},
);
if (!response.ok)
throw new Error(`Failed to backup version: ${response.statusText}`);
return response.json();
}
async exportProject(
projectId: string,
codeVersion: string,
exportType: string = "zip",
config?: any,
): Promise<Blob> {
const response = await fetch(
`${this.config.baseUrl}/project/export-project`,
{
method: "POST",
headers: {
...this.getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId, codeVersion, exportType, config }),
},
);
if (!response.ok)
throw new Error(`Failed to export project: ${response.statusText}`);
return response.blob();
}
async deleteProject(projectId: string, pid?: string): Promise<any> {
const params = new URLSearchParams({ projectId });
if (pid) params.append("pid", pid);
const response = await fetch(
`${this.config.baseUrl}/project/delete-project?${params}`,
{
method: "GET",
headers: this.getHeaders(),
},
);
if (!response.ok)
throw new Error(`Failed to delete project: ${response.statusText}`);
return response.json();
}
// Check connection
async checkConnection(): Promise<boolean> {
try {
// 优先通过 IPC
if (window.electronAPI?.computer) {
const result = await window.electronAPI.computer.health();
return result.status === "healthy";
}
// 回退到 HTTP
const response = await fetch(`${this.config.baseUrl}/health`, {
method: "GET",
});
return response.ok;
} catch {
return false;
}
}
}
export const fileServerService = new FileServerService();

View File

@@ -0,0 +1,316 @@
export type IMPlatform = 'discord' | 'telegram' | 'dingtalk' | 'feishu';
export interface IMConfig {
platform: IMPlatform;
enabled: boolean;
token?: string;
botToken?: string;
apiKey?: string;
apiSecret?: string;
webhookUrl?: string;
allowedUsers?: string[];
autoReply?: boolean;
}
export interface IMLogin {
platform: IMPlatform;
userId: string;
username: string;
displayName: string;
avatar?: string;
}
export interface IMMessage {
id: string;
platform: IMPlatform;
sender: IMLogin;
content: string;
timestamp: number;
channelId?: string;
groupId?: string;
isGroup: boolean;
replyTo?: string;
}
export interface IMSendOptions {
content: string;
channelId?: string;
userId?: string;
replyTo?: string;
markdown?: boolean;
}
class IMService {
private configs: Map<IMPlatform, IMConfig> = new Map();
private handlers: Map<IMPlatform, (message: IMMessage) => void> = new Map();
private connections: Map<IMPlatform, boolean> = new Map();
constructor() {
// Initialize with empty configs
this.configs.set('discord', { platform: 'discord', enabled: false });
this.configs.set('telegram', { platform: 'telegram', enabled: false });
this.configs.set('dingtalk', { platform: 'dingtalk', enabled: false });
this.configs.set('feishu', { platform: 'feishu', enabled: false });
}
getConfig(platform: IMPlatform): IMConfig | undefined {
return this.configs.get(platform);
}
setConfig(platform: IMPlatform, config: Partial<IMConfig>): void {
const current = this.configs.get(platform) || { platform, enabled: false };
this.configs.set(platform, { ...current, ...config });
}
getEnabledPlatforms(): IMPlatform[] {
return Array.from(this.configs.entries())
.filter(([_, config]) => config.enabled)
.map(([platform]) => platform);
}
isConnected(platform: IMPlatform): boolean {
return this.connections.get(platform) || false;
}
onMessage(platform: IMPlatform, handler: (message: IMMessage) => void): void {
this.handlers.set(platform, handler);
}
async connect(platform: IMPlatform): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get(platform);
if (!config || !config.enabled) {
return { success: false, error: 'Platform not enabled' };
}
try {
// Platform-specific connection logic
switch (platform) {
case 'discord':
return await this.connectDiscord(config);
case 'telegram':
return await this.connectTelegram(config);
case 'dingtalk':
return await this.connectDingtalk(config);
case 'feishu':
return await this.connectFeishu(config);
default:
return { success: false, error: 'Unknown platform' };
}
} catch (error) {
return { success: false, error: String(error) };
}
}
async disconnect(platform: IMPlatform): Promise<void> {
this.connections.set(platform, false);
}
async sendMessage(platform: IMPlatform, options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
if (!this.connections.get(platform)) {
return { success: false, error: 'Not connected' };
}
try {
switch (platform) {
case 'discord':
return await this.sendDiscordMessage(options);
case 'telegram':
return await this.sendTelegramMessage(options);
case 'dingtalk':
return await this.sendDingtalkMessage(options);
case 'feishu':
return await this.sendFeishuMessage(options);
default:
return { success: false, error: 'Unknown platform' };
}
} catch (error) {
return { success: false, error: String(error) };
}
}
// Discord implementation
private async connectDiscord(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.botToken) {
return { success: false, error: 'Bot token required' };
}
// Test API connection
try {
const response = await fetch('https://discord.com/api/v10/users/@me', {
headers: { 'Authorization': `Bot ${config.botToken}` }
});
if (response.ok) {
this.connections.set('discord', true);
return { success: true };
}
return { success: false, error: 'Invalid bot token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendDiscordMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get('discord');
if (!config?.botToken || !options.channelId) {
return { success: false, error: 'Missing configuration' };
}
try {
const response = await fetch(`https://discord.com/api/v10/channels/${options.channelId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bot ${config.botToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: options.content })
});
return { success: response.ok };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Telegram implementation
private async connectTelegram(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.botToken) {
return { success: false, error: 'Bot token required' };
}
try {
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/getMe`);
const data = await response.json();
if (data.ok) {
this.connections.set('telegram', true);
return { success: true };
}
return { success: false, error: 'Invalid bot token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendTelegramMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
const config = this.configs.get('telegram');
if (!config?.botToken || !options.userId) {
return { success: false, error: 'Missing configuration' };
}
try {
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: options.userId,
text: options.content,
})
});
return { success: response.ok };
} catch (error) {
return { success: false, error: String(error) };
}
}
// DingTalk implementation
private async connectDingtalk(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.appKey || !config.appSecret) {
return { success: false, error: 'AppKey and AppSecret required' };
}
try {
// Get access token
const tokenResponse = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appKey: config.appKey,
appSecret: config.appSecret,
})
});
const data = await tokenResponse.json();
if (data.accessToken) {
this.connections.set('dingtalk', true);
return { success: true };
}
return { success: false, error: 'Failed to get access token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendDingtalkMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
// DingTalk webhook implementation
if (!options.userId) {
return { success: false, error: 'User ID required' };
}
return { success: true }; // Placeholder
}
// Feishu ( Lark ) implementation
private async connectFeishu(config: IMConfig): Promise<{ success: boolean; error?: string }> {
if (!config.appId || !config.appSecret) {
return { success: false, error: 'AppId and AppSecret required' };
}
try {
const response = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: config.appId,
app_secret: config.appSecret,
})
});
const data = await response.json();
if (data.tenant_access_token) {
this.connections.set('feishu', true);
return { success: true };
}
return { success: false, error: 'Failed to get tenant token' };
} catch (error) {
return { success: false, error: String(error) };
}
}
private async sendFeishuMessage(options: IMSendOptions): Promise<{ success: boolean; error?: string }> {
if (!options.userId) {
return { success: false, error: 'User ID required' };
}
return { success: true }; // Placeholder
}
// Load/save config
async loadConfigs(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('im_configs');
if (saved) {
const configs = saved as IMConfig[];
for (const config of configs) {
this.configs.set(config.platform, config);
}
}
} catch (error) {
console.error('Failed to load IM configs:', error);
}
}
async saveConfigs(): Promise<void> {
try {
const configs = Array.from(this.configs.values());
await window.electronAPI?.settings.set('im_configs', configs);
} catch (error) {
console.error('Failed to save IM configs:', error);
}
}
}
export const imService = new IMService();

View File

@@ -0,0 +1,141 @@
import { LOCALHOST_IP, DEFAULT_LANPROXY_PORT } from "@shared/constants";
import { t } from "../core/i18n";
export interface LanproxyConfig {
enabled: boolean;
serverIp: string;
serverPort: number;
ssl: boolean;
}
export const defaultLanproxyConfig: LanproxyConfig = {
enabled: false,
serverIp: LOCALHOST_IP,
serverPort: DEFAULT_LANPROXY_PORT,
ssl: true,
};
export interface LanproxyStatus {
running: boolean;
pid?: number;
error?: string;
}
class LanproxyManager {
private config: LanproxyConfig = { ...defaultLanproxyConfig };
private status: LanproxyStatus = { running: false };
private process: ReturnType<typeof setInterval> | null = null;
getConfig(): LanproxyConfig {
return { ...this.config };
}
setConfig(config: Partial<LanproxyConfig>) {
this.config = { ...this.config, ...config };
}
getStatus(): LanproxyStatus {
return { ...this.status };
}
async loadConfig(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get("lanproxy_config");
if (saved) {
this.config = {
...defaultLanproxyConfig,
...(saved as LanproxyConfig),
};
}
} catch (error) {
console.error("Failed to load lanproxy config:", error);
}
}
async saveConfig(): Promise<void> {
try {
await window.electronAPI?.settings.set("lanproxy_config", this.config);
} catch (error) {
console.error("Failed to save lanproxy config:", error);
}
}
// Start lanproxy via IPC — clientKey 从 auth.saved_key 读取,不由 LanproxyManager 管理
async start(): Promise<{ success: boolean; error?: string }> {
if (this.status.running) {
return { success: true };
}
try {
// clientKey 始终从 auth.saved_key 读取(参考 Tauri 客户端)
const clientKey = (await window.electronAPI?.settings.get(
"auth.saved_key",
)) as string | null;
if (!clientKey) {
return {
success: false,
error: t("Claw.Errors.loginFirstForClientKey"),
};
}
const result = await window.electronAPI?.lanproxy.start({
serverIp: this.config.serverIp,
serverPort: this.config.serverPort,
clientKey,
ssl: this.config.ssl,
});
if (result?.success) {
this.status.running = true;
}
return result || { success: false, error: "IPC failed" };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Stop lanproxy via IPC
async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.status.running) {
return { success: true };
}
try {
const result = await window.electronAPI?.lanproxy.stop();
if (result?.success) {
this.status.running = false;
}
return result || { success: false, error: "IPC failed" };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Get status via IPC
async checkStatus(): Promise<LanproxyStatus> {
try {
const status = await window.electronAPI?.lanproxy.status();
this.status = status || { running: false };
return this.status;
} catch (error) {
return { running: false, error: String(error) };
}
}
// Start periodic status check
startStatusCheck(intervalMs: number = 5000) {
this.stopStatusCheck();
this.process = setInterval(() => {
this.checkStatus();
}, intervalMs);
}
// Stop periodic status check
stopStatusCheck() {
if (this.process) {
clearInterval(this.process);
this.process = null;
}
}
}
export const lanproxyManager = new LanproxyManager();

View File

@@ -0,0 +1,314 @@
import { DEFAULT_SCHEDULER_MINUTE_INTERVAL } from '@shared/constants';
export interface ScheduledTask {
id: string;
name: string;
description: string;
enabled: boolean;
schedule: TaskSchedule;
action: TaskAction;
lastRun?: number;
nextRun?: number;
status: 'idle' | 'running' | 'success' | 'error';
lastError?: string;
}
export interface TaskSchedule {
type: 'once' | 'interval' | 'cron';
// For once
timestamp?: number;
// For interval
intervalMs?: number;
// For cron
cron?: string;
}
export interface TaskAction {
type: 'message' | 'command' | 'webhook';
content: string;
// For webhook
url?: string;
method?: 'GET' | 'POST';
headers?: Record<string, string>;
}
export interface ScheduledTaskLog {
id: string;
taskId: string;
timestamp: number;
status: 'success' | 'error';
output?: string;
error?: string;
}
class TaskScheduler {
private tasks: Map<string, ScheduledTask> = new Map();
private timers: Map<string, NodeJS.Timeout> = new Map();
private logs: Map<string, ScheduledTaskLog[]> = new Map();
private maxLogsPerTask = 50;
constructor() {
// Load tasks from storage on init
}
// Create a new task
createTask(task: Omit<ScheduledTask, 'id' | 'status' | 'lastRun' | 'nextRun'>): ScheduledTask {
const id = crypto.randomUUID();
const newTask: ScheduledTask = {
...task,
id,
status: 'idle',
};
this.tasks.set(id, newTask);
this.scheduleTask(newTask);
return newTask;
}
// Update a task
updateTask(id: string, updates: Partial<ScheduledTask>): ScheduledTask | null {
const task = this.tasks.get(id);
if (!task) return null;
const updatedTask = { ...task, ...updates };
this.tasks.set(id, updatedTask);
// Reschedule if schedule changed
if (updates.schedule) {
this.unscheduleTask(id);
this.scheduleTask(updatedTask);
}
return updatedTask;
}
// Delete a task
deleteTask(id: string): boolean {
this.unscheduleTask(id);
return this.tasks.delete(id);
}
// Get all tasks
getTasks(): ScheduledTask[] {
return Array.from(this.tasks.values());
}
// Get task by ID
getTask(id: string): ScheduledTask | undefined {
return this.tasks.get(id);
}
// Enable/disable task
toggleTask(id: string, enabled: boolean): ScheduledTask | null {
const task = this.tasks.get(id);
if (!task) return null;
task.enabled = enabled;
if (enabled) {
this.scheduleTask(task);
} else {
this.unscheduleTask(id);
}
return task;
}
// Run task immediately
async runTask(id: string): Promise<{ success: boolean; error?: string }> {
const task = this.tasks.get(id);
if (!task) {
return { success: false, error: 'Task not found' };
}
task.status = 'running';
this.tasks.set(id, task);
try {
const result = await this.executeAction(task.action);
task.status = 'success';
task.lastRun = Date.now();
task.lastError = undefined;
this.tasks.set(id, task);
this.addLog(task.id, { success: true, output: result });
return { success: true };
} catch (error) {
task.status = 'error';
task.lastError = String(error);
this.tasks.set(id, task);
this.addLog(task.id, { success: false, error: String(error) });
return { success: false, error: String(error) };
}
}
// Schedule a task
private scheduleTask(task: ScheduledTask): void {
if (!task.enabled) return;
const now = Date.now();
let nextRun: number;
switch (task.schedule.type) {
case 'once':
if (task.schedule.timestamp && task.schedule.timestamp > now) {
nextRun = task.schedule.timestamp;
} else {
return; // Past timestamp, don't schedule
}
break;
case 'interval':
if (task.schedule.intervalMs) {
nextRun = now + task.schedule.intervalMs;
} else {
return;
}
break;
case 'cron':
// Simple cron implementation - just use interval for now
nextRun = now + 60000; // Default 1 minute
break;
default:
return;
}
task.nextRun = nextRun;
this.tasks.set(task.id, task);
const delay = Math.max(0, nextRun - now);
const timer = setTimeout(async () => {
await this.runTask(task.id);
// Reschedule for next run
if (task.enabled && task.schedule.type !== 'once') {
this.scheduleTask(task);
}
}, delay);
this.timers.set(task.id, timer);
}
// Unschedule a task
private unscheduleTask(id: string): void {
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
this.timers.delete(id);
}
const task = this.tasks.get(id);
if (task) {
task.nextRun = undefined;
this.tasks.set(id, task);
}
}
// Execute task action
private async executeAction(action: TaskAction): Promise<string> {
switch (action.type) {
case 'message':
// This would trigger the AI to send a message
return `Message: ${action.content}`;
case 'command':
// Execute shell command
return `Command: ${action.content}`;
case 'webhook':
// Make HTTP request
try {
const response = await fetch(action.url!, {
method: action.method || 'GET',
headers: action.headers,
body: action.method === 'POST' ? action.content : undefined,
});
return `Webhook response: ${response.status}`;
} catch (error) {
throw new Error(`Webhook failed: ${error}`);
}
default:
throw new Error('Unknown action type');
}
}
// Add log entry
private addLog(taskId: string, result: { success: boolean; output?: string; error?: string }): void {
const log: ScheduledTaskLog = {
id: crypto.randomUUID(),
taskId,
timestamp: Date.now(),
status: result.success ? 'success' : 'error',
output: result.output,
error: result.error,
};
const taskLogs = this.logs.get(taskId) || [];
taskLogs.unshift(log);
// Keep only last N logs
if (taskLogs.length > this.maxLogsPerTask) {
taskLogs.pop();
}
this.logs.set(taskId, taskLogs);
}
// Get logs for a task
getLogs(taskId: string): ScheduledTaskLog[] {
return this.logs.get(taskId) || [];
}
// Load tasks from storage
async loadTasks(): Promise<void> {
try {
const saved = await window.electronAPI?.settings.get('scheduled_tasks');
if (saved) {
const tasks = saved as ScheduledTask[];
for (const task of tasks) {
this.tasks.set(task.id, task);
if (task.enabled) {
this.scheduleTask(task);
}
}
}
} catch (error) {
console.error('Failed to load scheduled tasks:', error);
}
}
// Save tasks to storage
async saveTasks(): Promise<void> {
try {
const tasks = Array.from(this.tasks.values());
await window.electronAPI?.settings.set('scheduled_tasks', tasks);
} catch (error) {
console.error('Failed to save scheduled tasks:', error);
}
}
// Stop all tasks
stopAll(): void {
for (const id of this.timers.keys()) {
this.unscheduleTask(id);
}
}
}
export const taskScheduler = new TaskScheduler();
// Common preset schedules
export const presetSchedules = {
everyMinute: { type: 'interval' as const, intervalMs: DEFAULT_SCHEDULER_MINUTE_INTERVAL },
everyHour: { type: 'interval' as const, intervalMs: 3600000 },
everyDay: { type: 'interval' as const, intervalMs: 86400000 },
everyWeek: { type: 'interval' as const, intervalMs: 604800000 },
};

View File

@@ -0,0 +1,277 @@
export interface SkillDefinition {
id: string;
name: string;
description: string;
enabled: boolean;
requiresPermission?: boolean;
icon?: string;
}
export interface SkillExecutionResult {
success: boolean;
output?: string;
error?: string;
requiresPermission?: boolean;
permissionDetails?: {
type: 'command' | 'file' | 'network';
title: string;
description: string;
details: Record<string, unknown>;
};
}
export interface SkillContext {
workingDir?: string;
userId: string;
sessionId: string;
requirePermission?: (details: SkillExecutionResult['permissionDetails']) => Promise<boolean>;
}
export interface BaseSkill {
id: string;
name: string;
description: string;
enabled: boolean;
requiresPermission: boolean;
execute(input: string, context: SkillContext): Promise<SkillExecutionResult>;
}
// Built-in skills
export const builtInSkills: BaseSkill[] = [];
class WebSearchSkill implements BaseSkill {
id = 'web-search';
name = 'Web Search';
description = 'Search the web for information';
enabled = true;
requiresPermission = false;
async execute(input: string, _context: SkillContext): Promise<SkillExecutionResult> {
// TODO: Implement web search
return {
success: true,
output: `Search results for: ${input}`,
};
}
}
class CalculatorSkill implements BaseSkill {
id = 'calculator';
name = 'Calculator';
description = 'Perform mathematical calculations';
enabled = true;
requiresPermission = false;
async execute(input: string, _context: SkillContext): Promise<SkillExecutionResult> {
try {
const sanitized = input.replace(/[^0-9+\-*/.()%]/g, '');
// eslint-disable-next-line no-new-func
const result = new Function(`"use strict"; return (${sanitized})`)();
return {
success: true,
output: String(result),
};
} catch {
return {
success: false,
error: 'Invalid expression',
};
}
}
}
class FileReadSkill implements BaseSkill {
id = 'file-read';
name = 'File Read';
description = 'Read content from a file';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
const filePath = input.trim();
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'file',
title: 'Read File',
description: `Read content from file: ${filePath}`,
details: { file: filePath },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `File read: ${filePath}`,
};
}
}
class CommandRunSkill implements BaseSkill {
id = 'command-run';
name = 'Command Run';
description = 'Run shell commands';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'command',
title: 'Run Command',
description: `Execute shell command: ${input}`,
details: { command: 'sh', args: ['-c', input], env: {} },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `Command would execute: ${input}`,
};
}
}
class NetworkFetchSkill implements BaseSkill {
id = 'network-fetch';
name = 'Network Fetch';
description = 'Fetch content from a URL';
enabled = true;
requiresPermission = true;
async execute(input: string, context: SkillContext): Promise<SkillExecutionResult> {
const url = input.trim();
if (context.requirePermission) {
const approved = await context.requirePermission({
type: 'network',
title: 'Network Request',
description: `Fetch content from: ${url}`,
details: { url },
});
if (!approved) {
return {
success: false,
error: 'Permission denied',
};
}
}
return {
success: true,
output: `Would fetch: ${url}`,
};
}
}
// Skill manager
class SkillManager {
private skills: Map<string, BaseSkill> = new Map();
register(skill: BaseSkill) {
this.skills.set(skill.id, skill);
}
getSkill(id: string): BaseSkill | undefined {
return this.skills.get(id);
}
listSkills(): SkillDefinition[] {
return Array.from(this.skills.values()).map((s) => ({
id: s.id,
name: s.name,
description: s.description,
enabled: s.enabled,
requiresPermission: s.requiresPermission,
}));
}
async executeSkill(
skillId: string,
input: string,
context: SkillContext,
requirePermission?: SkillContext['requirePermission']
): Promise<SkillExecutionResult> {
const skill = this.skills.get(skillId);
if (!skill) {
return { success: false, error: `Skill not found: ${skillId}` };
}
if (!skill.enabled) {
return { success: false, error: `Skill disabled: ${skillId}` };
}
const ctx = { ...context, requirePermission };
// Check if permission is required but not provided
if (skill.requiresPermission && !requirePermission) {
const result = await skill.execute(input, ctx);
return {
...result,
requiresPermission: true,
};
}
return skill.execute(input, ctx);
}
async executeAuto(
input: string,
context: SkillContext,
requirePermission?: SkillContext['requirePermission']
): Promise<SkillExecutionResult | null> {
const lowerInput = input.toLowerCase();
// Calculator
if (/^\d+[\s+\-*/%()\d]+$/.test(input.replace(/\s/g, ''))) {
return this.executeSkill('calculator', input, context, requirePermission);
}
// Web search
if (lowerInput.startsWith('search:') || lowerInput.startsWith('查找:') || lowerInput.startsWith('搜索:')) {
const query = input.replace(/^(search|查找|搜索):\s*/i, '');
return this.executeSkill('web-search', query, context, requirePermission);
}
// Command (starts with !)
if (lowerInput.startsWith('!') || lowerInput.startsWith('run:')) {
const cmd = input.replace(/^(!|run:)\s*/i, '');
return this.executeSkill('command-run', cmd, context, requirePermission);
}
// File read (starts with cat:)
if (lowerInput.startsWith('cat:')) {
const file = input.replace(/^cat:\s*/i, '');
return this.executeSkill('file-read', file, context, requirePermission);
}
// Network fetch (starts with fetch:)
if (lowerInput.startsWith('fetch:') || lowerInput.startsWith('curl:')) {
const url = input.replace(/^(fetch|curl):\s*/i, '');
return this.executeSkill('network-fetch', url, context, requirePermission);
}
return null;
}
}
export const skillManager = new SkillManager();
// Register built-in skills
skillManager.register(new WebSearchSkill());
skillManager.register(new CalculatorSkill());
skillManager.register(new FileReadSkill());
skillManager.register(new CommandRunSkill());
skillManager.register(new NetworkFetchSkill());

View File

@@ -0,0 +1 @@
export * from './integrations/lanproxy';

View File

@@ -0,0 +1,2 @@
export * from './utils/logService';
export { default } from './utils/logService';

View File

@@ -0,0 +1,2 @@
export * from './agents/permissions';
export { default } from './agents/permissions';

View File

@@ -0,0 +1,2 @@
export * from './agents/sandbox';
export { default } from './agents/sandbox';

View File

@@ -0,0 +1 @@
export * from './integrations/scheduler';

View File

@@ -0,0 +1,2 @@
export * from './core/setup';
export { default } from './core/setup';

View File

@@ -0,0 +1 @@
export * from './integrations/skills';

View File

@@ -0,0 +1,100 @@
/**
* 手动启动服务流程单测:断言「先 reg 再启动」的调用顺序。
* 对应 L7启动代理服务前必须调用 /reg保证 lanproxy 使用最新 serverHost/serverPort。
*/
import { describe, it, expect, vi } from "vitest";
import { runManualStartService } from "./startServiceFlow";
describe("runManualStartService", () => {
it("应先调用 syncConfigToServer再调用 startServicekey=lanproxy", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
// 不传 callbacks测试基本流程
);
expect(sync).toHaveBeenCalledWith({ suppressToast: true });
expect(start).toHaveBeenCalledWith("lanproxy");
expect(callOrder).toEqual(["sync", "start"]);
});
it("其他 key 同样保持先 reg 再 startService 的顺序", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService("agent", {
syncConfigToServer: sync,
startService: start,
});
expect(callOrder).toEqual(["sync", "start"]);
expect(start).toHaveBeenCalledWith("agent");
});
it("callbacks 应在 reg 调用期间触发", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockImplementation(async () => {
callOrder.push("sync");
});
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
{
onRegStart: () => callOrder.push("regStart"),
onRegEnd: () => callOrder.push("regEnd"),
},
);
// regStart < sync < regEnd < start
expect(callOrder).toEqual(["regStart", "sync", "regEnd", "start"]);
});
it("reg 失败时仍应调用 onRegEnd", async () => {
const callOrder: string[] = [];
const sync = vi.fn().mockRejectedValue(new Error("reg failed"));
const start = vi.fn().mockImplementation(async () => {
callOrder.push("start");
});
await expect(
runManualStartService(
"lanproxy",
{
syncConfigToServer: sync,
startService: start,
},
{
onRegStart: () => callOrder.push("regStart"),
onRegEnd: () => callOrder.push("regEnd"),
},
),
).rejects.toThrow("reg failed");
expect(callOrder).toEqual(["regStart", "regEnd"]);
expect(start).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,42 @@
/**
* 手动启动服务流程:先 reg 同步配置,再执行启动。
* 用于「手动启动单个服务」按钮,保证 lanproxy 等使用最新 serverHost/serverPort。
* 抽离以便单测断言调用顺序(先 syncConfigToServer再 startService
*/
export interface ManualStartServiceDeps {
/** 同步配置到后端(/reg返回最新 serverHost/serverPort 等;返回值本流程未使用 */
syncConfigToServer: (opts?: { suppressToast?: boolean }) => Promise<unknown>;
/** 实际启动指定 key 的服务(内部会调 IPC 如 lanproxy.start */
startService: (key: string) => Promise<unknown>;
}
export interface ManualStartServiceCallbacks {
/** reg 调用开始时的回调 */
onRegStart?: () => void;
/** reg 调用结束时的回调(无论成功失败) */
onRegEnd?: () => void;
}
/**
* 先调用 reg 同步配置,再启动指定服务。
* 调用顺序保证syncConfigToServer 完成后再 startService(key)。
*
* @param key - 服务标识符(如 'lanproxy', 'agent', 'fileServer'
* @param deps - 依赖注入对象,包含 syncConfigToServer 和 startService 方法
* @param callbacks - 可选回调,用于在 reg 阶段显示 loading
* @throws 重新抛出 syncConfigToServer 或 startService 的错误
*/
export async function runManualStartService(
key: string,
deps: ManualStartServiceDeps,
callbacks?: ManualStartServiceCallbacks,
): Promise<void> {
callbacks?.onRegStart?.();
try {
await deps.syncConfigToServer({ suppressToast: true });
} finally {
callbacks?.onRegEnd?.();
}
await deps.startService(key);
}

View File

@@ -0,0 +1,321 @@
/**
* 日志服务 - Renderer 安全版本
*
* 功能:
* - 日志获取、过滤、导出
* - 实时日志订阅
* - 日志统计
* - 通过 IPC 转发到主进程日志
*/
import { t } from "../core/i18n";
// ==================== Types ====================
export type LogLevel = "error" | "warning" | "success" | "info";
export interface LogEntry {
id: string;
timestamp: string;
level: LogLevel;
message: string;
source?: string;
details?: unknown;
}
export interface LogStats {
total: number;
error: number;
warning: number;
success: number;
info: number;
}
export interface LogFilter {
level?: LogLevel | "all";
keyword?: string;
startTime?: string;
endTime?: string;
source?: string;
}
export type ExportFormat = "json" | "csv" | "txt";
// ==================== Log Store ====================
class LogStore {
private logs: LogEntry[] = [];
private maxLogs = 1000;
private persistMaxLogs = 100;
private subscribers: Set<(log: LogEntry) => void> = new Set();
private readonly storageKey = "app_logs_cache";
constructor() {
// 从 localStorage 加载历史日志renderer 可用)
this.loadFromStorage();
}
private getStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
private loadFromStorage(): void {
try {
const storage = this.getStorage();
if (!storage) return;
const raw = storage.getItem(this.storageKey);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
this.logs = parsed as LogEntry[];
}
} catch (error) {
console.error("[LogService] Failed to load logs:", error);
}
}
private saveToStorage(): void {
try {
const storage = this.getStorage();
if (!storage) return;
storage.setItem(
this.storageKey,
JSON.stringify(this.logs.slice(0, this.persistMaxLogs)),
);
} catch (error) {
console.error("[LogService] Failed to save logs:", error);
}
}
addLog(entry: Omit<LogEntry, "id" | "timestamp">): LogEntry {
const newLog: LogEntry = {
...entry,
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 9),
timestamp: new Date().toISOString(),
};
this.logs.unshift(newLog);
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(0, this.maxLogs);
}
// 保存到 localStorage
this.saveToStorage();
// 通知订阅者
this.subscribers.forEach((cb) => cb(newLog));
// 同步转发到主进程日志(避免 renderer 直接依赖 electron-log
const msg = `[${entry.source || "app"}]${entry.level === "success" ? " ✓" : ""} ${entry.message}`;
const level: "info" | "warn" | "error" =
entry.level === "error"
? "error"
: entry.level === "warning"
? "warn"
: "info";
if (typeof window !== "undefined") {
void window.electronAPI?.log?.write(level, msg, entry.details);
}
return newLog;
}
getLogs(filter?: LogFilter): LogEntry[] {
let result = [...this.logs];
if (!filter) return result;
if (filter.level && filter.level !== "all") {
result = result.filter((log) => log.level === filter.level);
}
if (filter.keyword) {
const keyword = filter.keyword.toLowerCase();
result = result.filter(
(log) =>
log.message.toLowerCase().includes(keyword) ||
log.source?.toLowerCase().includes(keyword),
);
}
if (filter.startTime) {
result = result.filter((log) => log.timestamp >= filter.startTime!);
}
if (filter.endTime) {
result = result.filter((log) => log.timestamp <= filter.endTime!);
}
if (filter.source) {
result = result.filter((log) => log.source === filter.source);
}
return result.sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
getStats(): LogStats {
return {
total: this.logs.length,
error: this.logs.filter((l) => l.level === "error").length,
warning: this.logs.filter((l) => l.level === "warning").length,
success: this.logs.filter((l) => l.level === "success").length,
info: this.logs.filter((l) => l.level === "info").length,
};
}
clearLogs(): void {
this.logs = [];
this.saveToStorage();
}
subscribe(callback: (log: LogEntry) => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
getSources(): string[] {
const sources = new Set<string>();
this.logs.forEach((log) => {
if (log.source) sources.add(log.source);
});
return Array.from(sources);
}
}
export const logStore = new LogStore();
// ==================== Log Service ====================
/**
* 添加日志 (快捷方法)
*/
export function addLog(
level: LogLevel,
message: string,
source?: string,
details?: unknown,
): LogEntry {
return logStore.addLog({ level, message, source, details });
}
/**
* 获取日志
*/
export function getLogs(filter?: LogFilter): LogEntry[] {
return logStore.getLogs(filter);
}
/**
* 获取日志统计
*/
export function getLogStats(): LogStats {
return logStore.getStats();
}
/**
* 清空日志
*/
export function clearLogs(): void {
logStore.clearLogs();
}
/**
* 订阅实时日志
*/
export function subscribeLogs(callback: (log: LogEntry) => void): () => void {
return logStore.subscribe(callback);
}
/**
* 获取日志来源列表
*/
export function getLogSources(): string[] {
return logStore.getSources();
}
/**
* 导出日志
*/
export function exportLogs(format: ExportFormat = "json"): string {
const logs = logStore.getLogs();
switch (format) {
case "json":
return JSON.stringify(logs, null, 2);
case "csv": {
const headers = t("Claw.Log.csvHeader") + "\n";
const rows = logs
.map(
(log) =>
`${log.timestamp},${log.level},${log.source || ""},"${log.message.replace(/"/g, '""')}"`,
)
.join("\n");
return headers + rows;
}
case "txt":
return logs
.map(
(log) =>
`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.source ? `[${log.source}] ` : ""}${log.message}`,
)
.join("\n");
default:
return JSON.stringify(logs, null, 2);
}
}
/**
* 获取日志文件路径
*/
export function getLogFilePath(): string {
return "localStorage:app_logs_cache";
}
// ==================== Quick Log Methods ====================
export const logger = {
error: (message: string, source?: string, details?: unknown) =>
addLog("error", message, source, details),
warn: (message: string, source?: string, details?: unknown) =>
addLog("warning", message, source, details),
warning: (message: string, source?: string, details?: unknown) =>
addLog("warning", message, source, details),
success: (message: string, source?: string, details?: unknown) =>
addLog("success", message, source, details),
info: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
// debug 使用 info 级别LogStore 无 debug 级别),语义上表示诊断日志
debug: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
log: (message: string, source?: string, details?: unknown) =>
addLog("info", message, source, details),
};
export default {
addLog,
getLogs,
getLogStats,
clearLogs,
subscribeLogs,
getLogSources,
exportLogs,
logger,
};

View File

@@ -0,0 +1,54 @@
/**
* Renderer 进程日志工具
* 通过 IPC 将日志写入主进程日志文件
*/
export type LogLevel = "info" | "warn" | "error";
export interface Logger {
info: (msg: string, ...args: unknown[]) => void;
warn: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
/**
* 创建带 scope 的日志记录器
* @param scope - 日志作用域,如 "SetupCheck", "DepsCheck", "AutoReconnect"
* @returns Logger 实例
*
* 使用示例:
* const log = createLogger("AutoReconnect");
* log.info("starting services");
* // 输出: [Renderer] [AutoReconnect] starting services
*/
export function createLogger(scope: string): Logger {
const prefix = `[Renderer] [${scope}]`;
return {
info: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("info", `${prefix} ${msg}`, ...args);
},
warn: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("warn", `${prefix} ${msg}`, ...args);
},
error: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("error", `${prefix} ${msg}`, ...args);
},
};
}
/**
* 默认日志记录器(无 scope
*/
export const rendererLog: Logger = {
info: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("info", `[Renderer] ${msg}`, ...args);
},
warn: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("warn", `[Renderer] ${msg}`, ...args);
},
error: (msg: string, ...args: unknown[]): void => {
window.electronAPI?.log.write("error", `[Renderer] ${msg}`, ...args);
},
};
export default rendererLog;

View File

@@ -0,0 +1,558 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
buildRedirectUrl,
buildNewSessionUrl,
buildChatSessionUrl,
syncSessionCookie,
syncCookieAndGetRedirectUrl,
syncCookieAndGetNewSessionUrl,
syncCookieAndGetChatUrl,
persistTicketCookie,
} from "./sessionUrl";
const { mockSettings, mockSession, mockLogger } = vi.hoisted(() => ({
mockSettings: { get: vi.fn(), set: vi.fn() },
mockSession: { setCookie: vi.fn(), getCookie: vi.fn() },
mockLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.stubGlobal("window", {
electronAPI: { settings: mockSettings, session: mockSession },
});
const mockGetCurrentAuth = vi.fn();
vi.mock("../core/auth", () => ({
getCurrentAuth: (...args: unknown[]) => mockGetCurrentAuth(...args),
}));
vi.mock("./logService", () => ({
logger: mockLogger,
}));
beforeEach(() => {
vi.clearAllMocks();
mockSession.setCookie.mockResolvedValue({ success: true });
mockSession.getCookie.mockResolvedValue({ success: true, found: false });
});
describe("buildRedirectUrl", () => {
it("strips trailing slashes and builds correct URL", () => {
expect(buildRedirectUrl("https://example.com///", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
expect(buildRedirectUrl("https://example.com/", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
expect(buildRedirectUrl("https://example.com", "user1")).toBe(
"https://example.com/api/sandbox/config/redirect/user1?hideMenu=true",
);
});
it("works with numeric configId", () => {
expect(buildRedirectUrl("https://example.com", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/42?hideMenu=true",
);
});
});
describe("buildNewSessionUrl", () => {
it("builds correct new-session URL", () => {
expect(buildNewSessionUrl("https://example.com", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true",
);
});
it("strips trailing slashes", () => {
expect(buildNewSessionUrl("https://example.com///", 42)).toBe(
"https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true",
);
});
});
describe("buildChatSessionUrl", () => {
it("builds correct URL with session id", () => {
expect(buildChatSessionUrl("https://example.com", "sess-abc-123")).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc-123?hideMenu=true",
);
});
it("strips trailing slashes", () => {
expect(buildChatSessionUrl("https://example.com///", "s1")).toBe(
"https://example.com/api/sandbox/config/redirect/chat/s1?hideMenu=true",
);
});
});
describe("syncSessionCookie", () => {
it("不设 domain 和 secure由主进程根据 URL scheme 判断", async () => {
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "https://app.example.com:8080/path",
name: "ticket",
value: "tok123",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
expect(payload).not.toHaveProperty("secure");
});
it("invalid URL 也不设 domain 和 secure", async () => {
await syncSessionCookie("not-a-valid-url", "tok123");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "not-a-valid-url",
name: "ticket",
value: "tok123",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
expect(payload).not.toHaveProperty("secure");
});
it("host-only cookie — 不设 domain与 webview Set-Cookie 行为一致", async () => {
await syncSessionCookie("https://example.com", "tok");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).not.toHaveProperty("domain");
});
it("IPv4 host 也不设 domain", async () => {
await syncSessionCookie("http://127.0.0.1:8080", "tok-ip");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "http://127.0.0.1:8080",
name: "ticket",
value: "tok-ip",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
});
it("localhost 也不设 domain", async () => {
await syncSessionCookie("http://localhost:3000", "tok-local");
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).toEqual({
url: "http://localhost:3000",
name: "ticket",
value: "tok-local",
httpOnly: true,
});
expect(payload).not.toHaveProperty("domain");
});
});
describe("syncCookieAndGetRedirectUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns null when domain is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 1, username: "u" },
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns null when userId is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { currentDomain: "https://example.com", username: "u" },
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBeNull();
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("returns redirect URL and syncs cookie when all present", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com",
name: "ticket",
value: "my-token",
}),
);
// host-only cookie不设 domain
const payload = mockSession.setCookie.mock.calls[0][0];
expect(payload).not.toHaveProperty("domain");
});
it("clears local auth token after syncing to webview cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
await syncCookieAndGetRedirectUrl();
expect(mockSession.setCookie).toHaveBeenCalled();
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
});
it("keeps local auth token when cookie sync fails", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
mockSession.setCookie.mockResolvedValue({
success: false,
error: "cookie write failed",
});
await expect(syncCookieAndGetRedirectUrl()).rejects.toThrow(
"cookie write failed",
);
expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null);
});
it("returns URL without syncing cookie when token is null", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue(null);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
it("overwrites cookie when domain cache token exists (regardless of existing cookie)", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockImplementation((key: string) => {
if (key === "auth.token") return Promise.resolve(null);
if (key === "auth.tokens.example.com") return Promise.resolve("my-token");
return Promise.resolve(null);
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
// 有 token 时无条件覆盖,不管现有 cookie 状态
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com",
name: "ticket",
value: "my-token",
}),
);
});
it("overwrites existing ticket when one-shot auth token is present", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("fresh-token");
mockSession.getCookie.mockResolvedValue({ success: true, found: true });
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
});
it("prints diagnostic logs regardless of NODE_ENV", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockLogger.debug).toHaveBeenCalledWith(
"[SessionUrl] Pre-session state",
"SessionUrl",
expect.objectContaining({
domain: "https://example.com",
hasToken: true,
}),
);
expect(mockLogger.debug).toHaveBeenCalledWith(
"[SessionUrl] ticket cookie synced successfully",
"SessionUrl",
expect.objectContaining({ domain: "https://example.com" }),
);
});
});
describe("syncCookieAndGetNewSessionUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetNewSessionUrl();
expect(result).toBeNull();
});
it("returns new-session URL and syncs cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetNewSessionUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/new/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
});
describe("syncCookieAndGetChatUrl", () => {
it("returns null when not logged in", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: false,
userInfo: null,
});
const result = await syncCookieAndGetChatUrl("sess-1");
expect(result).toBeNull();
});
it("returns null when domain is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 1, username: "u" },
});
const result = await syncCookieAndGetChatUrl("sess-1");
expect(result).toBeNull();
});
it("returns chat URL and syncs cookie", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetChatUrl("sess-abc");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("returns chat URL even when user id is missing", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue("my-token");
const result = await syncCookieAndGetChatUrl("sess-no-id");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-no-id?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("returns chat URL without cookie when token is null", async () => {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: "https://example.com", username: "u" },
});
mockSettings.get.mockResolvedValue(null);
const result = await syncCookieAndGetChatUrl("sess-abc");
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
});
});
// --- JWT expiry & token cache clearing ---
/** Helper: build a minimal JWT-like string with a given exp (epoch seconds). */
function makeJwt(exp: number): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = btoa(JSON.stringify({ exp }));
return `${header}.${payload}.sig`;
}
function setupAuthWithDomain(domain = "https://example.com") {
mockGetCurrentAuth.mockResolvedValue({
isLoggedIn: true,
userInfo: { id: 7, currentDomain: domain, username: "u" },
});
}
describe("syncCookieAndBuildUrl — JWT expiry guard", () => {
it("skips cookie sync when one-shot token is expired, clears AUTH_TOKEN only", async () => {
setupAuthWithDomain();
const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60); // expired 60s ago
mockSettings.get.mockResolvedValue(expiredJwt);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
// Only AUTH_TOKEN cleared, not domain cache
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
expect(mockSettings.set).not.toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
});
it("skips cookie sync when domain cache token is expired, clears domain key only", async () => {
setupAuthWithDomain();
const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60);
mockSettings.get.mockImplementation((key: string) => {
if (key === "auth.token") return Promise.resolve(null);
if (key === "auth.tokens.example.com") return Promise.resolve(expiredJwt);
return Promise.resolve(null);
});
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).not.toHaveBeenCalled();
// Only domain key cleared, not global AUTH_TOKEN
expect(mockSettings.set).toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null);
});
it("proceeds with cookie sync when token is not expired", async () => {
setupAuthWithDomain();
const validJwt = makeJwt(Math.floor(Date.now() / 1000) + 3600); // expires in 1h
mockSettings.get.mockResolvedValue(validJwt);
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ name: "ticket", value: validJwt }),
);
});
it("proceeds with cookie sync for non-JWT token (no exp field)", async () => {
setupAuthWithDomain();
mockSettings.get.mockResolvedValue("opaque-token-not-jwt");
const result = await syncCookieAndGetRedirectUrl();
expect(result).toBe(
"https://example.com/api/sandbox/config/redirect/7?hideMenu=true",
);
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ value: "opaque-token-not-jwt" }),
);
});
it("proceeds with cookie sync for malformed JWT", async () => {
setupAuthWithDomain();
mockSettings.get.mockResolvedValue("not-even..a..jwt");
const result = await syncCookieAndGetRedirectUrl();
expect(mockSession.setCookie).toHaveBeenCalled();
});
it("treats token as valid if expired within clock-skew buffer (30s)", async () => {
setupAuthWithDomain();
// Expired only 10 seconds ago — within the 30s grace period
const barelyExpiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 10);
mockSettings.get.mockResolvedValue(barelyExpiredJwt);
const result = await syncCookieAndGetRedirectUrl();
// Should still sync — not treated as expired
expect(mockSession.setCookie).toHaveBeenCalledWith(
expect.objectContaining({ value: barelyExpiredJwt }),
);
});
});
describe("persistTicketCookie", () => {
it("clears both AUTH_TOKEN and domain token cache after webview login", async () => {
mockSession.getCookie.mockResolvedValue({ found: true });
mockSession.flushStore = vi.fn().mockResolvedValue(undefined);
await persistTicketCookie("https://example.com");
expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null);
expect(mockSettings.set).toHaveBeenCalledWith(
"auth.tokens.example.com",
null,
);
});
it("skips clearing when no ticket cookie is present", async () => {
mockSession.getCookie.mockResolvedValue({ found: false });
await persistTicketCookie("https://example.com");
expect(mockSettings.set).not.toHaveBeenCalled();
});
it("does not throw on failure, logs warning instead", async () => {
mockSession.getCookie.mockRejectedValue(new Error("cookie read failed"));
await expect(
persistTicketCookie("https://example.com"),
).resolves.toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
"[SessionUrl] persistTicketCookie failed",
"SessionUrl",
expect.objectContaining({ domain: "https://example.com" }),
);
});
});

View File

@@ -0,0 +1,287 @@
/**
* Shared utility for constructing session redirect URLs and syncing cookies.
* Used by both ClientPage and SessionsPage.
*
* Redirect URL patterns:
* /api/sandbox/config/redirect/{sandboxConfigId} - enter sandbox (开始会话)
* /api/sandbox/config/redirect/new/{sandboxConfigId} - create new session (新建会话)
* /api/sandbox/config/redirect/chat/{sessionId} - enter history session (进入历史会话)
*/
import { getCurrentAuth } from "../core/auth";
import { AUTH_KEYS } from "@shared/constants";
import { logger } from "./logService";
import { getDomainTokenKey } from "@shared/utils/domain";
/**
* 解析 JWT exp 字段,返回 ISO 时间字符串或 null。
* 仅读取 exp不验证签名。
*/
/** JWT 过期后仍视为有效的宽限时间(毫秒),用于容忍时钟偏移。 */
const JWT_EXPIRY_BUFFER_MS = 30_000;
function parseJwtExpDate(token: string): string | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = JSON.parse(
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
);
if (typeof payload.exp === "number") {
return new Date(payload.exp * 1000).toISOString();
}
} catch {
/* ignore */
}
return null;
}
/**
* Build redirect URL for entering the sandbox dashboard (开始会话).
*/
export function buildRedirectUrl(
domain: string,
configId: string | number,
): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/${configId}?hideMenu=true`;
}
/**
* Build redirect URL for creating a new session (新建会话).
*/
export function buildNewSessionUrl(
domain: string,
configId: string | number,
): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/new/${configId}?hideMenu=true`;
}
/**
* Build redirect URL for entering a history session (进入历史会话).
*/
export function buildChatSessionUrl(domain: string, sessionId: string): string {
const normalizedDomain = domain.replace(/\/+$/, "");
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
}
export async function syncSessionCookie(
domain: string,
token: string,
): Promise<void> {
// 不设置 domain → host-only cookie与 webview 内 Set-Cookie 行为一致,
// 确保 webview 登录后能覆盖 Electron 侧写入的 ticket避免 count=2 冲突
// 不设置 secure → 由主进程根据 URL scheme 自动判断(支持 HTTP 场景)
const payload: {
url: string;
name: string;
value: string;
httpOnly: boolean;
} = {
url: domain,
name: "ticket",
value: token,
httpOnly: true,
};
const result = await window.electronAPI?.session.setCookie(payload);
if (!result?.success) {
// 只记录域名和错误,不记录 token 等敏感信息
throw new Error(result?.error || `session:setCookie failed for ${domain}`);
}
// 写入后立即回读,便于定位"已写入但页面仍未登录"的问题
try {
const verify = await window.electronAPI?.session.getCookie({
url: domain,
name: "ticket",
});
logger.debug("[SessionUrl] ticket cookie read-back result", "SessionUrl", {
domain,
found: !!verify?.found,
count: verify?.count ?? 0,
});
} catch (error) {
logger.debug("[SessionUrl] ticket cookie read-back error", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Shared helper: check auth, sync cookie, then call buildUrl to produce the final URL.
*/
async function syncCookieAndBuildUrl<T>(
buildUrl: (domain: string, configId?: number) => T,
options?: { requireConfigId?: boolean },
): Promise<T | null> {
const requireConfigId = options?.requireConfigId ?? true;
const auth = await getCurrentAuth();
if (!auth.isLoggedIn) return null;
const domain = auth.userInfo?.currentDomain;
const configId = auth.userInfo?.id;
if (!domain) return null;
if (requireConfigId && !configId) return null;
const oneShotToken = (await window.electronAPI?.settings.get(
AUTH_KEYS.AUTH_TOKEN,
)) as string | null;
const domainTokenKey = getDomainTokenKey(domain);
let tokenSource: "one_shot" | "domain_cache" | "none" = "none";
let token = oneShotToken;
if (token) {
tokenSource = "one_shot";
} else {
token = (await window.electronAPI?.settings.get(domainTokenKey)) as
| string
| null;
if (token) tokenSource = "domain_cache";
}
logger.debug("[SessionUrl] Pre-session state", "SessionUrl", {
domain,
hasToken: !!token,
tokenSource,
});
if (!token) {
// reg 未返回 token → 不做任何操作(不清空现有 cookie
logger.debug(
"[SessionUrl] No token available, skipping ticket sync",
"SessionUrl",
{ domain },
);
return buildUrl(domain, configId);
}
// 检查缓存 token 是否已过期JWT only。若已过期清空缓存并跳过覆写
// 避免用旧 token 覆盖 webview 内重新登录后获得的有效 ticket防御性兜底
const expDate = parseJwtExpDate(token);
if (
expDate &&
new Date(expDate).getTime() + JWT_EXPIRY_BUFFER_MS <= Date.now()
) {
logger.info(
"[SessionUrl] Cached token expired, clearing cache to avoid overwriting fresh webview cookie",
"SessionUrl",
{ domain, expDate },
);
// Only clear the token source that was actually checked and found expired.
// Avoids clearing a valid one-shot token when domain cache is expired, and vice versa.
if (tokenSource === "one_shot") {
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
} else {
await window.electronAPI?.settings.set(domainTokenKey, null);
}
return buildUrl(domain, configId);
}
// 有有效 token → 同步到 webview cookie
try {
await syncSessionCookie(domain, token);
// one-shot token 成功后清除,避免反复覆盖 webview 内 ticket
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
logger.debug(
"[SessionUrl] ticket cookie synced successfully",
"SessionUrl",
{
domain,
tokenSource,
},
);
} catch (error) {
logger.debug("[SessionUrl] ticket cookie sync failed", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
// 失败时不要清空 token保留重试机会
logger.error(
"[SessionUrl] Cookie sync failed, keeping local token for retry",
"SessionUrl",
{
domain,
error: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
return buildUrl(domain, configId);
}
/**
* Sync cookie and return the sandbox redirect URL (开始会话).
*/
export async function syncCookieAndGetRedirectUrl(): Promise<string | null> {
return syncCookieAndBuildUrl(buildRedirectUrl, { requireConfigId: true });
}
/**
* Sync cookie and return the new-session redirect URL (新建会话).
*/
export async function syncCookieAndGetNewSessionUrl(): Promise<string | null> {
return syncCookieAndBuildUrl(buildNewSessionUrl, { requireConfigId: true });
}
/**
* Sync cookie and return the chat-session redirect URL (进入历史会话).
*/
export async function syncCookieAndGetChatUrl(
sessionId: string,
): Promise<string | null> {
return syncCookieAndBuildUrl(
(domain) => buildChatSessionUrl(domain, sessionId),
{ requireConfigId: false },
);
}
/**
* 将 webview 内登录产生的 ticket cookie 刷到磁盘。
*
* webview 内 Set-Cookie 产生的 ticket 带有 Max-Age持久 cookie
* 但 Chromium 不保证立即写入磁盘。如果 Electron 在 flush 前退出(开发调试常见),
* cookie 会丢失。此函数主动调用 flushStore 确保持久化。
*
* 应在检测到 webview 登录成功(从 /login 跳转到非 login 页面)时调用。
*/
export async function persistTicketCookie(domain: string): Promise<void> {
try {
const result = await window.electronAPI?.session.getCookie({
url: domain,
name: "ticket",
});
if (!result?.found) {
logger.debug(
"[SessionUrl] persistTicketCookie: no ticket cookie, skipping",
"SessionUrl",
{ domain },
);
return;
}
// 主动刷盘,确保 Chromium 将 cookie 写入磁盘
await window.electronAPI?.session.flushStore();
logger.info(
"[SessionUrl] persistTicketCookie: cookie store flushed",
"SessionUrl",
{ domain },
);
// webview 内重新登录后,清除 settings 里的旧 token cache。
// 否则下次打开 webview 时syncCookieAndBuildUrl 会用过期的缓存 token
// 覆盖刚登录得到的新 ticket导致服务端再次拒绝并跳到登录页。
const domainTokenKey = getDomainTokenKey(domain);
await window.electronAPI?.settings.set(AUTH_KEYS.AUTH_TOKEN, null);
await window.electronAPI?.settings.set(domainTokenKey, null);
logger.info(
"[SessionUrl] persistTicketCookie: stale token cache cleared",
"SessionUrl",
{ domain },
);
} catch (error) {
logger.warn("[SessionUrl] persistTicketCookie failed", "SessionUrl", {
domain,
error: error instanceof Error ? error.message : String(error),
});
}
}