mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.17.4
This commit is contained in:
@@ -96,3 +96,7 @@ Kiro 凭证采用完全独立的副本策略:
|
||||
- 日志输出使用 `tracing` 宏
|
||||
- API 请求调试文件保存在 `~/.proxycast/logs/`
|
||||
- 使用 `debug_kiro_credentials` 命令调试凭证加载
|
||||
|
||||
## 文档维护
|
||||
|
||||
文档维护规范详见 `.kiro/steering/doc-maintenance.md`(Kiro 自动加载)。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# docs
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
项目文档目录,包含技术规格、操作指南和文档站点配置。
|
||||
使用 Nuxt Content 构建文档站点。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `content/` - 文档内容(Markdown)
|
||||
- `images/` - 文档图片资源
|
||||
- `TECH_SPEC.md` - 技术规格文档
|
||||
- `LLM_FLOW_MONITOR_SPEC.md` - LLM 流量监控规格
|
||||
- `ops.md` - 运维操作指南
|
||||
- `app.config.ts` - Nuxt 应用配置
|
||||
- `nuxt.config.ts` - Nuxt 框架配置
|
||||
- `package.json` - 文档站点依赖
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.17.3",
|
||||
"version": "0.17.4",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Playwright Login Sidecar
|
||||
|
||||
ProxyCast 的 Playwright 指纹浏览器登录 Sidecar 脚本。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `index.js` | Sidecar 主入口,处理 stdin/stdout JSON 通信 |
|
||||
| `browser-context.js` | 浏览器上下文工厂,配置反检测参数 |
|
||||
| `oauth-handler.js` | OAuth 流程处理器,处理授权码提取 |
|
||||
| `test/browser-config.property.test.js` | 浏览器配置属性测试 |
|
||||
| `test/oauth-url.property.test.js` | OAuth URL 解析属性测试 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm test
|
||||
|
||||
# 运行属性测试
|
||||
npm run test:property
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
此脚本通过 Tauri Sidecar 机制调用,不建议直接运行。
|
||||
|
||||
## 通信协议
|
||||
|
||||
### 请求格式
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "login" | "cancel" | "check",
|
||||
"provider": "Google" | "Github" | "BuilderId",
|
||||
"authUrl": "https://oauth.provider.com/authorize?...",
|
||||
"callbackUrl": "http://localhost:PORT/callback"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true | false,
|
||||
"action": "login" | "cancel" | "check" | "progress" | "ready",
|
||||
"data": {
|
||||
"code": "authorization_code",
|
||||
"state": "state_value",
|
||||
"error": "error_message",
|
||||
"available": true,
|
||||
"browserPath": "/path/to/chromium",
|
||||
"message": "进度消息"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的操作
|
||||
|
||||
### check - 检查 Playwright 可用性
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "action": "check" }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "check",
|
||||
"data": {
|
||||
"available": true,
|
||||
"browserPath": "/Users/xxx/Library/Caches/ms-playwright/chromium-xxx/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### login - 启动 OAuth 登录
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"action": "login",
|
||||
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||
"callbackUrl": "http://localhost:8080/callback"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "login",
|
||||
"data": {
|
||||
"code": "4/0AX4XfWh...",
|
||||
"state": "random_state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cancel - 取消登录
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "action": "cancel" }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "cancel",
|
||||
"data": { "message": "登录已取消" }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @file browser-context.js
|
||||
* @description 浏览器上下文工厂,配置 Playwright 反检测参数
|
||||
* @module playwright-login/browser-context
|
||||
*
|
||||
* Requirements: 3.2, 3.3
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { join } from 'path';
|
||||
import { homedir, platform } from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
/**
|
||||
* 默认浏览器配置
|
||||
* @type {BrowserConfig}
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
// 视口大小 - 使用常见的桌面分辨率
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
// 真实的 Chrome 用户代理
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
// 反检测启动参数
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
'--disable-site-isolation-trials',
|
||||
'--disable-web-security',
|
||||
'--disable-features=BlockInsecurePrivateNetworkRequests',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-infobars',
|
||||
'--window-position=0,0',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-default-apps',
|
||||
'--enable-features=NetworkService,NetworkServiceInProcess',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取系统 Chrome 可执行文件路径
|
||||
* @returns {string | null} Chrome 路径,如果未找到则返回 null
|
||||
*/
|
||||
export function getSystemChromePath() {
|
||||
const os = platform();
|
||||
|
||||
if (os === 'darwin') {
|
||||
// macOS: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
join(homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
} else if (os === 'win32') {
|
||||
// Windows: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
join(homedir(), 'AppData\\Local\\Google\\Chrome\\Application\\chrome.exe'),
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
} else {
|
||||
// Linux: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Playwright Chromium 可执行文件路径
|
||||
* @returns {string | null} Playwright Chromium 路径,如果未找到则返回 null
|
||||
*/
|
||||
export function getPlaywrightChromiumPath() {
|
||||
const home = homedir();
|
||||
const os = platform();
|
||||
|
||||
// Playwright 浏览器缓存目录
|
||||
let cacheDir;
|
||||
if (os === 'darwin') {
|
||||
cacheDir = join(home, 'Library', 'Caches', 'ms-playwright');
|
||||
} else if (os === 'win32') {
|
||||
cacheDir = join(home, 'AppData', 'Local', 'ms-playwright');
|
||||
} else {
|
||||
cacheDir = join(home, '.cache', 'ms-playwright');
|
||||
}
|
||||
|
||||
// 查找 chromium 目录
|
||||
const chromiumDirs = [
|
||||
'chromium-1140', 'chromium-1134', 'chromium-1124', 'chromium-1117',
|
||||
'chromium-1112', 'chromium-1108', 'chromium-1105', 'chromium-1097',
|
||||
'chromium-1091', 'chromium-1084', 'chromium-1080', 'chromium-1076',
|
||||
'chromium-1067', 'chromium-1060', 'chromium-1055', 'chromium-1048',
|
||||
'chromium-1045', 'chromium-1041', 'chromium-1033', 'chromium-1028',
|
||||
'chromium-1024', 'chromium-1020', 'chromium-1015', 'chromium-1012',
|
||||
'chromium-1008', 'chromium-1005', 'chromium-1000', 'chromium'
|
||||
];
|
||||
|
||||
for (const dir of chromiumDirs) {
|
||||
let execPath;
|
||||
if (os === 'darwin') {
|
||||
execPath = join(cacheDir, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
|
||||
} else if (os === 'win32') {
|
||||
execPath = join(cacheDir, dir, 'chrome-win', 'chrome.exe');
|
||||
} else {
|
||||
execPath = join(cacheDir, dir, 'chrome-linux', 'chrome');
|
||||
}
|
||||
|
||||
if (existsSync(execPath)) {
|
||||
return execPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的浏览器路径(优先系统 Chrome)
|
||||
* @returns {{ path: string, source: 'system' | 'playwright' } | null}
|
||||
*/
|
||||
export function getAvailableBrowserPath() {
|
||||
// 优先使用系统 Chrome
|
||||
const systemChrome = getSystemChromePath();
|
||||
if (systemChrome) {
|
||||
return { path: systemChrome, source: 'system' };
|
||||
}
|
||||
|
||||
// 其次使用 Playwright Chromium
|
||||
const playwrightChromium = getPlaywrightChromiumPath();
|
||||
if (playwrightChromium) {
|
||||
return { path: playwrightChromium, source: 'playwright' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据目录路径
|
||||
* @returns {string} 用户数据目录的绝对路径
|
||||
*/
|
||||
export function getUserDataDir() {
|
||||
return join(homedir(), '.proxycast', 'playwright-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建浏览器配置对象
|
||||
* @param {Partial<BrowserConfig>} [overrides] - 覆盖默认配置的选项
|
||||
* @returns {BrowserConfig} 完整的浏览器配置
|
||||
*/
|
||||
export function createBrowserConfig(overrides = {}) {
|
||||
const config = {
|
||||
userDataDir: overrides.userDataDir || getUserDataDir(),
|
||||
viewport: {
|
||||
...DEFAULT_CONFIG.viewport,
|
||||
...(overrides.viewport || {})
|
||||
},
|
||||
userAgent: overrides.userAgent || DEFAULT_CONFIG.userAgent,
|
||||
args: overrides.args || [...DEFAULT_CONFIG.args]
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证浏览器配置是否完整有效
|
||||
* @param {BrowserConfig} config - 浏览器配置对象
|
||||
* @returns {{ valid: boolean, errors: string[] }} 验证结果
|
||||
*/
|
||||
export function validateBrowserConfig(config) {
|
||||
const errors = [];
|
||||
|
||||
// 检查 userDataDir
|
||||
if (!config.userDataDir || typeof config.userDataDir !== 'string') {
|
||||
errors.push('userDataDir 必须是有效的字符串路径');
|
||||
}
|
||||
|
||||
// 检查 viewport
|
||||
if (!config.viewport) {
|
||||
errors.push('viewport 配置缺失');
|
||||
} else {
|
||||
if (typeof config.viewport.width !== 'number' || config.viewport.width < 1024) {
|
||||
errors.push('viewport.width 必须 >= 1024');
|
||||
}
|
||||
if (typeof config.viewport.height !== 'number' || config.viewport.height < 768) {
|
||||
errors.push('viewport.height 必须 >= 768');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 userAgent
|
||||
if (!config.userAgent || typeof config.userAgent !== 'string') {
|
||||
errors.push('userAgent 必须是有效的字符串');
|
||||
} else if (!config.userAgent.includes('Mozilla') || !config.userAgent.includes('Chrome')) {
|
||||
errors.push('userAgent 必须包含真实的浏览器标识');
|
||||
}
|
||||
|
||||
// 检查反检测参数
|
||||
if (!Array.isArray(config.args)) {
|
||||
errors.push('args 必须是数组');
|
||||
} else {
|
||||
const hasAntiDetection = config.args.some(arg =>
|
||||
arg.includes('AutomationControlled')
|
||||
);
|
||||
if (!hasAntiDetection) {
|
||||
errors.push('args 必须包含反检测参数 --disable-blink-features=AutomationControlled');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Playwright 浏览器上下文
|
||||
* @param {Partial<BrowserConfig>} [options] - 配置选项
|
||||
* @returns {Promise<{ context: BrowserContext, config: BrowserConfig, browserSource: string }>} 浏览器上下文和配置
|
||||
*/
|
||||
export async function createBrowserContext(options = {}) {
|
||||
const config = createBrowserConfig(options);
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(`浏览器配置无效: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 获取可用的浏览器路径
|
||||
const browserInfo = getAvailableBrowserPath();
|
||||
|
||||
if (!browserInfo) {
|
||||
throw new Error('未找到可用的浏览器。请安装 Google Chrome 或运行: npx playwright install chromium');
|
||||
}
|
||||
|
||||
console.error(`[Browser] 使用 ${browserInfo.source} 浏览器: ${browserInfo.path}`);
|
||||
|
||||
// 使用 launchPersistentContext 以支持持久化用户数据
|
||||
const context = await chromium.launchPersistentContext(config.userDataDir, {
|
||||
headless: false,
|
||||
executablePath: browserInfo.path, // 使用检测到的浏览器
|
||||
viewport: config.viewport,
|
||||
userAgent: config.userAgent,
|
||||
args: config.args,
|
||||
ignoreHTTPSErrors: true,
|
||||
// 额外的反检测设置
|
||||
bypassCSP: true,
|
||||
javaScriptEnabled: true,
|
||||
// 模拟真实用户环境
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
// 权限设置
|
||||
permissions: ['geolocation', 'notifications'],
|
||||
// 设备像素比
|
||||
deviceScaleFactor: 2,
|
||||
// 禁用 WebDriver 标志
|
||||
extraHTTPHeaders: {
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||
}
|
||||
});
|
||||
|
||||
return { context, config, browserSource: browserInfo.source };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrowserConfig
|
||||
* @property {string} userDataDir - 用户数据目录路径
|
||||
* @property {{ width: number, height: number }} viewport - 视口大小
|
||||
* @property {string} userAgent - 用户代理字符串
|
||||
* @property {string[]} args - 浏览器启动参数
|
||||
*/
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file index.js
|
||||
* @description Playwright Sidecar 主入口,处理 stdin/stdout JSON 通信
|
||||
* @module playwright-login
|
||||
*
|
||||
* Requirements: 3.1, 3.4, 3.5, 5.1, 5.2, 5.3, 5.4
|
||||
*
|
||||
* 通信协议:
|
||||
* - 请求格式: { action: 'login' | 'cancel' | 'check', provider?: string, callbackUrl?: string, authUrl?: string }
|
||||
* - 响应格式: { success: boolean, action: string, data?: { code?, state?, error?, available?, browserPath? } }
|
||||
*
|
||||
* 错误处理 (Requirements 5.1, 5.2, 5.3, 5.4):
|
||||
* - 浏览器启动失败: 返回详细错误信息和故障排除建议
|
||||
* - OAuth 超时: 返回超时错误,允许重试
|
||||
* - 用户取消: 优雅处理取消操作
|
||||
* - 详细日志: 记录所有错误用于调试
|
||||
*/
|
||||
|
||||
import { createInterface } from 'readline';
|
||||
import { chromium } from 'playwright';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir, platform } from 'os';
|
||||
import { createBrowserContext, getAvailableBrowserPath, getSystemChromePath, getPlaywrightChromiumPath } from './browser-context.js';
|
||||
import { createOAuthHandler } from './oauth-handler.js';
|
||||
|
||||
/** @type {import('playwright').BrowserContext | null} */
|
||||
let currentContext = null;
|
||||
|
||||
/** @type {ReturnType<typeof createOAuthHandler> | null} */
|
||||
let currentOAuthHandler = null;
|
||||
|
||||
/**
|
||||
* 发送响应到 stdout
|
||||
* @param {SidecarResponse} response
|
||||
*/
|
||||
function sendResponse(response) {
|
||||
console.log(JSON.stringify(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送进度消息
|
||||
* @param {string} message
|
||||
*/
|
||||
function sendProgress(message) {
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'progress',
|
||||
data: { message }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器是否可用(优先系统 Chrome)
|
||||
* @returns {Promise<{ available: boolean, browserPath?: string, browserSource?: string, error?: string }>}
|
||||
*/
|
||||
async function checkPlaywrightAvailable() {
|
||||
try {
|
||||
const browserInfo = getAvailableBrowserPath();
|
||||
|
||||
if (browserInfo) {
|
||||
return {
|
||||
available: true,
|
||||
browserPath: browserInfo.path,
|
||||
browserSource: browserInfo.source
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
error: '未找到可用的浏览器。请安装 Google Chrome 或运行: npx playwright install chromium'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: `检查浏览器时出错: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录请求
|
||||
* @param {SidecarRequest} request
|
||||
*/
|
||||
async function handleLogin(request) {
|
||||
const { authUrl, callbackUrl } = request;
|
||||
|
||||
if (!authUrl) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: { error: '缺少 authUrl 参数' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callbackUrl) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: { error: '缺少 callbackUrl 参数' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendProgress('正在启动浏览器...');
|
||||
|
||||
// 创建浏览器上下文
|
||||
// Requirements: 5.1 - 处理浏览器启动失败
|
||||
let context, config;
|
||||
try {
|
||||
const result = await createBrowserContext();
|
||||
context = result.context;
|
||||
config = result.config;
|
||||
currentContext = context;
|
||||
} catch (browserError) {
|
||||
// 浏览器启动失败,返回详细错误信息
|
||||
const errorMessage = browserError.message || '未知错误';
|
||||
console.error('[Playwright] 浏览器启动失败:', errorMessage);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: `启动浏览器失败: ${errorMessage}`,
|
||||
errorType: 'BROWSER_LAUNCH_FAILED'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(`浏览器已启动,用户数据目录: ${config.userDataDir}`);
|
||||
|
||||
// 创建 OAuth 处理器
|
||||
// Requirements: 5.2 - 处理 OAuth 超时
|
||||
currentOAuthHandler = createOAuthHandler(context, {
|
||||
authUrl,
|
||||
callbackUrl,
|
||||
timeout: 5 * 60 * 1000, // 5 分钟超时
|
||||
onProgress: sendProgress
|
||||
});
|
||||
|
||||
// 启动 OAuth 流程
|
||||
const result = await currentOAuthHandler.start();
|
||||
|
||||
// 关闭浏览器
|
||||
await cleanup();
|
||||
|
||||
if (result.success) {
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'login',
|
||||
data: {
|
||||
code: result.code,
|
||||
state: result.state
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 根据错误类型返回不同的错误信息
|
||||
// Requirements: 5.2, 5.3, 5.4
|
||||
let errorType = 'UNKNOWN';
|
||||
const errorMessage = result.error || '未知错误';
|
||||
|
||||
if (errorMessage.includes('超时') || errorMessage.includes('timeout')) {
|
||||
errorType = 'OAUTH_TIMEOUT';
|
||||
} else if (errorMessage.includes('取消') || errorMessage.includes('cancel')) {
|
||||
errorType = 'USER_CANCELLED';
|
||||
} else if (errorMessage.includes('关闭') || errorMessage.includes('closed')) {
|
||||
errorType = 'BROWSER_CLOSED';
|
||||
} else if (errorMessage.includes('code') || errorMessage.includes('授权码')) {
|
||||
errorType = 'CODE_EXTRACTION_FAILED';
|
||||
}
|
||||
|
||||
console.error('[Playwright] OAuth 流程失败:', errorMessage, 'Type:', errorType);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: errorMessage,
|
||||
errorType
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
|
||||
// Requirements: 5.4 - 记录详细错误日志
|
||||
const errorMessage = error.message || '未知错误';
|
||||
console.error('[Playwright] 登录过程出错:', errorMessage);
|
||||
console.error('[Playwright] 错误堆栈:', error.stack);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: errorMessage,
|
||||
errorType: 'SCRIPT_ERROR'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消请求
|
||||
* Requirements: 5.3 - 处理用户取消
|
||||
*/
|
||||
async function handleCancel() {
|
||||
try {
|
||||
console.log('[Playwright] 收到取消请求');
|
||||
|
||||
if (currentOAuthHandler) {
|
||||
await currentOAuthHandler.cancel();
|
||||
}
|
||||
await cleanup();
|
||||
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'cancel',
|
||||
data: { message: '登录已取消' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Playwright] 取消登录时出错:', error.message);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'cancel',
|
||||
data: { error: error.message || '取消失败' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理检查请求
|
||||
*/
|
||||
async function handleCheck() {
|
||||
const status = await checkPlaywrightAvailable();
|
||||
|
||||
sendResponse({
|
||||
success: status.available,
|
||||
action: 'check',
|
||||
data: status
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
async function cleanup() {
|
||||
currentOAuthHandler = null;
|
||||
|
||||
if (currentContext) {
|
||||
try {
|
||||
await currentContext.close();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
currentContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求
|
||||
* @param {string} line - JSON 格式的请求
|
||||
*/
|
||||
async function handleRequest(line) {
|
||||
let request;
|
||||
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'unknown',
|
||||
data: { error: '无效的 JSON 格式' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { action } = request;
|
||||
|
||||
switch (action) {
|
||||
case 'login':
|
||||
await handleLogin(request);
|
||||
break;
|
||||
case 'cancel':
|
||||
await handleCancel();
|
||||
break;
|
||||
case 'check':
|
||||
await handleCheck();
|
||||
break;
|
||||
default:
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: action || 'unknown',
|
||||
data: { error: `未知的 action: ${action}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
async function main() {
|
||||
// 设置 stdin 为行模式
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
// 监听输入
|
||||
rl.on('line', async (line) => {
|
||||
if (line.trim()) {
|
||||
await handleRequest(line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// 监听关闭
|
||||
rl.on('close', async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 监听错误
|
||||
process.on('uncaughtException', async (error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `未捕获的异常: ${error.message}` }
|
||||
});
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', async (reason) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `未处理的 Promise 拒绝: ${reason}` }
|
||||
});
|
||||
});
|
||||
|
||||
// 发送就绪信号
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'ready',
|
||||
data: { message: 'Playwright Sidecar 已就绪' }
|
||||
});
|
||||
}
|
||||
|
||||
// 启动
|
||||
main().catch(async (error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `启动失败: ${error.message}` }
|
||||
});
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} SidecarRequest
|
||||
* @property {'login' | 'cancel' | 'check'} action - 操作类型
|
||||
* @property {string} [provider] - OAuth 提供商
|
||||
* @property {string} [authUrl] - OAuth 授权 URL
|
||||
* @property {string} [callbackUrl] - 回调 URL
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SidecarResponse
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} action - 操作类型
|
||||
* @property {Object} [data] - 响应数据
|
||||
* @property {string} [data.code] - 授权码
|
||||
* @property {string} [data.state] - 状态参数
|
||||
* @property {string} [data.error] - 错误信息
|
||||
* @property {boolean} [data.available] - Playwright 是否可用
|
||||
* @property {string} [data.browserPath] - 浏览器路径
|
||||
* @property {string} [data.message] - 消息
|
||||
*/
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @file oauth-handler.js
|
||||
* @description OAuth 流程处理器,处理授权码提取
|
||||
* @module playwright-login/oauth-handler
|
||||
*
|
||||
* Requirements: 4.1, 4.2, 4.3
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth 回调 URL 模式
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const CALLBACK_URL_PATTERN = /^https?:\/\/localhost(:\d+)?\/callback/;
|
||||
|
||||
/**
|
||||
* 解析 OAuth 回调 URL,提取授权码和状态
|
||||
* @param {string} url - 回调 URL
|
||||
* @returns {OAuthCallbackResult} 解析结果
|
||||
*/
|
||||
export function parseCallbackUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'URL 不能为空'
|
||||
};
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `无效的 URL 格式: ${e.message}`
|
||||
};
|
||||
}
|
||||
|
||||
const params = parsedUrl.searchParams;
|
||||
|
||||
// 检查是否有错误参数
|
||||
const error = params.get('error');
|
||||
if (error) {
|
||||
const errorDescription = params.get('error_description');
|
||||
return {
|
||||
success: false,
|
||||
error: errorDescription ? `${error}: ${errorDescription}` : error
|
||||
};
|
||||
}
|
||||
|
||||
// 提取授权码
|
||||
const code = params.get('code');
|
||||
if (!code) {
|
||||
return {
|
||||
success: false,
|
||||
error: '回调 URL 中缺少 code 参数'
|
||||
};
|
||||
}
|
||||
|
||||
// 提取状态(可选)
|
||||
const state = params.get('state');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code,
|
||||
state: state || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 URL 是否为 OAuth 回调 URL
|
||||
* @param {string} url - 要检查的 URL
|
||||
* @param {string} [expectedCallbackBase] - 期望的回调基础 URL
|
||||
* @returns {boolean} 是否为回调 URL
|
||||
*/
|
||||
export function isCallbackUrl(url, expectedCallbackBase) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// 如果提供了期望的回调基础 URL,进行精确匹配
|
||||
if (expectedCallbackBase) {
|
||||
const expectedParsed = new URL(expectedCallbackBase);
|
||||
return (
|
||||
parsedUrl.hostname === expectedParsed.hostname &&
|
||||
parsedUrl.port === expectedParsed.port &&
|
||||
parsedUrl.pathname === expectedParsed.pathname
|
||||
);
|
||||
}
|
||||
|
||||
// 默认匹配 localhost 回调模式
|
||||
return CALLBACK_URL_PATTERN.test(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 OAuth 流程处理器
|
||||
* @param {BrowserContext} context - Playwright 浏览器上下文
|
||||
* @param {OAuthHandlerOptions} options - 处理器选项
|
||||
* @returns {OAuthHandler} OAuth 处理器实例
|
||||
*/
|
||||
export function createOAuthHandler(context, options) {
|
||||
const {
|
||||
authUrl,
|
||||
callbackUrl,
|
||||
timeout = 5 * 60 * 1000, // 默认 5 分钟超时
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
let page = null;
|
||||
let cancelled = false;
|
||||
let timeoutId = null;
|
||||
|
||||
/**
|
||||
* 启动 OAuth 流程
|
||||
* @returns {Promise<OAuthCallbackResult>}
|
||||
*/
|
||||
async function start() {
|
||||
if (cancelled) {
|
||||
return { success: false, error: '流程已取消' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新页面
|
||||
page = await context.newPage();
|
||||
|
||||
onProgress?.('正在打开授权页面...');
|
||||
|
||||
// 设置超时
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error('OAuth 流程超时'));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
// 导航到授权 URL
|
||||
await page.goto(authUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
onProgress?.('等待用户授权...');
|
||||
|
||||
// 等待回调 URL
|
||||
const resultPromise = waitForCallback(page, callbackUrl);
|
||||
|
||||
// 竞争:回调完成 vs 超时
|
||||
const result = await Promise.race([resultPromise, timeoutPromise]);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (cancelled) {
|
||||
return { success: false, error: '用户取消了登录' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待回调 URL
|
||||
* @param {Page} page - Playwright 页面
|
||||
* @param {string} callbackUrl - 期望的回调 URL
|
||||
* @returns {Promise<OAuthCallbackResult>}
|
||||
*/
|
||||
async function waitForCallback(page, callbackUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 监听页面导航
|
||||
const handleNavigation = (frame) => {
|
||||
if (frame !== page.mainFrame()) return;
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
if (isCallbackUrl(currentUrl, callbackUrl)) {
|
||||
onProgress?.('检测到回调,正在提取授权码...');
|
||||
const result = parseCallbackUrl(currentUrl);
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
page.on('framenavigated', handleNavigation);
|
||||
|
||||
// 监听页面关闭
|
||||
page.on('close', () => {
|
||||
if (!cancelled) {
|
||||
resolve({ success: false, error: '用户关闭了浏览器窗口' });
|
||||
}
|
||||
});
|
||||
|
||||
// 检查当前 URL(可能已经在回调页面)
|
||||
const currentUrl = page.url();
|
||||
if (isCallbackUrl(currentUrl, callbackUrl)) {
|
||||
const result = parseCallbackUrl(currentUrl);
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 OAuth 流程
|
||||
*/
|
||||
async function cancel() {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (page && !page.isClosed()) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthCallbackResult
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} [code] - 授权码
|
||||
* @property {string} [state] - 状态参数
|
||||
* @property {string} [error] - 错误信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthHandlerOptions
|
||||
* @property {string} authUrl - OAuth 授权 URL
|
||||
* @property {string} callbackUrl - 回调 URL
|
||||
* @property {number} [timeout] - 超时时间(毫秒)
|
||||
* @property {(message: string) => void} [onProgress] - 进度回调
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthHandler
|
||||
* @property {() => Promise<OAuthCallbackResult>} start - 启动 OAuth 流程
|
||||
* @property {() => Promise<void>} cancel - 取消 OAuth 流程
|
||||
*/
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "playwright-login",
|
||||
"version": "1.0.0",
|
||||
"description": "Playwright sidecar script for ProxyCast fingerprint browser login",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "node --test",
|
||||
"test:property": "node --test test/*.property.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-check": "^3.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"author": "ProxyCast",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @file browser-config.property.test.js
|
||||
* @description 浏览器配置属性测试
|
||||
*
|
||||
* **Property 2: 浏览器配置完整性**
|
||||
* **Validates: Requirements 3.2, 3.3**
|
||||
*
|
||||
* *For any* Playwright 浏览器配置对象,该配置应该:
|
||||
* - 包含有效的 `userDataDir` 路径
|
||||
* - 包含反检测所需的启动参数
|
||||
* - 设置合理的视口大小(宽度 >= 1024,高度 >= 768)
|
||||
* - 包含真实的用户代理字符串
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
createBrowserConfig,
|
||||
validateBrowserConfig,
|
||||
DEFAULT_CONFIG,
|
||||
getUserDataDir
|
||||
} from '../browser-context.js';
|
||||
|
||||
describe('Property 2: 浏览器配置完整性', () => {
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认创建的浏览器配置,应该包含有效的 userDataDir 路径
|
||||
*/
|
||||
test('默认配置应包含有效的 userDataDir 路径', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // 不需要生成输入,测试默认行为
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// userDataDir 必须是非空字符串
|
||||
assert.strictEqual(typeof config.userDataDir, 'string');
|
||||
assert.ok(config.userDataDir.length > 0, 'userDataDir 不能为空');
|
||||
|
||||
// 应该包含 .proxycast 目录
|
||||
assert.ok(
|
||||
config.userDataDir.includes('.proxycast') ||
|
||||
config.userDataDir.includes('proxycast'),
|
||||
'userDataDir 应该在 proxycast 相关目录下'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 生成的视口配置,宽度应 >= 1024,高度应 >= 768
|
||||
*/
|
||||
test('视口大小应满足最小要求', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
(viewport) => {
|
||||
const config = createBrowserConfig({ viewport });
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该有效
|
||||
assert.ok(validation.valid, `配置应该有效: ${validation.errors.join(', ')}`);
|
||||
|
||||
// 视口尺寸应该符合要求
|
||||
assert.ok(config.viewport.width >= 1024, '宽度应 >= 1024');
|
||||
assert.ok(config.viewport.height >= 768, '高度应 >= 768');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 小于最小要求的视口配置,验证应该失败
|
||||
*/
|
||||
test('小于最小要求的视口应验证失败', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1, max: 1023 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 1, max: 767 })
|
||||
})
|
||||
),
|
||||
(viewport) => {
|
||||
const config = {
|
||||
userDataDir: '/tmp/test',
|
||||
viewport,
|
||||
userAgent: DEFAULT_CONFIG.userAgent,
|
||||
args: DEFAULT_CONFIG.args
|
||||
};
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该无效
|
||||
assert.ok(!validation.valid, '小于最小要求的视口应验证失败');
|
||||
assert.ok(validation.errors.length > 0, '应该有错误信息');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认配置,应包含反检测启动参数
|
||||
*/
|
||||
test('默认配置应包含反检测启动参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null),
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// 必须包含 AutomationControlled 禁用参数
|
||||
const hasAntiDetection = config.args.some(arg =>
|
||||
arg.includes('AutomationControlled')
|
||||
);
|
||||
assert.ok(hasAntiDetection, '应包含 --disable-blink-features=AutomationControlled');
|
||||
|
||||
// args 应该是数组
|
||||
assert.ok(Array.isArray(config.args), 'args 应该是数组');
|
||||
assert.ok(config.args.length > 0, 'args 不应为空');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认配置,应包含真实的用户代理字符串
|
||||
*/
|
||||
test('默认配置应包含真实的用户代理字符串', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null),
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// userAgent 必须是字符串
|
||||
assert.strictEqual(typeof config.userAgent, 'string');
|
||||
|
||||
// 必须包含 Mozilla 标识
|
||||
assert.ok(
|
||||
config.userAgent.includes('Mozilla'),
|
||||
'userAgent 应包含 Mozilla'
|
||||
);
|
||||
|
||||
// 必须包含 Chrome 标识
|
||||
assert.ok(
|
||||
config.userAgent.includes('Chrome'),
|
||||
'userAgent 应包含 Chrome'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 自定义 userDataDir,配置应正确使用该路径
|
||||
*/
|
||||
test('自定义 userDataDir 应被正确使用', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||
(customPath) => {
|
||||
const config = createBrowserConfig({ userDataDir: customPath });
|
||||
|
||||
// 应该使用自定义路径
|
||||
assert.strictEqual(config.userDataDir, customPath);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 缺少反检测参数的配置,验证应该失败
|
||||
*/
|
||||
test('缺少反检测参数的配置应验证失败', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.string().filter(s => !s.includes('AutomationControlled')), { minLength: 0, maxLength: 10 }),
|
||||
(args) => {
|
||||
const config = {
|
||||
userDataDir: '/tmp/test',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: DEFAULT_CONFIG.userAgent,
|
||||
args
|
||||
};
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该无效
|
||||
assert.ok(!validation.valid, '缺少反检测参数应验证失败');
|
||||
assert.ok(
|
||||
validation.errors.some(e => e.includes('反检测参数')),
|
||||
'错误信息应提及反检测参数'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 完整有效的配置,验证应该通过
|
||||
*/
|
||||
test('完整有效的配置应验证通过', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userDataDir: fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||
viewport: fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
userAgent: fc.constant(DEFAULT_CONFIG.userAgent),
|
||||
args: fc.constant([...DEFAULT_CONFIG.args])
|
||||
}),
|
||||
(config) => {
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该有效
|
||||
assert.ok(validation.valid, `配置应该有效: ${validation.errors.join(', ')}`);
|
||||
assert.strictEqual(validation.errors.length, 0, '不应有错误');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @file oauth-url.property.test.js
|
||||
* @description OAuth URL 解析属性测试
|
||||
*
|
||||
* **Property 3: OAuth 回调 URL 解析正确性**
|
||||
* **Validates: Requirements 4.1, 4.2**
|
||||
*
|
||||
* *For any* 有效的 OAuth 回调 URL,URL 解析函数应该:
|
||||
* - 正确提取 `code` 参数
|
||||
* - 正确提取 `state` 参数
|
||||
* - 对于缺少必要参数的 URL 返回错误
|
||||
* - 正确处理 URL 编码的参数值
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fc from 'fast-check';
|
||||
import { parseCallbackUrl, isCallbackUrl } from '../oauth-handler.js';
|
||||
|
||||
describe('Property 3: OAuth 回调 URL 解析正确性', () => {
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含有效 code 参数的回调 URL,应正确提取 code
|
||||
*/
|
||||
test('应正确提取 code 参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 生成随机的授权码(字母数字组合)
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,100}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(code, port) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}`;
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, code, 'code 应正确提取');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含 code 和 state 参数的回调 URL,应正确提取两者
|
||||
*/
|
||||
test('应正确提取 code 和 state 参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,100}$/),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(code, state, port) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}&state=${state}`;
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, code, 'code 应正确提取');
|
||||
assert.strictEqual(result.state, state, 'state 应正确提取');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 缺少 code 参数的 URL,应返回错误
|
||||
*/
|
||||
test('缺少 code 参数应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.option(fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/), { nil: undefined }),
|
||||
(port, state) => {
|
||||
let url = `http://localhost:${port}/callback`;
|
||||
if (state) {
|
||||
url += `?state=${state}`;
|
||||
}
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(!result.success, '缺少 code 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
assert.ok(result.error.includes('code'), '错误信息应提及 code');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* URL 编码的参数值,应正确解码
|
||||
*/
|
||||
test('应正确处理 URL 编码的参数值', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 生成包含特殊字符的字符串
|
||||
fc.stringMatching(/^[a-zA-Z0-9]{5,20}$/),
|
||||
fc.constantFrom('+', '/', '=', '%', '&'),
|
||||
fc.stringMatching(/^[a-zA-Z0-9]{5,20}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(prefix, special, suffix, port) => {
|
||||
const originalCode = `${prefix}${special}${suffix}`;
|
||||
const encodedCode = encodeURIComponent(originalCode);
|
||||
const url = `http://localhost:${port}/callback?code=${encodedCode}`;
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, originalCode, 'URL 编码的 code 应正确解码');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含 error 参数的 URL,应返回错误
|
||||
*/
|
||||
test('包含 error 参数应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('access_denied', 'invalid_request', 'unauthorized_client', 'server_error'),
|
||||
fc.option(fc.string({ minLength: 5, maxLength: 50 }), { nil: undefined }),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(error, errorDescription, port) => {
|
||||
let url = `http://localhost:${port}/callback?error=${error}`;
|
||||
if (errorDescription) {
|
||||
url += `&error_description=${encodeURIComponent(errorDescription)}`;
|
||||
}
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(!result.success, '包含 error 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
assert.ok(result.error.includes(error), '错误信息应包含 error 值');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 无效的 URL 格式,应返回错误
|
||||
*/
|
||||
test('无效的 URL 格式应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.constant('not-a-url'),
|
||||
fc.constant('://missing-protocol'),
|
||||
fc.constant('http://'),
|
||||
fc.constant(null),
|
||||
fc.constant(undefined)
|
||||
),
|
||||
(invalidUrl) => {
|
||||
const result = parseCallbackUrl(invalidUrl);
|
||||
|
||||
assert.ok(!result.success, '无效 URL 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* localhost 回调 URL,isCallbackUrl 应返回 true
|
||||
*/
|
||||
test('localhost 回调 URL 应被正确识别', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
(port, code) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}`;
|
||||
|
||||
assert.ok(isCallbackUrl(url), 'localhost 回调 URL 应被识别');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 非回调 URL,isCallbackUrl 应返回 false
|
||||
*/
|
||||
test('非回调 URL 应返回 false', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(
|
||||
'https://accounts.google.com/oauth',
|
||||
'https://github.com/login/oauth/authorize',
|
||||
'http://localhost:3000/home',
|
||||
'http://localhost:3000/api/auth',
|
||||
'https://example.com/callback'
|
||||
),
|
||||
(url) => {
|
||||
assert.ok(!isCallbackUrl(url), '非回调 URL 应返回 false');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 指定的期望回调 URL,应精确匹配
|
||||
*/
|
||||
test('应精确匹配期望的回调 URL', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
(port, code) => {
|
||||
const expectedCallback = `http://localhost:${port}/callback`;
|
||||
const actualUrl = `http://localhost:${port}/callback?code=${code}`;
|
||||
const wrongPortUrl = `http://localhost:${port + 1}/callback?code=${code}`;
|
||||
|
||||
assert.ok(
|
||||
isCallbackUrl(actualUrl, expectedCallback),
|
||||
'匹配的回调 URL 应返回 true'
|
||||
);
|
||||
assert.ok(
|
||||
!isCallbackUrl(wrongPortUrl, expectedCallback),
|
||||
'端口不匹配应返回 false'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Generated
+1
-1
@@ -3377,7 +3377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.17.3"
|
||||
version = "0.17.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.17.3"
|
||||
version = "0.17.4"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-stdin-write",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default"]}}
|
||||
@@ -0,0 +1,40 @@
|
||||
# src
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
Tauri 后端核心代码,处理系统级功能和 API 服务。
|
||||
使用 Rust 实现高性能的代理、认证和流量监控逻辑。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `commands/` - Tauri 命令处理(前端调用入口)
|
||||
- `config/` - 配置管理(导入/导出/热重载)
|
||||
- `converter/` - 协议转换(OpenAI ↔ CW/Claude/Antigravity)
|
||||
- `credential/` - 凭证池管理(负载均衡、健康检查)
|
||||
- `database/` - 数据库层(SQLite + DAO)
|
||||
- `flow_monitor/` - LLM 流量监控(拦截、存储、查询)
|
||||
- `injection/` - 请求注入(系统提示词等)
|
||||
- `middleware/` - HTTP 中间件
|
||||
- `models/` - 数据模型定义
|
||||
- `plugin/` - 插件系统
|
||||
- `processor/` - 请求处理管道
|
||||
- `providers/` - 各 Provider 的认证和 API 实现
|
||||
- `proxy/` - HTTP 代理客户端
|
||||
- `resilience/` - 弹性策略(重试、超时、故障转移)
|
||||
- `router/` - 请求路由(模型映射、规则匹配)
|
||||
- `server/` - HTTP 服务器(OpenAI/Claude 兼容 API)
|
||||
- `services/` - 业务服务层
|
||||
- `streaming/` - 流式响应处理
|
||||
- `telemetry/` - 遥测和统计
|
||||
- `tray/` - 系统托盘
|
||||
- `websocket/` - WebSocket 支持
|
||||
- `lib.rs` - 库入口
|
||||
- `main.rs` - 应用入口
|
||||
- `logger.rs` - 日志配置
|
||||
- `server_utils.rs` - 服务器工具函数
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
|
||||
proxy_url: None,
|
||||
ampcode: crate::config::AmpConfig::default(),
|
||||
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
|
||||
minimize_to_tray: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,6 +491,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
|
||||
proxy_url: None,
|
||||
ampcode: crate::config::AmpConfig::default(),
|
||||
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
|
||||
minimize_to_tray: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,6 +536,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
|
||||
proxy_url: None,
|
||||
ampcode: crate::config::AmpConfig::default(),
|
||||
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
|
||||
minimize_to_tray: true,
|
||||
};
|
||||
// 根据类型使配置无效
|
||||
match invalid_type {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# converter
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
协议转换模块,实现不同 LLM API 格式之间的转换。
|
||||
支持 OpenAI、Claude、CodeWhisperer、Antigravity 等格式。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口
|
||||
- `protocol_selector.rs` - 协议选择器
|
||||
- `openai_to_cw.rs` - OpenAI → CodeWhisperer 转换
|
||||
- `cw_to_openai.rs` - CodeWhisperer → OpenAI 转换
|
||||
- `anthropic_to_openai.rs` - Anthropic → OpenAI 转换
|
||||
- `openai_to_antigravity.rs` - OpenAI → Antigravity 转换
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1952,6 +1952,7 @@ pub fn run() {
|
||||
commands::provider_pool_cmd::check_provider_pool_credential_health,
|
||||
commands::provider_pool_cmd::check_provider_pool_type_health,
|
||||
commands::provider_pool_cmd::add_kiro_oauth_credential,
|
||||
commands::provider_pool_cmd::add_kiro_from_json,
|
||||
commands::provider_pool_cmd::add_gemini_oauth_credential,
|
||||
commands::provider_pool_cmd::add_qwen_oauth_credential,
|
||||
commands::provider_pool_cmd::add_antigravity_oauth_credential,
|
||||
@@ -1980,6 +1981,21 @@ pub fn run() {
|
||||
commands::provider_pool_cmd::start_gemini_oauth_login,
|
||||
commands::provider_pool_cmd::exchange_gemini_code,
|
||||
commands::provider_pool_cmd::get_kiro_credential_fingerprint,
|
||||
// Kiro Builder ID 登录命令
|
||||
commands::provider_pool_cmd::start_kiro_builder_id_login,
|
||||
commands::provider_pool_cmd::poll_kiro_builder_id_auth,
|
||||
commands::provider_pool_cmd::cancel_kiro_builder_id_login,
|
||||
commands::provider_pool_cmd::add_kiro_from_builder_id_auth,
|
||||
// Kiro Social Auth 登录命令 (Google/GitHub)
|
||||
commands::provider_pool_cmd::start_kiro_social_auth_login,
|
||||
commands::provider_pool_cmd::exchange_kiro_social_auth_token,
|
||||
commands::provider_pool_cmd::cancel_kiro_social_auth_login,
|
||||
commands::provider_pool_cmd::start_kiro_social_auth_callback_server,
|
||||
// Playwright 指纹浏览器登录命令
|
||||
commands::provider_pool_cmd::check_playwright_available,
|
||||
commands::provider_pool_cmd::install_playwright,
|
||||
commands::provider_pool_cmd::start_kiro_playwright_login,
|
||||
commands::provider_pool_cmd::cancel_kiro_playwright_login,
|
||||
// Route commands
|
||||
commands::route_cmd::get_available_routes,
|
||||
commands::route_cmd::get_route_curl_examples,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# providers
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
各 LLM Provider 的认证和 API 实现。
|
||||
支持 OAuth 和 API Key 两种认证方式。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口和 Provider 枚举
|
||||
- `traits.rs` - Provider trait 定义
|
||||
- `error.rs` - 错误类型定义
|
||||
- `kiro.rs` - Kiro/CodeWhisperer OAuth 认证
|
||||
- `gemini.rs` - Gemini OAuth 认证
|
||||
- `qwen.rs` - Qwen OAuth 认证
|
||||
- `antigravity.rs` - Antigravity OAuth 认证
|
||||
- `claude_oauth.rs` - Claude OAuth 认证
|
||||
- `claude_custom.rs` - Claude API Key 认证
|
||||
- `openai_custom.rs` - OpenAI API Key 认证
|
||||
- `codex.rs` - Codex Provider
|
||||
- `iflow.rs` - iFlow Provider
|
||||
- `vertex.rs` - Vertex AI Provider
|
||||
- `tests.rs` - 单元测试
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,27 @@
|
||||
# services
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
业务服务层,封装核心业务逻辑。
|
||||
提供凭证池管理、Token 缓存、MCP 同步等功能。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口
|
||||
- `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询)
|
||||
- `token_cache_service.rs` - Token 缓存服务
|
||||
- `mcp_service.rs` - MCP 服务器管理
|
||||
- `mcp_sync.rs` - MCP 配置同步
|
||||
- `prompt_service.rs` - Prompt 管理服务
|
||||
- `prompt_sync.rs` - Prompt 同步
|
||||
- `skill_service.rs` - 技能管理服务
|
||||
- `usage_service.rs` - 使用量统计服务
|
||||
- `backup_service.rs` - 备份服务
|
||||
- `live_sync.rs` - 实时同步服务
|
||||
- `switch.rs` - 开关服务
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.17.3",
|
||||
"version": "0.17.4",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -35,7 +35,8 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"icons/tray/*"
|
||||
"icons/tray/*",
|
||||
"../scripts/playwright-login/**/*"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# components
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
React 组件层,包含 UI 组件和业务组件。
|
||||
使用 TailwindCSS 进行样式管理,shadcn/ui 作为基础组件库。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `api-server/` - API 服务器配置组件
|
||||
- `clients/` - 客户端管理组件
|
||||
- `config/` - 配置管理组件
|
||||
- `extensions/` - 扩展功能组件
|
||||
- `flow-monitor/` - LLM 流量监控组件
|
||||
- `mcp/` - MCP 服务器管理组件
|
||||
- `monitoring/` - 监控面板组件
|
||||
- `plugins/` - 插件管理组件
|
||||
- `prompts/` - Prompt 管理组件
|
||||
- `provider-pool/` - Provider 凭证池管理组件
|
||||
- `resilience/` - 弹性策略配置组件
|
||||
- `routing/` - 路由规则配置组件
|
||||
- `settings/` - 设置页面组件
|
||||
- `skills/` - 技能管理组件
|
||||
- `switch/` - 开关控制组件
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
- `ConfirmDialog.tsx` - 确认对话框
|
||||
- `Dashboard.tsx` - 仪表盘主页
|
||||
- `HelpTip.tsx` - 帮助提示组件
|
||||
- `Providers.tsx` - Provider 上下文
|
||||
- `Sidebar.tsx` - 侧边栏导航
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -13,6 +13,7 @@ import { ClaudeOAuthForm } from "./credential-forms/ClaudeOAuthForm";
|
||||
import { QwenForm } from "./credential-forms/QwenForm";
|
||||
import { IFlowForm } from "./credential-forms/IFlowForm";
|
||||
import { GeminiForm } from "./credential-forms/GeminiForm";
|
||||
import { KiroForm } from "./credential-forms/KiroForm";
|
||||
import { defaultCredsPath, providerLabels } from "./credential-forms/types";
|
||||
|
||||
interface AddCredentialModalProps {
|
||||
@@ -41,8 +42,8 @@ export function AddCredentialModal({
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// 判断是否为 OAuth 类型(不包括有特殊表单的 antigravity、codex、claude_oauth、qwen、iflow、gemini)
|
||||
const isSimpleOAuth = ["kiro"].includes(providerType);
|
||||
// 判断是否为 OAuth 类型(不包括有特殊表单的 antigravity、codex、claude_oauth、qwen、iflow、gemini、kiro)
|
||||
const isSimpleOAuth: string[] = []; // Kiro 现在有自己的表单
|
||||
const isApiKey = ["openai", "claude"].includes(providerType);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
@@ -137,6 +138,18 @@ export function AddCredentialModal({
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
// Kiro 表单
|
||||
const kiroForm = KiroForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
// 简单 OAuth 和 API Key 的提交处理
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
@@ -145,7 +158,7 @@ export function AddCredentialModal({
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
|
||||
if (isSimpleOAuth) {
|
||||
if (isSimpleOAuth.includes(providerType)) {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
setLoading(false);
|
||||
@@ -153,9 +166,6 @@ export function AddCredentialModal({
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
case "kiro":
|
||||
await providerPoolApi.addKiroOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
case "gemini":
|
||||
await providerPoolApi.addGeminiOAuth(
|
||||
credsFilePath,
|
||||
@@ -222,8 +232,6 @@ export function AddCredentialModal({
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{providerType === "kiro" &&
|
||||
"默认路径: ~/.aws/sso/cache/kiro-auth-token.json"}
|
||||
{providerType === "gemini" && "默认路径: ~/.gemini/oauth_creds.json"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -462,6 +470,37 @@ export function AddCredentialModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Kiro 登录模式 - 不需要按钮,登录按钮在表单内部
|
||||
if (providerType === "kiro" && kiroForm.mode === "login") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Kiro JSON 模式
|
||||
if (providerType === "kiro" && kiroForm.mode === "json") {
|
||||
return (
|
||||
<button
|
||||
onClick={kiroForm.handleJsonSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Kiro 文件模式
|
||||
if (providerType === "kiro" && kiroForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={kiroForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return (
|
||||
<button
|
||||
@@ -510,7 +549,8 @@ export function AddCredentialModal({
|
||||
{providerType === "qwen" && qwenForm.render()}
|
||||
{providerType === "iflow" && iflowForm.render()}
|
||||
{providerType === "gemini" && geminiForm.render()}
|
||||
{isSimpleOAuth && renderSimpleOAuthForm()}
|
||||
{providerType === "kiro" && kiroForm.render()}
|
||||
{isSimpleOAuth.includes(providerType) && renderSimpleOAuthForm()}
|
||||
{isApiKey && renderApiKeyForm()}
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 浏览器模式选择器组件
|
||||
*
|
||||
* 允许用户在系统浏览器和 Playwright 指纹浏览器之间切换
|
||||
* 用于 Kiro OAuth 登录流程
|
||||
*
|
||||
* @module components/provider-pool/credential-forms/BrowserModeSelector
|
||||
* @description 实现 Requirements 1.1, 1.2, 1.3
|
||||
*/
|
||||
|
||||
import { Globe, Fingerprint, Loader2, AlertCircle } from "lucide-react";
|
||||
|
||||
export type BrowserMode = "system" | "playwright";
|
||||
|
||||
interface BrowserModeSelectorProps {
|
||||
/** 当前选中的浏览器模式 */
|
||||
mode: BrowserMode;
|
||||
/** 模式变更回调 */
|
||||
onModeChange: (mode: BrowserMode) => void;
|
||||
/** Playwright 是否可用 */
|
||||
playwrightAvailable: boolean;
|
||||
/** 是否正在检查 Playwright 可用性 */
|
||||
playwrightChecking: boolean;
|
||||
/** 重新检查 Playwright 可用性回调 */
|
||||
onCheckPlaywright: () => void;
|
||||
/** 是否禁用(登录中) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器模式选择器
|
||||
*
|
||||
* 显示两个选项:系统浏览器和指纹浏览器
|
||||
* 当 Playwright 不可用时,指纹浏览器选项会显示警告状态
|
||||
*/
|
||||
export function BrowserModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
playwrightAvailable,
|
||||
playwrightChecking,
|
||||
onCheckPlaywright,
|
||||
disabled = false,
|
||||
}: BrowserModeSelectorProps) {
|
||||
const handlePlaywrightSelect = () => {
|
||||
if (disabled) return;
|
||||
|
||||
if (!playwrightAvailable && !playwrightChecking) {
|
||||
// 如果 Playwright 不可用,先检查一次
|
||||
onCheckPlaywright();
|
||||
}
|
||||
onModeChange("playwright");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
浏览器模式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 系统浏览器选项 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onModeChange("system")}
|
||||
disabled={disabled}
|
||||
className={`relative flex items-center justify-center gap-1.5 px-2 py-1.5 rounded border text-xs transition-all duration-200 ${
|
||||
mode === "system"
|
||||
? "border-primary bg-primary/5 text-primary font-medium"
|
||||
: "border-muted hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<Globe className="h-3 w-3 flex-shrink-0" />
|
||||
<span>系统浏览器</span>
|
||||
</button>
|
||||
|
||||
{/* 指纹浏览器选项 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlaywrightSelect}
|
||||
disabled={disabled}
|
||||
className={`relative flex items-center justify-center gap-1.5 px-2 py-1.5 rounded border text-xs transition-all duration-200 ${
|
||||
mode === "playwright"
|
||||
? playwrightAvailable
|
||||
? "border-primary bg-primary/5 text-primary font-medium"
|
||||
: "border-amber-500 bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 font-medium"
|
||||
: "border-muted hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{playwrightChecking ? (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{playwrightChecking
|
||||
? "检测中"
|
||||
: playwrightAvailable
|
||||
? "指纹浏览器"
|
||||
: "指纹(需安装)"}
|
||||
</span>
|
||||
|
||||
{/* 不可用警告图标 */}
|
||||
{mode === "playwright" &&
|
||||
!playwrightAvailable &&
|
||||
!playwrightChecking && (
|
||||
<AlertCircle className="h-2.5 w-2.5 text-amber-500 absolute -top-0.5 -right-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* Kiro 凭证添加表单
|
||||
*
|
||||
* 支持三种模式:
|
||||
* 1. 在线登录(OAuth 授权)- Google、GitHub、AWS Builder ID
|
||||
* 2. 粘贴 JSON(直接粘贴凭证内容)
|
||||
* 3. 导入文件
|
||||
*
|
||||
* 支持两种浏览器模式:
|
||||
* - 系统浏览器:使用系统默认浏览器
|
||||
* - 指纹浏览器:使用 Playwright 指纹浏览器(绕过机器人检测)
|
||||
*
|
||||
* @module components/provider-pool/credential-forms/KiroForm
|
||||
* @description 参考 liuyun-kiro 项目实现的 Kiro 凭证添加表单
|
||||
* @description 实现 Requirements 5.1, 5.2, 5.3, 5.4 错误处理
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { providerPoolApi, PlaywrightStatus } from "@/lib/api/providerPool";
|
||||
import {
|
||||
checkPlaywrightAvailable,
|
||||
startKiroPlaywrightLogin,
|
||||
cancelKiroPlaywrightLogin,
|
||||
} from "@/lib/api/providerPool";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { BrowserModeSelector, BrowserMode } from "./BrowserModeSelector";
|
||||
import { PlaywrightInstallGuide } from "./PlaywrightInstallGuide";
|
||||
import { PlaywrightErrorDisplay } from "./PlaywrightErrorDisplay";
|
||||
import {
|
||||
logPlaywrightError,
|
||||
parsePlaywrightError,
|
||||
PlaywrightErrorType,
|
||||
} from "@/lib/errors/playwrightErrors";
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
LogIn,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
interface KiroFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type KiroMode = "login" | "json" | "file";
|
||||
type LoginType = "builderid" | "google" | "github";
|
||||
|
||||
interface BuilderIdLoginData {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export function KiroForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: KiroFormProps) {
|
||||
const [mode, setMode] = useState<KiroMode>("json");
|
||||
const [jsonContent, setJsonContent] = useState("");
|
||||
|
||||
// 浏览器模式状态
|
||||
const [browserMode, setBrowserMode] = useState<BrowserMode>("system");
|
||||
const [playwrightStatus, setPlaywrightStatus] = useState<PlaywrightStatus>({
|
||||
available: false,
|
||||
});
|
||||
const [playwrightChecking, setPlaywrightChecking] = useState(false);
|
||||
|
||||
// 登录相关状态
|
||||
const [_loginType, setLoginType] = useState<LoginType>("builderid");
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [builderIdLoginData, setBuilderIdLoginData] =
|
||||
useState<BuilderIdLoginData | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Playwright 错误状态(用于显示详细错误信息)
|
||||
// Requirements: 5.1, 5.2, 5.4
|
||||
const [playwrightError, setPlaywrightError] = useState<unknown>(null);
|
||||
const [lastLoginProvider, setLastLoginProvider] = useState<
|
||||
"Google" | "Github" | "BuilderId" | null
|
||||
>(null);
|
||||
|
||||
// 检查 Playwright 可用性
|
||||
const checkPlaywright = useCallback(async () => {
|
||||
setPlaywrightChecking(true);
|
||||
setPlaywrightError(null);
|
||||
try {
|
||||
const status = await checkPlaywrightAvailable();
|
||||
setPlaywrightStatus(status);
|
||||
if (!status.available && status.error) {
|
||||
logPlaywrightError("checkPlaywright", status.error, {
|
||||
context: "availability_check",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logPlaywrightError("checkPlaywright", err, {
|
||||
context: "availability_check",
|
||||
});
|
||||
setPlaywrightStatus({
|
||||
available: false,
|
||||
error: err instanceof Error ? err.message : "检测失败",
|
||||
});
|
||||
} finally {
|
||||
setPlaywrightChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化时检查 Playwright 可用性
|
||||
useEffect(() => {
|
||||
checkPlaywright();
|
||||
}, [checkPlaywright]);
|
||||
|
||||
// 清理轮询和事件监听
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
if (unlistenRef.current) {
|
||||
unlistenRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 清除 Playwright 错误
|
||||
const clearPlaywrightError = useCallback(() => {
|
||||
setPlaywrightError(null);
|
||||
setError(null);
|
||||
}, [setError]);
|
||||
|
||||
// 切换到系统浏览器模式
|
||||
const switchToSystemBrowser = useCallback(() => {
|
||||
setBrowserMode("system");
|
||||
clearPlaywrightError();
|
||||
}, [clearPlaywrightError]);
|
||||
|
||||
// 复制 user_code
|
||||
const handleCopyUserCode = async () => {
|
||||
if (builderIdLoginData?.userCode) {
|
||||
await navigator.clipboard.writeText(builderIdLoginData.userCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 Playwright 指纹浏览器登录
|
||||
// Requirements: 5.1, 5.2, 5.3, 5.4
|
||||
const handlePlaywrightLogin = async (
|
||||
provider: "Google" | "Github" | "BuilderId",
|
||||
) => {
|
||||
setIsLoggingIn(true);
|
||||
setError(null);
|
||||
setPlaywrightError(null);
|
||||
setLastLoginProvider(provider);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await startKiroPlaywrightLogin(provider, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
// 记录详细错误日志 (Requirements: 5.4)
|
||||
logPlaywrightError("handlePlaywrightLogin", e, {
|
||||
provider,
|
||||
browserMode,
|
||||
name: name.trim() || undefined,
|
||||
});
|
||||
|
||||
// 解析错误类型
|
||||
const errorInfo = parsePlaywrightError(e);
|
||||
|
||||
// 设置 Playwright 错误状态(用于显示详细错误组件)
|
||||
setPlaywrightError(e);
|
||||
|
||||
// 根据错误类型设置用户友好的错误消息
|
||||
// Requirements: 5.1, 5.2
|
||||
if (
|
||||
errorInfo.type === PlaywrightErrorType.USER_CANCELLED ||
|
||||
errorInfo.type === PlaywrightErrorType.BROWSER_CLOSED
|
||||
) {
|
||||
// 用户主动取消,不显示为错误
|
||||
setError(null);
|
||||
} else {
|
||||
setError(errorInfo.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重试 Playwright 登录
|
||||
const handleRetryPlaywrightLogin = useCallback(() => {
|
||||
if (lastLoginProvider) {
|
||||
handlePlaywrightLogin(lastLoginProvider);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lastLoginProvider]);
|
||||
|
||||
// 启动 Social Auth 登录 (Google/GitHub)
|
||||
const handleStartSocialAuthLogin = async (provider: "Google" | "Github") => {
|
||||
// 如果选择了指纹浏览器模式,使用 Playwright 登录
|
||||
if (browserMode === "playwright") {
|
||||
await handlePlaywrightLogin(provider);
|
||||
return;
|
||||
}
|
||||
|
||||
// 系统浏览器模式
|
||||
setIsLoggingIn(true);
|
||||
setError(null);
|
||||
setBuilderIdLoginData(null);
|
||||
|
||||
try {
|
||||
// 启动回调服务器
|
||||
await providerPoolApi.startKiroSocialAuthCallbackServer();
|
||||
|
||||
// 监听回调事件
|
||||
const unlisten = await listen<{ code: string; state: string }>(
|
||||
"kiro-social-auth-callback",
|
||||
async (event) => {
|
||||
try {
|
||||
// 交换 Token
|
||||
const tokenResult =
|
||||
await providerPoolApi.exchangeKiroSocialAuthToken(
|
||||
event.payload.code,
|
||||
event.payload.state,
|
||||
);
|
||||
|
||||
if (tokenResult.success) {
|
||||
// 添加凭证到凭证池
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addKiroFromBuilderIdAuth(trimmedName);
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(tokenResult.error || "Token 交换失败");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "登录失败");
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
// 启动登录
|
||||
const result = await providerPoolApi.startKiroSocialAuthLogin(provider);
|
||||
|
||||
if (result.success && result.loginUrl) {
|
||||
// 打开系统默认浏览器
|
||||
await open(result.loginUrl);
|
||||
} else {
|
||||
setError(result.error || "启动登录失败");
|
||||
setIsLoggingIn(false);
|
||||
if (unlistenRef.current) {
|
||||
unlistenRef.current();
|
||||
unlistenRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "启动登录失败");
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 启动 Builder ID 登录
|
||||
const handleStartBuilderIdLogin = async () => {
|
||||
// 如果选择了指纹浏览器模式,使用 Playwright 登录
|
||||
if (browserMode === "playwright") {
|
||||
await handlePlaywrightLogin("BuilderId");
|
||||
return;
|
||||
}
|
||||
|
||||
// 系统浏览器模式
|
||||
setIsLoggingIn(true);
|
||||
setError(null);
|
||||
setBuilderIdLoginData(null);
|
||||
|
||||
try {
|
||||
const result = await providerPoolApi.startKiroBuilderIdLogin();
|
||||
|
||||
if (result.userCode && result.verificationUri) {
|
||||
setBuilderIdLoginData({
|
||||
userCode: result.userCode,
|
||||
verificationUri: result.verificationUri,
|
||||
expiresIn: result.expiresIn || 600,
|
||||
interval: result.interval || 5,
|
||||
});
|
||||
|
||||
// 打开浏览器
|
||||
await open(result.verificationUri);
|
||||
|
||||
// 监听授权完成事件
|
||||
const unlisten = await listen<{ uuid: string }>(
|
||||
"kiro-builderid-auth-complete",
|
||||
() => {
|
||||
// 授权完成
|
||||
setIsLoggingIn(false);
|
||||
setBuilderIdLoginData(null);
|
||||
onSuccess();
|
||||
},
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
// 开始轮询
|
||||
startPolling(result.interval || 5);
|
||||
} else {
|
||||
setError(result.error || "启动登录失败");
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "启动登录失败");
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询 Builder ID 授权
|
||||
const startPolling = (interval: number) => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await providerPoolApi.pollKiroBuilderIdAuth();
|
||||
|
||||
if (!result.success) {
|
||||
if (
|
||||
result.error?.includes("过期") ||
|
||||
result.error?.includes("超时")
|
||||
) {
|
||||
setError("授权已过期,请重新登录");
|
||||
setIsLoggingIn(false);
|
||||
setBuilderIdLoginData(null);
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.completed) {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// 添加凭证到凭证池
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addKiroFromBuilderIdAuth(trimmedName);
|
||||
|
||||
setIsLoggingIn(false);
|
||||
setBuilderIdLoginData(null);
|
||||
onSuccess();
|
||||
}
|
||||
// 如果是 pending,继续轮询
|
||||
} catch (e) {
|
||||
console.error("[KiroForm] Poll error:", e);
|
||||
}
|
||||
}, interval * 1000);
|
||||
};
|
||||
|
||||
// 取消登录
|
||||
// Requirements: 5.3
|
||||
const handleCancelLogin = async () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (unlistenRef.current) {
|
||||
unlistenRef.current();
|
||||
unlistenRef.current = null;
|
||||
}
|
||||
|
||||
// 取消 Builder ID 和 Social Auth 登录
|
||||
await providerPoolApi.cancelKiroBuilderIdLogin();
|
||||
await providerPoolApi.cancelKiroSocialAuthLogin();
|
||||
|
||||
// 如果是 Playwright 模式,也取消 Playwright 登录
|
||||
// Requirements: 5.3
|
||||
if (browserMode === "playwright") {
|
||||
try {
|
||||
await cancelKiroPlaywrightLogin();
|
||||
logPlaywrightError("handleCancelLogin", "用户取消登录", {
|
||||
provider: lastLoginProvider,
|
||||
browserMode,
|
||||
});
|
||||
} catch (e) {
|
||||
// 取消操作的错误不需要显示给用户
|
||||
console.warn("[KiroForm] Cancel Playwright login error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoggingIn(false);
|
||||
setBuilderIdLoginData(null);
|
||||
setError(null);
|
||||
setPlaywrightError(null);
|
||||
};
|
||||
|
||||
// JSON 粘贴提交
|
||||
const handleJsonSubmit = async () => {
|
||||
if (!jsonContent.trim()) {
|
||||
setError("请粘贴凭证 JSON 内容");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
try {
|
||||
JSON.parse(jsonContent);
|
||||
} catch {
|
||||
setError("JSON 格式无效,请检查内容");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addKiroFromJson(jsonContent, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addKiroOAuth(credsFilePath, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 模式选择器
|
||||
const renderModeSelector = () => (
|
||||
<div className="grid grid-cols-3 gap-1 p-1 bg-muted/50 rounded-xl border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "login"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<LogIn className="inline h-4 w-4 mr-1" />
|
||||
在线登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("json");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "json"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="inline h-4 w-4 mr-1" />
|
||||
粘贴 JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("file");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "file"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="inline h-4 w-4 mr-1" />
|
||||
导入文件
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 在线登录表单
|
||||
const renderLoginForm = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 浏览器模式选择器 */}
|
||||
{!isLoggingIn && (
|
||||
<BrowserModeSelector
|
||||
mode={browserMode}
|
||||
onModeChange={setBrowserMode}
|
||||
playwrightAvailable={playwrightStatus.available}
|
||||
playwrightChecking={playwrightChecking}
|
||||
onCheckPlaywright={checkPlaywright}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playwright 安装引导(当选择指纹浏览器但未安装时显示) */}
|
||||
{!isLoggingIn &&
|
||||
browserMode === "playwright" &&
|
||||
!playwrightStatus.available &&
|
||||
!playwrightChecking && (
|
||||
<PlaywrightInstallGuide
|
||||
onRetryCheck={checkPlaywright}
|
||||
checking={playwrightChecking}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playwright 错误显示(当有错误且不在登录中时显示) */}
|
||||
{/* Requirements: 5.1, 5.2, 5.4 */}
|
||||
{!isLoggingIn &&
|
||||
playwrightError !== null &&
|
||||
browserMode === "playwright" && (
|
||||
<PlaywrightErrorDisplay
|
||||
error={playwrightError}
|
||||
onRetry={handleRetryPlaywrightLogin}
|
||||
onSwitchToSystemBrowser={switchToSystemBrowser}
|
||||
onDismiss={clearPlaywrightError}
|
||||
retrying={isLoggingIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 登录中状态 - Builder ID */}
|
||||
{isLoggingIn && builderIdLoginData && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
请在浏览器中完成登录,并输入以下代码:
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-2xl font-bold tracking-widest bg-white dark:bg-gray-800 px-4 py-2 rounded border">
|
||||
{builderIdLoginData.userCode}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUserCode}
|
||||
className="p-2 rounded-lg border hover:bg-muted"
|
||||
title="复制代码"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
等待授权中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(builderIdLoginData.verificationUri)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
重新打开浏览器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelLogin}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
取消登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录中状态 - Social Auth / Playwright */}
|
||||
{isLoggingIn && !builderIdLoginData && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-blue-500" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{browserMode === "playwright"
|
||||
? "正在使用指纹浏览器登录..."
|
||||
: "请在浏览器中完成登录..."}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{browserMode === "playwright"
|
||||
? "请在弹出的浏览器窗口中完成登录"
|
||||
: "登录完成后会自动返回"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelLogin}
|
||||
className="w-full px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
取消登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未登录状态 - 显示登录选项 */}
|
||||
{!isLoggingIn && (
|
||||
<div className="space-y-2">
|
||||
{/* 第一行:Google 和 GitHub */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Google */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("google");
|
||||
handleStartSocialAuthLogin("Google");
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group flex items-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-white rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="w-full h-full">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Google
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* GitHub */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("github");
|
||||
handleStartSocialAuthLogin("Github");
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group flex items-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-white rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#24292f"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
GitHub
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:AWS Builder ID */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("builderid");
|
||||
handleStartBuilderIdLogin();
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group w-full flex items-center justify-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-[#232f3e] rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg viewBox="0 0 24 24" fill="#ff9900" className="w-full h-full">
|
||||
<text
|
||||
x="2"
|
||||
y="16"
|
||||
fontSize="10"
|
||||
fontWeight="bold"
|
||||
fontFamily="Arial"
|
||||
>
|
||||
aws
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
AWS Builder ID
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// JSON 粘贴表单
|
||||
const renderJsonForm = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
直接粘贴 Kiro 凭证 JSON 内容,无需选择文件。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
凭证 JSON 通常包含 accessToken、refreshToken 等字段。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证 JSON <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonContent}
|
||||
onChange={(e) => setJsonContent(e.target.value)}
|
||||
placeholder={`粘贴凭证 JSON 内容,例如:
|
||||
{
|
||||
"accessToken": "...",
|
||||
"refreshToken": "...",
|
||||
"region": "us-east-1",
|
||||
...
|
||||
}`}
|
||||
className="w-full h-48 rounded-lg border bg-background px-3 py-2 text-sm font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
mode,
|
||||
handleJsonSubmit,
|
||||
handleFileSubmit,
|
||||
handleLoginSubmit: () => {}, // 登录模式不需要手动提交
|
||||
render: () => (
|
||||
<>
|
||||
{renderModeSelector()}
|
||||
|
||||
{mode === "login" && renderLoginForm()}
|
||||
{mode === "json" && renderJsonForm()}
|
||||
{mode === "file" && (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 kiro-auth-token.json..."
|
||||
hint="默认路径: ~/.aws/sso/cache/kiro-auth-token.json"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Playwright 错误显示组件
|
||||
*
|
||||
* 显示 Playwright 登录过程中的错误信息,
|
||||
* 包括错误标题、描述和故障排除建议
|
||||
*
|
||||
* @module components/provider-pool/credential-forms/PlaywrightErrorDisplay
|
||||
* @description 实现 Requirements 5.1, 5.2, 5.4
|
||||
*/
|
||||
|
||||
import { AlertCircle, RefreshCw, XCircle, Clock, Globe } from "lucide-react";
|
||||
import {
|
||||
parsePlaywrightError,
|
||||
PlaywrightErrorType,
|
||||
type PlaywrightErrorInfo,
|
||||
} from "@/lib/errors/playwrightErrors";
|
||||
|
||||
interface PlaywrightErrorDisplayProps {
|
||||
/** 错误信息(可以是 Error 对象、字符串或 null) */
|
||||
error: unknown;
|
||||
/** 重试回调 */
|
||||
onRetry?: () => void;
|
||||
/** 切换到系统浏览器回调 */
|
||||
onSwitchToSystemBrowser?: () => void;
|
||||
/** 关闭/清除错误回调 */
|
||||
onDismiss?: () => void;
|
||||
/** 是否正在重试 */
|
||||
retrying?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型获取图标
|
||||
*/
|
||||
function getErrorIcon(type: PlaywrightErrorType) {
|
||||
switch (type) {
|
||||
case PlaywrightErrorType.OAUTH_TIMEOUT:
|
||||
return <Clock className="h-5 w-5" />;
|
||||
case PlaywrightErrorType.USER_CANCELLED:
|
||||
case PlaywrightErrorType.BROWSER_CLOSED:
|
||||
return <XCircle className="h-5 w-5" />;
|
||||
default:
|
||||
return <AlertCircle className="h-5 w-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型获取样式
|
||||
*/
|
||||
function getErrorStyles(type: PlaywrightErrorType) {
|
||||
switch (type) {
|
||||
case PlaywrightErrorType.USER_CANCELLED:
|
||||
case PlaywrightErrorType.BROWSER_CLOSED:
|
||||
// 用户主动操作,使用较温和的样式
|
||||
return {
|
||||
container:
|
||||
"border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/50",
|
||||
icon: "text-slate-500",
|
||||
title: "text-slate-800 dark:text-slate-200",
|
||||
message: "text-slate-600 dark:text-slate-400",
|
||||
};
|
||||
case PlaywrightErrorType.OAUTH_TIMEOUT:
|
||||
// 超时,使用警告样式
|
||||
return {
|
||||
container:
|
||||
"border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30",
|
||||
icon: "text-amber-500",
|
||||
title: "text-amber-800 dark:text-amber-300",
|
||||
message: "text-amber-700 dark:text-amber-400",
|
||||
};
|
||||
default:
|
||||
// 其他错误,使用错误样式
|
||||
return {
|
||||
container:
|
||||
"border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30",
|
||||
icon: "text-red-500",
|
||||
title: "text-red-800 dark:text-red-300",
|
||||
message: "text-red-700 dark:text-red-400",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright 错误显示组件
|
||||
*
|
||||
* 根据错误类型显示不同样式的错误信息,
|
||||
* 并提供重试和切换浏览器模式的操作按钮
|
||||
*/
|
||||
export function PlaywrightErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
onSwitchToSystemBrowser,
|
||||
onDismiss,
|
||||
retrying = false,
|
||||
}: PlaywrightErrorDisplayProps) {
|
||||
if (!error) return null;
|
||||
|
||||
const errorInfo: PlaywrightErrorInfo = parsePlaywrightError(error);
|
||||
const styles = getErrorStyles(errorInfo.type);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 space-y-3 ${styles.container}`}>
|
||||
{/* 错误标题和关闭按钮 */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex-shrink-0 mt-0.5 ${styles.icon}`}>
|
||||
{getErrorIcon(errorInfo.type)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className={`text-sm font-semibold ${styles.title}`}>
|
||||
{errorInfo.title}
|
||||
</h4>
|
||||
<p className={`text-sm mt-1 ${styles.message}`}>
|
||||
{errorInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="关闭"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 故障排除建议 */}
|
||||
{errorInfo.suggestions.length > 0 && (
|
||||
<div className="pl-8">
|
||||
<p className={`text-xs font-medium mb-1.5 ${styles.message}`}>
|
||||
建议操作:
|
||||
</p>
|
||||
<ul className={`text-xs space-y-1 ${styles.message}`}>
|
||||
{errorInfo.suggestions.map((suggestion, index) => (
|
||||
<li key={index} className="flex items-start gap-1.5">
|
||||
<span className="flex-shrink-0">•</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(errorInfo.retryable || onSwitchToSystemBrowser) && (
|
||||
<div className="flex items-center gap-2 pl-8 pt-1">
|
||||
{errorInfo.retryable && onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
retrying
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${retrying ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{retrying ? "重试中..." : "重试"}
|
||||
</button>
|
||||
)}
|
||||
{onSwitchToSystemBrowser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSystemBrowser}
|
||||
disabled={retrying}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border hover:bg-muted transition-colors"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
使用系统浏览器
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 调试信息(开发模式下显示) */}
|
||||
{import.meta.env.DEV && errorInfo.originalError && (
|
||||
<details className="pl-8 pt-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||
调试信息
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 text-xs bg-slate-100 dark:bg-slate-800 rounded overflow-x-auto">
|
||||
{errorInfo.originalError}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Playwright 安装引导组件
|
||||
*
|
||||
* 当 Playwright 未安装时显示安装指南
|
||||
* 提供一键安装、复制命令和重新检测功能
|
||||
*
|
||||
* @module components/provider-pool/credential-forms/PlaywrightInstallGuide
|
||||
* @description 实现 Requirements 1.4, 6.1, 6.2, 6.3, 6.4
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Copy, Check, RefreshCw, Download, Loader2 } from "lucide-react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { installPlaywright } from "@/lib/api/providerPool";
|
||||
|
||||
interface PlaywrightInstallGuideProps {
|
||||
/** 重新检测回调 */
|
||||
onRetryCheck: () => void;
|
||||
/** 是否正在检测 */
|
||||
checking: boolean;
|
||||
}
|
||||
|
||||
interface InstallProgress {
|
||||
message: string;
|
||||
done: boolean;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND = "npx playwright install chromium";
|
||||
|
||||
/**
|
||||
* Playwright 安装引导
|
||||
*
|
||||
* 紧凑的内联显示安装命令和操作按钮
|
||||
* 支持一键安装功能
|
||||
*/
|
||||
export function PlaywrightInstallGuide({
|
||||
onRetryCheck,
|
||||
checking,
|
||||
}: PlaywrightInstallGuideProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 监听安装进度事件
|
||||
useEffect(() => {
|
||||
const unlisten = listen<InstallProgress>(
|
||||
"playwright-install-progress",
|
||||
(event) => {
|
||||
setProgress(event.payload.message);
|
||||
if (event.payload.done) {
|
||||
setInstalling(false);
|
||||
if (event.payload.success) {
|
||||
// 安装成功,触发重新检测
|
||||
setError(null);
|
||||
onRetryCheck();
|
||||
} else {
|
||||
// 安装失败,显示错误
|
||||
setError(event.payload.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [onRetryCheck]);
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("复制失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
setInstalling(true);
|
||||
setProgress("正在准备安装...");
|
||||
setError(null);
|
||||
try {
|
||||
await installPlaywright();
|
||||
} catch (err) {
|
||||
console.error("安装失败:", err);
|
||||
const errorMsg =
|
||||
typeof err === "string" ? err : (err as Error)?.message || String(err);
|
||||
setError(errorMsg);
|
||||
setProgress("");
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 显示错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950/30 px-2 py-1.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="text-red-700 dark:text-red-400 flex-1 truncate"
|
||||
title={error}
|
||||
>
|
||||
安装失败:{" "}
|
||||
{error.length > 50 ? error.substring(0, 50) + "..." : error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-200 dark:bg-red-800 hover:bg-red-300 dark:hover:bg-red-700 transition-colors text-red-800 dark:text-red-200 whitespace-nowrap"
|
||||
title="重试安装"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
<span>重试</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 安装中显示进度
|
||||
if (installing) {
|
||||
return (
|
||||
<div className="rounded border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950/30 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="flex-1 text-blue-700 dark:text-blue-300 truncate">
|
||||
{progress || "正在安装..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-200 dark:bg-amber-800 hover:bg-amber-300 dark:hover:bg-amber-700 transition-colors text-amber-800 dark:text-amber-200"
|
||||
title="一键安装"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
<span>安装</span>
|
||||
</button>
|
||||
<span className="text-amber-600 dark:text-amber-500">或</span>
|
||||
<code className="flex-1 font-mono text-amber-800 dark:text-amber-300 truncate select-all text-[10px]">
|
||||
{INSTALL_COMMAND}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCommand}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryCheck}
|
||||
disabled={checking}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors disabled:opacity-50"
|
||||
title="重新检测"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-amber-600 dark:text-amber-400 ${checking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,3 +12,6 @@ export * from "./ClaudeOAuthForm";
|
||||
export * from "./QwenForm";
|
||||
export * from "./IFlowForm";
|
||||
export * from "./GeminiForm";
|
||||
export * from "./BrowserModeSelector";
|
||||
export * from "./PlaywrightInstallGuide";
|
||||
export * from "./PlaywrightErrorDisplay";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# hooks
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
React 自定义 Hooks,封装业务逻辑和状态管理。
|
||||
通过 Tauri invoke 与 Rust 后端通信。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - Hooks 导出入口
|
||||
- `useErrorHandler.ts` - 错误处理 Hook
|
||||
- `useFileMonitoring.ts` - 文件监控 Hook
|
||||
- `useFlowActions.ts` - 流量操作 Hook
|
||||
- `useFlowEvents.ts` - 流量事件 Hook
|
||||
- `useFlowNotifications.ts` - 流量通知 Hook
|
||||
- `useMcpServers.ts` - MCP 服务器管理 Hook
|
||||
- `useOAuthCredentials.ts` - OAuth 凭证管理 Hook
|
||||
- `usePrompts.ts` - Prompt 管理 Hook
|
||||
- `useProviderPool.ts` - Provider 池管理 Hook
|
||||
- `useProviderState.ts` - Provider 状态 Hook
|
||||
- `useSkills.ts` - 技能管理 Hook
|
||||
- `useSwitch.ts` - 开关状态 Hook
|
||||
- `useTauri.ts` - Tauri 通用 Hook
|
||||
- `useWindowResize.ts` - 窗口大小 Hook
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,23 @@
|
||||
# lib
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
前端工具库和 API 封装层。
|
||||
包含 Tauri 命令封装、工具函数和服务类。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `api/` - API 调用封装
|
||||
- `errors/` - 错误处理模块
|
||||
- `playwrightErrors.ts` - Playwright 登录错误处理(Requirements 5.1, 5.2, 5.3, 5.4)
|
||||
- `tauri/` - Tauri 命令封装
|
||||
- `utils/` - 通用工具函数
|
||||
- `flowEventManager.ts` - 流量事件管理器
|
||||
- `notificationService.ts` - 通知服务
|
||||
- `utils.ts` - 通用工具函数
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -284,6 +284,14 @@ export const providerPoolApi = {
|
||||
return invoke("add_kiro_oauth_credential", { credsFilePath, name });
|
||||
},
|
||||
|
||||
// 从 JSON 内容添加 Kiro 凭证(直接粘贴 JSON)
|
||||
async addKiroFromJson(
|
||||
jsonContent: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_kiro_from_json", { jsonContent, name });
|
||||
},
|
||||
|
||||
async addGeminiOAuth(
|
||||
credsFilePath: string,
|
||||
projectId?: string,
|
||||
@@ -454,6 +462,57 @@ export const providerPoolApi = {
|
||||
return invoke("exchange_gemini_code", { code, sessionId, name });
|
||||
},
|
||||
|
||||
// ============ Kiro Builder ID 登录 ============
|
||||
|
||||
// 启动 Kiro Builder ID 登录(OIDC Device Authorization Flow)
|
||||
async startKiroBuilderIdLogin(
|
||||
region?: string,
|
||||
): Promise<KiroBuilderIdLoginResponse> {
|
||||
return invoke("start_kiro_builder_id_login", { region });
|
||||
},
|
||||
|
||||
// 轮询 Kiro Builder ID 授权状态
|
||||
async pollKiroBuilderIdAuth(): Promise<KiroBuilderIdPollResponse> {
|
||||
return invoke("poll_kiro_builder_id_auth");
|
||||
},
|
||||
|
||||
// 取消 Kiro Builder ID 登录
|
||||
async cancelKiroBuilderIdLogin(): Promise<boolean> {
|
||||
return invoke("cancel_kiro_builder_id_login");
|
||||
},
|
||||
|
||||
// 从 Builder ID 授权结果添加 Kiro 凭证
|
||||
async addKiroFromBuilderIdAuth(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("add_kiro_from_builder_id_auth", { name });
|
||||
},
|
||||
|
||||
// ============ Kiro Social Auth 登录 (Google/GitHub) ============
|
||||
|
||||
// 启动 Kiro Social Auth 登录
|
||||
async startKiroSocialAuthLogin(
|
||||
provider: "Google" | "Github",
|
||||
): Promise<KiroSocialAuthLoginResponse> {
|
||||
return invoke("start_kiro_social_auth_login", { provider });
|
||||
},
|
||||
|
||||
// 交换 Kiro Social Auth Token
|
||||
async exchangeKiroSocialAuthToken(
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<KiroSocialAuthTokenResponse> {
|
||||
return invoke("exchange_kiro_social_auth_token", { code, state });
|
||||
},
|
||||
|
||||
// 取消 Kiro Social Auth 登录
|
||||
async cancelKiroSocialAuthLogin(): Promise<boolean> {
|
||||
return invoke("cancel_kiro_social_auth_login");
|
||||
},
|
||||
|
||||
// 启动 Kiro Social Auth 回调服务器
|
||||
async startKiroSocialAuthCallbackServer(): Promise<boolean> {
|
||||
return invoke("start_kiro_social_auth_callback_server");
|
||||
},
|
||||
|
||||
// OAuth token management
|
||||
async refreshCredentialToken(uuid: string): Promise<string> {
|
||||
return invoke("refresh_pool_credential_token", { uuid });
|
||||
@@ -476,6 +535,38 @@ export interface MigrationResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Kiro Builder ID 登录响应
|
||||
export interface KiroBuilderIdLoginResponse {
|
||||
success: boolean;
|
||||
userCode?: string;
|
||||
verificationUri?: string;
|
||||
expiresIn?: number;
|
||||
interval?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Kiro Builder ID 轮询响应
|
||||
export interface KiroBuilderIdPollResponse {
|
||||
success: boolean;
|
||||
completed: boolean;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Kiro Social Auth 登录响应
|
||||
export interface KiroSocialAuthLoginResponse {
|
||||
success: boolean;
|
||||
loginUrl?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Kiro Social Auth Token 交换响应
|
||||
export interface KiroSocialAuthTokenResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Kiro 凭证指纹信息
|
||||
export interface KiroFingerprintInfo {
|
||||
/** Machine ID(SHA256 哈希,64 字符) */
|
||||
@@ -488,9 +579,64 @@ export interface KiroFingerprintInfo {
|
||||
auth_method: string;
|
||||
}
|
||||
|
||||
// Playwright 状态
|
||||
export interface PlaywrightStatus {
|
||||
/** 浏览器是否可用 */
|
||||
available: boolean;
|
||||
/** 浏览器可执行文件路径 */
|
||||
browserPath?: string;
|
||||
/** 浏览器来源: "system" 或 "playwright" */
|
||||
browserSource?: "system" | "playwright";
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 获取 Kiro 凭证的指纹信息
|
||||
export async function getKiroCredentialFingerprint(
|
||||
uuid: string,
|
||||
): Promise<KiroFingerprintInfo> {
|
||||
return invoke("get_kiro_credential_fingerprint", { uuid });
|
||||
}
|
||||
|
||||
// ============ Playwright 指纹浏览器登录 ============
|
||||
|
||||
/**
|
||||
* 检查 Playwright 是否可用
|
||||
* Requirements: 2.1
|
||||
*/
|
||||
export async function checkPlaywrightAvailable(): Promise<PlaywrightStatus> {
|
||||
return invoke("check_playwright_available");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 Playwright Chromium 浏览器
|
||||
* Requirements: 6.1, 6.2
|
||||
*
|
||||
* 执行 npm install playwright && npx playwright install chromium
|
||||
* 会发送 playwright-install-progress 事件通知安装进度
|
||||
*/
|
||||
export async function installPlaywright(): Promise<PlaywrightStatus> {
|
||||
return invoke("install_playwright");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Playwright 指纹浏览器启动 Kiro 登录
|
||||
* Requirements: 3.1
|
||||
*
|
||||
* @param provider 登录提供商: Google, Github, BuilderId
|
||||
* @param name 可选的凭证名称
|
||||
*/
|
||||
export async function startKiroPlaywrightLogin(
|
||||
provider: "Google" | "Github" | "BuilderId",
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("start_kiro_playwright_login", { provider, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 Playwright 登录
|
||||
* Requirements: 5.3
|
||||
*/
|
||||
export async function cancelKiroPlaywrightLogin(): Promise<boolean> {
|
||||
return invoke("cancel_kiro_playwright_login");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# errors
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
错误处理模块,提供结构化的错误类型定义、错误消息映射和错误处理工具函数。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块导出入口
|
||||
- `playwrightErrors.ts` - Playwright 登录错误处理
|
||||
- 错误类型枚举 `PlaywrightErrorType`
|
||||
- 错误信息接口 `PlaywrightErrorInfo`
|
||||
- 错误解析函数 `parsePlaywrightError`
|
||||
- 错误日志函数 `logPlaywrightError`
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
import { parsePlaywrightError, logPlaywrightError } from '@/lib/errors';
|
||||
|
||||
try {
|
||||
await startKiroPlaywrightLogin(provider, name);
|
||||
} catch (e) {
|
||||
// 解析错误类型
|
||||
const errorInfo = parsePlaywrightError(e);
|
||||
|
||||
// 记录详细日志
|
||||
logPlaywrightError('handlePlaywrightLogin', e, { provider });
|
||||
|
||||
// 显示用户友好的错误消息
|
||||
setError(errorInfo.message);
|
||||
}
|
||||
```
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 5.1: Playwright 启动失败错误处理
|
||||
- Requirements 5.2: OAuth 超时错误处理
|
||||
- Requirements 5.3: 用户取消错误处理
|
||||
- Requirements 5.4: 详细错误日志记录
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 错误处理模块导出
|
||||
*
|
||||
* @module lib/errors
|
||||
*/
|
||||
|
||||
export * from "./playwrightErrors";
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Playwright 登录错误处理模块
|
||||
*
|
||||
* 提供 Playwright 指纹浏览器登录相关的错误类型定义、
|
||||
* 错误消息映射和错误处理工具函数
|
||||
*
|
||||
* @module lib/errors/playwrightErrors
|
||||
* @description 实现 Requirements 5.1, 5.2, 5.3, 5.4
|
||||
*/
|
||||
|
||||
/**
|
||||
* Playwright 错误类型枚举
|
||||
*/
|
||||
export enum PlaywrightErrorType {
|
||||
/** Playwright 未安装 */
|
||||
NOT_INSTALLED = "NOT_INSTALLED",
|
||||
/** 浏览器启动失败 */
|
||||
BROWSER_LAUNCH_FAILED = "BROWSER_LAUNCH_FAILED",
|
||||
/** OAuth 流程超时 */
|
||||
OAUTH_TIMEOUT = "OAUTH_TIMEOUT",
|
||||
/** 用户取消登录 */
|
||||
USER_CANCELLED = "USER_CANCELLED",
|
||||
/** 用户关闭浏览器窗口 */
|
||||
BROWSER_CLOSED = "BROWSER_CLOSED",
|
||||
/** 授权码提取失败 */
|
||||
CODE_EXTRACTION_FAILED = "CODE_EXTRACTION_FAILED",
|
||||
/** Token 交换失败 */
|
||||
TOKEN_EXCHANGE_FAILED = "TOKEN_EXCHANGE_FAILED",
|
||||
/** 网络错误 */
|
||||
NETWORK_ERROR = "NETWORK_ERROR",
|
||||
/** 脚本执行错误 */
|
||||
SCRIPT_ERROR = "SCRIPT_ERROR",
|
||||
/** 未知错误 */
|
||||
UNKNOWN = "UNKNOWN",
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright 错误信息接口
|
||||
*/
|
||||
export interface PlaywrightErrorInfo {
|
||||
/** 错误类型 */
|
||||
type: PlaywrightErrorType;
|
||||
/** 用户友好的错误标题 */
|
||||
title: string;
|
||||
/** 用户友好的错误描述 */
|
||||
message: string;
|
||||
/** 故障排除建议 */
|
||||
suggestions: string[];
|
||||
/** 是否可重试 */
|
||||
retryable: boolean;
|
||||
/** 原始错误消息(用于调试) */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误消息模式匹配规则
|
||||
*/
|
||||
const ERROR_PATTERNS: Array<{
|
||||
pattern: RegExp | string;
|
||||
type: PlaywrightErrorType;
|
||||
}> = [
|
||||
{ pattern: /playwright.*不可用/i, type: PlaywrightErrorType.NOT_INSTALLED },
|
||||
{ pattern: /playwright.*未安装/i, type: PlaywrightErrorType.NOT_INSTALLED },
|
||||
{ pattern: /chromium.*未安装/i, type: PlaywrightErrorType.NOT_INSTALLED },
|
||||
{
|
||||
pattern: /browser.*not.*installed/i,
|
||||
type: PlaywrightErrorType.NOT_INSTALLED,
|
||||
},
|
||||
{
|
||||
pattern: /启动.*浏览器.*失败/i,
|
||||
type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED,
|
||||
},
|
||||
{
|
||||
pattern: /browser.*launch.*failed/i,
|
||||
type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED,
|
||||
},
|
||||
{
|
||||
pattern: /启动.*playwright.*失败/i,
|
||||
type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED,
|
||||
},
|
||||
{ pattern: /超时/i, type: PlaywrightErrorType.OAUTH_TIMEOUT },
|
||||
{ pattern: /timeout/i, type: PlaywrightErrorType.OAUTH_TIMEOUT },
|
||||
{ pattern: /用户取消/i, type: PlaywrightErrorType.USER_CANCELLED },
|
||||
{ pattern: /user.*cancel/i, type: PlaywrightErrorType.USER_CANCELLED },
|
||||
{ pattern: /登录已取消/i, type: PlaywrightErrorType.USER_CANCELLED },
|
||||
{ pattern: /关闭.*浏览器/i, type: PlaywrightErrorType.BROWSER_CLOSED },
|
||||
{ pattern: /browser.*closed/i, type: PlaywrightErrorType.BROWSER_CLOSED },
|
||||
{ pattern: /授权码/i, type: PlaywrightErrorType.CODE_EXTRACTION_FAILED },
|
||||
{ pattern: /code.*参数/i, type: PlaywrightErrorType.CODE_EXTRACTION_FAILED },
|
||||
{ pattern: /token.*交换/i, type: PlaywrightErrorType.TOKEN_EXCHANGE_FAILED },
|
||||
{
|
||||
pattern: /token.*exchange/i,
|
||||
type: PlaywrightErrorType.TOKEN_EXCHANGE_FAILED,
|
||||
},
|
||||
{ pattern: /网络/i, type: PlaywrightErrorType.NETWORK_ERROR },
|
||||
{ pattern: /network/i, type: PlaywrightErrorType.NETWORK_ERROR },
|
||||
{ pattern: /connection/i, type: PlaywrightErrorType.NETWORK_ERROR },
|
||||
];
|
||||
|
||||
/**
|
||||
* 错误类型对应的详细信息
|
||||
*/
|
||||
const ERROR_INFO_MAP: Record<
|
||||
PlaywrightErrorType,
|
||||
Omit<PlaywrightErrorInfo, "type" | "originalError">
|
||||
> = {
|
||||
[PlaywrightErrorType.NOT_INSTALLED]: {
|
||||
title: "Playwright 未安装",
|
||||
message: "指纹浏览器功能需要 Playwright Chromium 浏览器支持。",
|
||||
suggestions: [
|
||||
"在终端中运行: npx playwright install chromium",
|
||||
"安装完成后点击「重新检测」按钮",
|
||||
"如果安装失败,请检查网络连接或使用代理",
|
||||
],
|
||||
retryable: false,
|
||||
},
|
||||
[PlaywrightErrorType.BROWSER_LAUNCH_FAILED]: {
|
||||
title: "浏览器启动失败",
|
||||
message: "无法启动 Playwright 浏览器,可能是权限问题或浏览器文件损坏。",
|
||||
suggestions: [
|
||||
"尝试重新安装 Playwright: npx playwright install chromium --force",
|
||||
"检查系统是否有足够的内存和磁盘空间",
|
||||
"尝试使用系统浏览器模式登录",
|
||||
"如果问题持续,请重启应用后重试",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.OAUTH_TIMEOUT]: {
|
||||
title: "登录超时",
|
||||
message: "OAuth 授权流程超时,请在 5 分钟内完成登录操作。",
|
||||
suggestions: [
|
||||
"点击「重试」按钮重新开始登录",
|
||||
"确保网络连接稳定",
|
||||
"如果页面加载缓慢,请检查网络或使用代理",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.USER_CANCELLED]: {
|
||||
title: "登录已取消",
|
||||
message: "您已取消登录操作。",
|
||||
suggestions: ["如需继续登录,请重新选择登录方式"],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.BROWSER_CLOSED]: {
|
||||
title: "浏览器窗口已关闭",
|
||||
message: "您在完成登录前关闭了浏览器窗口。",
|
||||
suggestions: [
|
||||
"请重新开始登录,并在浏览器中完成授权",
|
||||
"授权完成后浏览器会自动关闭",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.CODE_EXTRACTION_FAILED]: {
|
||||
title: "授权码获取失败",
|
||||
message: "无法从回调 URL 中提取授权码。",
|
||||
suggestions: [
|
||||
"请重试登录",
|
||||
"如果问题持续,请尝试使用系统浏览器模式",
|
||||
"检查是否有浏览器扩展干扰了登录流程",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.TOKEN_EXCHANGE_FAILED]: {
|
||||
title: "Token 交换失败",
|
||||
message: "授权成功但 Token 交换失败,可能是服务器暂时不可用。",
|
||||
suggestions: [
|
||||
"请稍后重试",
|
||||
"检查网络连接是否正常",
|
||||
"如果使用代理,请确保代理配置正确",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.NETWORK_ERROR]: {
|
||||
title: "网络错误",
|
||||
message: "网络连接出现问题,无法完成登录。",
|
||||
suggestions: [
|
||||
"检查网络连接是否正常",
|
||||
"如果使用代理,请确保代理配置正确",
|
||||
"尝试关闭 VPN 或代理后重试",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.SCRIPT_ERROR]: {
|
||||
title: "脚本执行错误",
|
||||
message: "Playwright 登录脚本执行出错。",
|
||||
suggestions: [
|
||||
"请重启应用后重试",
|
||||
"如果问题持续,请尝试重新安装 Playwright",
|
||||
"可以尝试使用系统浏览器模式登录",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
[PlaywrightErrorType.UNKNOWN]: {
|
||||
title: "登录失败",
|
||||
message: "登录过程中发生未知错误。",
|
||||
suggestions: [
|
||||
"请重试登录",
|
||||
"如果问题持续,请尝试使用系统浏览器模式",
|
||||
"重启应用后重试",
|
||||
],
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析错误消息,返回结构化的错误信息
|
||||
*
|
||||
* @param error - 原始错误(可以是 Error 对象或字符串)
|
||||
* @returns 结构化的错误信息
|
||||
*/
|
||||
export function parsePlaywrightError(error: unknown): PlaywrightErrorInfo {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 尝试匹配已知错误模式
|
||||
for (const { pattern, type } of ERROR_PATTERNS) {
|
||||
const regex =
|
||||
typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
|
||||
if (regex.test(errorMessage)) {
|
||||
const info = ERROR_INFO_MAP[type];
|
||||
return {
|
||||
type,
|
||||
...info,
|
||||
originalError: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 未匹配到已知模式,返回未知错误
|
||||
const unknownInfo = ERROR_INFO_MAP[PlaywrightErrorType.UNKNOWN];
|
||||
return {
|
||||
type: PlaywrightErrorType.UNKNOWN,
|
||||
...unknownInfo,
|
||||
originalError: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户友好的错误消息
|
||||
*
|
||||
* @param error - 原始错误
|
||||
* @returns 用户友好的错误消息
|
||||
*/
|
||||
export function getPlaywrightErrorMessage(error: unknown): string {
|
||||
const errorInfo = parsePlaywrightError(error);
|
||||
return errorInfo.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误是否可重试
|
||||
*
|
||||
* @param error - 原始错误
|
||||
* @returns 是否可重试
|
||||
*/
|
||||
export function isPlaywrightErrorRetryable(error: unknown): boolean {
|
||||
const errorInfo = parsePlaywrightError(error);
|
||||
return errorInfo.retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Playwright 错误日志
|
||||
*
|
||||
* @param context - 错误发生的上下文
|
||||
* @param error - 原始错误
|
||||
* @param additionalInfo - 附加信息
|
||||
*/
|
||||
export function logPlaywrightError(
|
||||
context: string,
|
||||
error: unknown,
|
||||
additionalInfo?: Record<string, unknown>,
|
||||
): void {
|
||||
const errorInfo = parsePlaywrightError(error);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
console.error(`[Playwright Error] ${timestamp}`, {
|
||||
context,
|
||||
errorType: errorInfo.type,
|
||||
title: errorInfo.title,
|
||||
message: errorInfo.message,
|
||||
originalError: errorInfo.originalError,
|
||||
retryable: errorInfo.retryable,
|
||||
...additionalInfo,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# pages
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
页面级组件,每个文件对应一个独立的路由页面。
|
||||
页面组件负责组合 components 中的业务组件。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `FlowMonitorPage.tsx` - LLM 流量监控页面
|
||||
- `index.ts` - 页面导出入口
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -21,5 +21,11 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/scripts/playwright-login/**",
|
||||
"**/src-tauri/**",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user