chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './agents/agentRunner';
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/ai';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/api';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core/auth';
|
||||
@@ -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();
|
||||
@@ -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 传入 fetch(P0-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)→ 应抛出包含"请求超时"的错误,不暴露原始 AbortError(P0-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');
|
||||
});
|
||||
});
|
||||
@@ -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 超时后抛出 TimeoutError(name === '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,
|
||||
});
|
||||
}
|
||||
@@ -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,而非全局 key(P0-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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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_config(LanproxySettings 可编辑的配置)
|
||||
// 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);
|
||||
// 密码不持久化保存,savedKey(configKey)用于后续自动认证
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/fileServer';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/im';
|
||||
@@ -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';
|
||||
@@ -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 webview(Java 前端 → Java 后端 → Electron),
|
||||
// chat() / streamChat() 不经过此路径,[PERF][Frontend] 日志在实际链路中不触发。
|
||||
// 代码保留供 Electron renderer 直连场景使用(如未来去除 webview 的方案)。
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
const t0 = Date.now();
|
||||
// 优先通过 IPC(AcpEngine 直接处理,返回 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),
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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,
|
||||
};
|
||||
}
|
||||
// 回退到 HTTP(rcoder 返回 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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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());
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/lanproxy';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './utils/logService';
|
||||
export { default } from './utils/logService';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './agents/permissions';
|
||||
export { default } from './agents/permissions';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './agents/sandbox';
|
||||
export { default } from './agents/sandbox';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/scheduler';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './core/setup';
|
||||
export { default } from './core/setup';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './integrations/skills';
|
||||
@@ -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,再调用 startService(key=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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user