chore: bump version to 0.17.4

This commit is contained in:
coso
2025-12-24 13:39:37 +08:00
parent 100884781f
commit 05b2e24484
37 changed files with 5807 additions and 15 deletions
+4
View File
@@ -96,3 +96,7 @@ Kiro 凭证采用完全独立的副本策略:
- 日志输出使用 `tracing` 宏
- API 请求调试文件保存在 `~/.proxycast/logs/`
- 使用 `debug_kiro_credentials` 命令调试凭证加载
## 文档维护
文档维护规范详见 `.kiro/steering/doc-maintenance.md`(Kiro 自动加载)。
+23
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.17.3",
"version": "0.17.4",
"type": "module",
"repository": {
"type": "git",
+123
View File
@@ -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": "登录已取消" }
}
```
+292
View File
@@ -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 - 浏览器启动参数
*/
+384
View File
@@ -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] - 消息
*/
+249
View File
@@ -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 流程
*/
+23
View File
@@ -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 }
);
});
});
+1 -1
View File
@@ -3377,7 +3377,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.17.3"
version = "0.17.4"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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"]}}
+40
View File
@@ -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
+3
View File
@@ -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 {
+21
View File
@@ -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 转换
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+16
View File
@@ -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,
+29
View File
@@ -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` - 单元测试
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+27
View File
@@ -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` - 开关服务
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+3 -2
View File
@@ -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": {
+37
View File
@@ -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";
+30
View File
@@ -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
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+23
View File
@@ -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` - 通用工具函数
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+146
View File
@@ -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");
}
+46
View File
@@ -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: 详细错误日志记录
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+7
View File
@@ -0,0 +1,7 @@
/**
* 错误处理模块导出
*
* @module lib/errors
*/
export * from "./playwrightErrors";
+283
View File
@@ -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,
});
}
+17
View File
@@ -0,0 +1,17 @@
# pages
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
页面级组件,每个文件对应一个独立的路由页面。
页面组件负责组合 components 中的业务组件。
## 文件索引
- `FlowMonitorPage.tsx` - LLM 流量监控页面
- `index.ts` - 页面导出入口
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+6
View File
@@ -21,5 +21,11 @@ export default defineConfig({
test: {
globals: true,
environment: "jsdom",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/scripts/playwright-login/**",
"**/src-tauri/**",
],
},
});