From 05b2e24484e9fa28fbd2ef45b08f977ab5206846 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 24 Dec 2025 13:39:37 +0800 Subject: [PATCH] chore: bump version to 0.17.4 --- AGENTS.md | 4 + docs/README.md | 23 + package.json | 2 +- scripts/playwright-login/README.md | 123 + scripts/playwright-login/browser-context.js | 292 +++ scripts/playwright-login/index.js | 384 +++ scripts/playwright-login/oauth-handler.js | 249 ++ scripts/playwright-login/package.json | 23 + .../test/browser-config.property.test.js | 256 ++ .../test/oauth-url.property.test.js | 250 ++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 4 + src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/README.md | 40 + src-tauri/src/commands/provider_pool_cmd.rs | 2113 +++++++++++++++++ src-tauri/src/config/tests.rs | 3 + src-tauri/src/converter/README.md | 21 + src-tauri/src/lib.rs | 16 + src-tauri/src/providers/README.md | 29 + src-tauri/src/services/README.md | 27 + src-tauri/tauri.conf.json | 5 +- src/components/README.md | 37 + .../provider-pool/AddCredentialModal.tsx | 58 +- .../credential-forms/BrowserModeSelector.tsx | 111 + .../credential-forms/KiroForm.tsx | 811 +++++++ .../PlaywrightErrorDisplay.tsx | 193 ++ .../PlaywrightInstallGuide.tsx | 181 ++ .../provider-pool/credential-forms/index.ts | 3 + src/hooks/README.md | 30 + src/lib/README.md | 23 + src/lib/api/providerPool.ts | 146 ++ src/lib/errors/README.md | 46 + src/lib/errors/index.ts | 7 + src/lib/errors/playwrightErrors.ts | 283 +++ src/pages/README.md | 17 + vite.config.ts | 6 + 37 files changed, 5807 insertions(+), 15 deletions(-) create mode 100644 docs/README.md create mode 100644 scripts/playwright-login/README.md create mode 100644 scripts/playwright-login/browser-context.js create mode 100644 scripts/playwright-login/index.js create mode 100644 scripts/playwright-login/oauth-handler.js create mode 100644 scripts/playwright-login/package.json create mode 100644 scripts/playwright-login/test/browser-config.property.test.js create mode 100644 scripts/playwright-login/test/oauth-url.property.test.js create mode 100644 src-tauri/src/README.md create mode 100644 src-tauri/src/converter/README.md create mode 100644 src-tauri/src/providers/README.md create mode 100644 src-tauri/src/services/README.md create mode 100644 src/components/README.md create mode 100644 src/components/provider-pool/credential-forms/BrowserModeSelector.tsx create mode 100644 src/components/provider-pool/credential-forms/KiroForm.tsx create mode 100644 src/components/provider-pool/credential-forms/PlaywrightErrorDisplay.tsx create mode 100644 src/components/provider-pool/credential-forms/PlaywrightInstallGuide.tsx create mode 100644 src/hooks/README.md create mode 100644 src/lib/README.md create mode 100644 src/lib/errors/README.md create mode 100644 src/lib/errors/index.ts create mode 100644 src/lib/errors/playwrightErrors.ts create mode 100644 src/pages/README.md diff --git a/AGENTS.md b/AGENTS.md index b23068eb2..c88670dac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,3 +96,7 @@ Kiro 凭证采用完全独立的副本策略: - 日志输出使用 `tracing` 宏 - API 请求调试文件保存在 `~/.proxycast/logs/` - 使用 `debug_kiro_credentials` 命令调试凭证加载 + +## 文档维护 + +文档维护规范详见 `.kiro/steering/doc-maintenance.md`(Kiro 自动加载)。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..a2e4a6148 --- /dev/null +++ b/docs/README.md @@ -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` - 文档站点依赖 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/package.json b/package.json index 93df14877..9b2618e3f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.17.3", + "version": "0.17.4", "type": "module", "repository": { "type": "git", diff --git a/scripts/playwright-login/README.md b/scripts/playwright-login/README.md new file mode 100644 index 000000000..04dcb2083 --- /dev/null +++ b/scripts/playwright-login/README.md @@ -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": "登录已取消" } +} +``` diff --git a/scripts/playwright-login/browser-context.js b/scripts/playwright-login/browser-context.js new file mode 100644 index 000000000..19aac3771 --- /dev/null +++ b/scripts/playwright-login/browser-context.js @@ -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} [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} [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 - 浏览器启动参数 + */ diff --git a/scripts/playwright-login/index.js b/scripts/playwright-login/index.js new file mode 100644 index 000000000..b3d1b261d --- /dev/null +++ b/scripts/playwright-login/index.js @@ -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 | 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] - 消息 + */ diff --git a/scripts/playwright-login/oauth-handler.js b/scripts/playwright-login/oauth-handler.js new file mode 100644 index 000000000..c79886ee9 --- /dev/null +++ b/scripts/playwright-login/oauth-handler.js @@ -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} + */ + 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} + */ + 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} start - 启动 OAuth 流程 + * @property {() => Promise} cancel - 取消 OAuth 流程 + */ diff --git a/scripts/playwright-login/package.json b/scripts/playwright-login/package.json new file mode 100644 index 000000000..fcaa90e2c --- /dev/null +++ b/scripts/playwright-login/package.json @@ -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" +} diff --git a/scripts/playwright-login/test/browser-config.property.test.js b/scripts/playwright-login/test/browser-config.property.test.js new file mode 100644 index 000000000..4f9dc0108 --- /dev/null +++ b/scripts/playwright-login/test/browser-config.property.test.js @@ -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 } + ); + }); +}); diff --git a/scripts/playwright-login/test/oauth-url.property.test.js b/scripts/playwright-login/test/oauth-url.property.test.js new file mode 100644 index 000000000..9a1272f4b --- /dev/null +++ b/scripts/playwright-login/test/oauth-url.property.test.js @@ -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 } + ); + }); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bbfa19f1b..a20b0ca38 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3377,7 +3377,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.17.3" +version = "0.17.4" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0daa46c0d..ece71fe0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ecf94c399..28697948b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 13b8dfd25..a7f76c40b 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md new file mode 100644 index 000000000..872262f8a --- /dev/null +++ b/src-tauri/src/README.md @@ -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` - 服务器工具函数 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index 7438c6cb7..c1639303d 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -624,6 +624,183 @@ pub fn add_kiro_oauth_credential( ) } +/// 从 JSON 内容创建 Kiro 凭证文件并添加到凭证池 +/// +/// 直接粘贴 JSON 内容,无需选择文件 +fn create_kiro_credential_from_json(json_content: &str) -> Result { + // 验证 JSON 格式 + let creds: serde_json::Value = + serde_json::from_str(json_content).map_err(|e| format!("JSON 格式无效: {}", e))?; + + // 验证必要字段 + if creds.get("refreshToken").is_none() { + return Err("凭证 JSON 缺少 refreshToken 字段".to_string()); + } + + // 检测 refreshToken 是否被截断 + if let Some(refresh_token) = creds.get("refreshToken").and_then(|v| v.as_str()) { + let token_len = refresh_token.len(); + let is_truncated = + token_len < 100 || refresh_token.ends_with("...") || refresh_token.contains("..."); + + if is_truncated { + tracing::warn!( + "[KIRO] 检测到 refreshToken 可能被截断!长度: {} (仍允许添加,刷新时会提示)", + token_len + ); + } + } + + // 生成新的文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let new_filename = format!("kiro_{}_{}_{}.json", &uuid[..8], timestamp, "kiro"); + + // 获取目标目录 + let credentials_dir = get_credentials_dir()?; + let target_path = credentials_dir.join(&new_filename); + + // 尝试合并 clientId/clientSecret(如果凭证中没有) + let mut merged_creds = creds.clone(); + + // 检查是否需要从外部文件获取 clientId/clientSecret + let has_client_id = merged_creds.get("clientId").is_some(); + let has_client_secret = merged_creds.get("clientSecret").is_some(); + + if !has_client_id || !has_client_secret { + let aws_sso_cache_dir = dirs::home_dir() + .ok_or_else(|| "无法获取用户主目录".to_string())? + .join(".aws") + .join("sso") + .join("cache"); + + let mut found_credentials = false; + + // 方式1:如果有 clientIdHash,读取对应文件 + if let Some(hash) = merged_creds.get("clientIdHash").and_then(|v| v.as_str()) { + let hash_file_path = aws_sso_cache_dir.join(format!("{}.json", hash)); + + if hash_file_path.exists() { + if let Ok(hash_content) = fs::read_to_string(&hash_file_path) { + if let Ok(hash_json) = serde_json::from_str::(&hash_content) + { + if let Some(client_id) = hash_json.get("clientId") { + merged_creds["clientId"] = client_id.clone(); + } + if let Some(client_secret) = hash_json.get("clientSecret") { + merged_creds["clientSecret"] = client_secret.clone(); + } + if merged_creds.get("clientId").is_some() + && merged_creds.get("clientSecret").is_some() + { + found_credentials = true; + tracing::info!( + "[KIRO] 已从 clientIdHash 文件合并 client_id/client_secret" + ); + } + } + } + } + } + + // 方式2:扫描目录中的其他 JSON 文件 + if !found_credentials && aws_sso_cache_dir.exists() { + tracing::info!("[KIRO] 扫描目录查找 client_id/client_secret"); + if let Ok(entries) = fs::read_dir(&aws_sso_cache_dir) { + for entry in entries.flatten() { + let file_path = entry.path(); + if file_path.extension().map(|e| e == "json").unwrap_or(false) { + let file_name = + file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if file_name.starts_with("kiro-auth-token") { + continue; + } + if let Ok(file_content) = fs::read_to_string(&file_path) { + if let Ok(file_json) = + serde_json::from_str::(&file_content) + { + let has_cid = + file_json.get("clientId").and_then(|v| v.as_str()).is_some(); + let has_csec = file_json + .get("clientSecret") + .and_then(|v| v.as_str()) + .is_some(); + if has_cid && has_csec { + merged_creds["clientId"] = file_json["clientId"].clone(); + merged_creds["clientSecret"] = + file_json["clientSecret"].clone(); + found_credentials = true; + tracing::info!( + "[KIRO] 从 {} 合并 client_id/client_secret", + file_name + ); + break; + } + } + } + } + } + } + } + + if !found_credentials { + let auth_method = merged_creds + .get("authMethod") + .and_then(|v| v.as_str()) + .unwrap_or("social"); + + if auth_method.to_lowercase() == "idc" { + tracing::error!( + "[KIRO] IdC 认证方式缺少 clientId/clientSecret,无法创建有效的凭证" + ); + return Err( + "IdC 认证凭证不完整:缺少 clientId/clientSecret。\n\n💡 解决方案:\n1. 确保 ~/.aws/sso/cache/ 目录下有对应的 clientIdHash 文件\n2. 如果使用 AWS IAM Identity Center,请确保已完成完整的 SSO 登录流程\n3. 或者尝试使用 Social 认证方式的凭证".to_string() + ); + } else { + tracing::warn!("[KIRO] 未找到 client_id/client_secret,将使用 social 认证方式"); + } + } + } + + // 写入凭证文件 + let merged_content = serde_json::to_string_pretty(&merged_creds) + .map_err(|e| format!("序列化凭证失败: {}", e))?; + fs::write(&target_path, merged_content).map_err(|e| format!("写入凭证文件失败: {}", e))?; + + tracing::info!("[KIRO] 凭证文件已创建: {:?}", target_path); + + Ok(target_path.to_string_lossy().to_string()) +} + +/// 添加 Kiro OAuth 凭证(通过 JSON 内容) +/// +/// 直接粘贴凭证 JSON 内容,无需选择文件 +#[tauri::command] +pub fn add_kiro_from_json( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + json_content: String, + name: Option, +) -> Result { + // 从 JSON 内容创建凭证文件 + let stored_file_path = create_kiro_credential_from_json(&json_content)?; + + pool_service.0.add_credential( + &db, + "kiro", + CredentialData::KiroOAuth { + creds_file_path: stored_file_path, + }, + name, + Some(true), + None, + ) +} + /// 添加 Gemini OAuth 凭证(通过文件路径) #[tauri::command] pub fn add_gemini_oauth_credential( @@ -1843,3 +2020,1939 @@ pub async fn start_gemini_oauth_login( Ok(credential) } + +// ============ Kiro Builder ID 登录相关命令 ============ + +/// Kiro Builder ID 登录状态 +#[derive(Debug, Clone)] +struct KiroBuilderIdLoginState { + /// OIDC 客户端 ID + client_id: String, + /// OIDC 客户端密钥 + client_secret: String, + /// 设备码 + device_code: String, + /// 用户码 + user_code: String, + /// 验证 URI + verification_uri: String, + /// 轮询间隔(秒) + interval: i64, + /// 过期时间戳 + expires_at: i64, + /// 区域 + region: String, +} + +/// 全局 Builder ID 登录状态存储 +static KIRO_BUILDER_ID_LOGIN_STATE: Lazy>> = + Lazy::new(|| RwLock::new(None)); + +/// Kiro Builder ID 登录启动响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KiroBuilderIdLoginResponse { + /// 是否成功 + pub success: bool, + /// 用户码(用于显示给用户) + #[serde(rename = "userCode")] + pub user_code: Option, + /// 验证 URI(用户需要访问的 URL) + #[serde(rename = "verificationUri")] + pub verification_uri: Option, + /// 过期时间(秒) + #[serde(rename = "expiresIn")] + pub expires_in: Option, + /// 轮询间隔(秒) + pub interval: Option, + /// 错误信息 + pub error: Option, +} + +/// Kiro Builder ID 轮询响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KiroBuilderIdPollResponse { + /// 是否成功 + pub success: bool, + /// 是否完成授权 + pub completed: bool, + /// 状态(pending / slow_down) + pub status: Option, + /// 错误信息 + pub error: Option, +} + +/// 启动 Kiro Builder ID 登录 +/// +/// 使用 OIDC Device Authorization Flow 进行登录 +#[tauri::command] +pub async fn start_kiro_builder_id_login( + region: Option, +) -> Result { + let region = region.unwrap_or_else(|| "us-east-1".to_string()); + let oidc_base = format!("https://oidc.{}.amazonaws.com", region); + let start_url = "https://view.awsapps.com/start"; + let scopes = vec![ + "codewhisperer:completions", + "codewhisperer:analysis", + "codewhisperer:conversations", + "codewhisperer:transformations", + "codewhisperer:taskassist", + ]; + + tracing::info!("[Kiro Builder ID] 开始登录流程,区域: {}", region); + + // Step 1: 注册 OIDC 客户端 + tracing::info!("[Kiro Builder ID] Step 1: 注册 OIDC 客户端..."); + let client = reqwest::Client::new(); + + let reg_body = serde_json::json!({ + "clientName": "ProxyCast Kiro Manager", + "clientType": "public", + "scopes": scopes, + "grantTypes": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + "issuerUrl": start_url + }); + + let reg_res = client + .post(format!("{}/client/register", oidc_base)) + .header("Content-Type", "application/json") + .json(®_body) + .send() + .await + .map_err(|e| format!("注册客户端请求失败: {}", e))?; + + if !reg_res.status().is_success() { + let err_text = reg_res.text().await.unwrap_or_default(); + return Ok(KiroBuilderIdLoginResponse { + success: false, + user_code: None, + verification_uri: None, + expires_in: None, + interval: None, + error: Some(format!("注册客户端失败: {}", err_text)), + }); + } + + let reg_data: serde_json::Value = reg_res + .json() + .await + .map_err(|e| format!("解析注册响应失败: {}", e))?; + + let client_id = reg_data["clientId"] + .as_str() + .ok_or("响应中缺少 clientId")? + .to_string(); + let client_secret = reg_data["clientSecret"] + .as_str() + .ok_or("响应中缺少 clientSecret")? + .to_string(); + + tracing::info!( + "[Kiro Builder ID] 客户端注册成功: {}...", + &client_id[..30.min(client_id.len())] + ); + + // Step 2: 发起设备授权 + tracing::info!("[Kiro Builder ID] Step 2: 发起设备授权..."); + let auth_body = serde_json::json!({ + "clientId": client_id, + "clientSecret": client_secret, + "startUrl": start_url + }); + + let auth_res = client + .post(format!("{}/device_authorization", oidc_base)) + .header("Content-Type", "application/json") + .json(&auth_body) + .send() + .await + .map_err(|e| format!("设备授权请求失败: {}", e))?; + + if !auth_res.status().is_success() { + let err_text = auth_res.text().await.unwrap_or_default(); + return Ok(KiroBuilderIdLoginResponse { + success: false, + user_code: None, + verification_uri: None, + expires_in: None, + interval: None, + error: Some(format!("设备授权失败: {}", err_text)), + }); + } + + let auth_data: serde_json::Value = auth_res + .json() + .await + .map_err(|e| format!("解析授权响应失败: {}", e))?; + + let device_code = auth_data["deviceCode"] + .as_str() + .ok_or("响应中缺少 deviceCode")? + .to_string(); + let user_code = auth_data["userCode"] + .as_str() + .ok_or("响应中缺少 userCode")? + .to_string(); + let verification_uri = auth_data["verificationUriComplete"] + .as_str() + .or_else(|| auth_data["verificationUri"].as_str()) + .ok_or("响应中缺少 verificationUri")? + .to_string(); + let interval = auth_data["interval"].as_i64().unwrap_or(5); + let expires_in = auth_data["expiresIn"].as_i64().unwrap_or(600); + + tracing::info!("[Kiro Builder ID] 设备码获取成功,user_code: {}", user_code); + + // 保存登录状态 + let expires_at = chrono::Utc::now().timestamp() + expires_in; + { + let mut state = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state = Some(KiroBuilderIdLoginState { + client_id, + client_secret, + device_code, + user_code: user_code.clone(), + verification_uri: verification_uri.clone(), + interval, + expires_at, + region, + }); + } + + Ok(KiroBuilderIdLoginResponse { + success: true, + user_code: Some(user_code), + verification_uri: Some(verification_uri), + expires_in: Some(expires_in), + interval: Some(interval), + error: None, + }) +} + +/// 轮询 Kiro Builder ID 授权状态 +#[tauri::command] +pub async fn poll_kiro_builder_id_auth() -> Result { + let state = { + let state_guard = KIRO_BUILDER_ID_LOGIN_STATE.read().await; + match state_guard.as_ref() { + Some(s) => s.clone(), + None => { + return Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some("没有进行中的登录".to_string()), + }); + } + } + }; + + // 检查是否过期 + if chrono::Utc::now().timestamp() > state.expires_at { + // 清除状态 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state_guard = None; + } + return Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some("授权已过期,请重新开始".to_string()), + }); + } + + let oidc_base = format!("https://oidc.{}.amazonaws.com", state.region); + let client = reqwest::Client::new(); + + let token_body = serde_json::json!({ + "clientId": state.client_id, + "clientSecret": state.client_secret, + "grantType": "urn:ietf:params:oauth:grant-type:device_code", + "deviceCode": state.device_code + }); + + let token_res = client + .post(format!("{}/token", oidc_base)) + .header("Content-Type", "application/json") + .json(&token_body) + .send() + .await + .map_err(|e| format!("Token 请求失败: {}", e))?; + + let status = token_res.status(); + + if status.is_success() { + // 授权成功 + let token_data: serde_json::Value = token_res + .json() + .await + .map_err(|e| format!("解析 Token 响应失败: {}", e))?; + + tracing::info!("[Kiro Builder ID] 授权成功!"); + + // 保存凭证到文件 + let access_token = token_data["accessToken"].as_str().unwrap_or("").to_string(); + let refresh_token = token_data["refreshToken"] + .as_str() + .unwrap_or("") + .to_string(); + let expires_in = token_data["expiresIn"].as_i64().unwrap_or(3600); + + // 创建凭证 JSON + let creds_json = serde_json::json!({ + "accessToken": access_token, + "refreshToken": refresh_token, + "clientId": state.client_id, + "clientSecret": state.client_secret, + "region": state.region, + "authMethod": "idc", + "expiresAt": chrono::Utc::now().timestamp() + expires_in + }); + + // 保存到临时状态,等待 add_kiro_from_builder_id_auth 调用 + // 这里我们把凭证 JSON 存储到一个临时位置 + { + let mut sessions = KIRO_BUILDER_ID_CREDENTIALS.write().await; + sessions.insert("pending".to_string(), creds_json); + } + + // 清除登录状态 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state_guard = None; + } + + Ok(KiroBuilderIdPollResponse { + success: true, + completed: true, + status: None, + error: None, + }) + } else if status.as_u16() == 400 { + let err_data: serde_json::Value = token_res + .json() + .await + .map_err(|e| format!("解析错误响应失败: {}", e))?; + + let error = err_data["error"].as_str().unwrap_or("unknown"); + + match error { + "authorization_pending" => Ok(KiroBuilderIdPollResponse { + success: true, + completed: false, + status: Some("pending".to_string()), + error: None, + }), + "slow_down" => { + // 增加轮询间隔 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + if let Some(ref mut s) = *state_guard { + s.interval += 5; + } + } + Ok(KiroBuilderIdPollResponse { + success: true, + completed: false, + status: Some("slow_down".to_string()), + error: None, + }) + } + "expired_token" => { + // 清除状态 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state_guard = None; + } + Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some("设备码已过期".to_string()), + }) + } + "access_denied" => { + // 清除状态 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state_guard = None; + } + Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some("用户拒绝授权".to_string()), + }) + } + _ => { + // 清除状态 + { + let mut state_guard = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state_guard = None; + } + Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some(format!("授权错误: {}", error)), + }) + } + } + } else { + Ok(KiroBuilderIdPollResponse { + success: false, + completed: false, + status: None, + error: Some(format!("未知响应: {}", status)), + }) + } +} + +/// 临时存储 Builder ID 登录成功后的凭证 +static KIRO_BUILDER_ID_CREDENTIALS: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// 取消 Kiro Builder ID 登录 +#[tauri::command] +pub async fn cancel_kiro_builder_id_login() -> Result { + tracing::info!("[Kiro Builder ID] 取消登录"); + { + let mut state = KIRO_BUILDER_ID_LOGIN_STATE.write().await; + *state = None; + } + { + let mut creds = KIRO_BUILDER_ID_CREDENTIALS.write().await; + creds.remove("pending"); + } + Ok(true) +} + +/// 从 Builder ID 授权结果添加 Kiro 凭证 +#[tauri::command] +pub async fn add_kiro_from_builder_id_auth( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + // 获取待处理的凭证 + let creds_json = { + let mut creds = KIRO_BUILDER_ID_CREDENTIALS.write().await; + creds + .remove("pending") + .ok_or("没有待处理的 Builder ID 凭证")? + }; + + // 将凭证 JSON 转换为字符串 + let json_content = + serde_json::to_string_pretty(&creds_json).map_err(|e| format!("序列化凭证失败: {}", e))?; + + // 使用现有的 create_kiro_credential_from_json 函数创建凭证文件 + let stored_file_path = create_kiro_credential_from_json(&json_content)?; + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "kiro", + CredentialData::KiroOAuth { + creds_file_path: stored_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Kiro Builder ID] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +// ============ Kiro Social Auth 登录相关命令 (Google/GitHub) ============ + +/// Kiro Auth 端点 +const KIRO_AUTH_ENDPOINT: &str = "https://prod.us-east-1.auth.desktop.kiro.dev"; + +/// Kiro Social Auth 登录状态 +#[derive(Debug, Clone)] +struct KiroSocialAuthLoginState { + /// 登录提供商 (Google / Github) + provider: String, + /// PKCE code_verifier + code_verifier: String, + /// PKCE code_challenge + code_challenge: String, + /// OAuth state + oauth_state: String, + /// 过期时间戳 + expires_at: i64, +} + +/// 全局 Social Auth 登录状态存储 +static KIRO_SOCIAL_AUTH_LOGIN_STATE: Lazy>> = + Lazy::new(|| RwLock::new(None)); + +/// Kiro Social Auth 登录启动响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KiroSocialAuthLoginResponse { + /// 是否成功 + pub success: bool, + /// 登录 URL + #[serde(rename = "loginUrl")] + pub login_url: Option, + /// OAuth state(用于验证回调) + pub state: Option, + /// 错误信息 + pub error: Option, +} + +/// Kiro Social Auth Token 交换响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KiroSocialAuthTokenResponse { + /// 是否成功 + pub success: bool, + /// 错误信息 + pub error: Option, +} + +/// 生成 PKCE code_verifier +fn generate_code_verifier() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..64).map(|_| rng.gen()).collect(); + base64_url_encode(&bytes)[..128.min(base64_url_encode(&bytes).len())].to_string() +} + +/// 生成 PKCE code_challenge (SHA256) +fn generate_code_challenge(verifier: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let result = hasher.finalize(); + base64_url_encode(&result) +} + +/// 生成 OAuth state +fn generate_oauth_state() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..32).map(|_| rng.gen()).collect(); + base64_url_encode(&bytes) +} + +/// Base64 URL 编码(无填充) +fn base64_url_encode(data: &[u8]) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD.encode(data) +} + +/// 启动 Kiro Social Auth 登录 (Google/GitHub) +/// +/// 使用 PKCE OAuth 流程进行登录 +/// 打开系统默认浏览器进行 OAuth 登录 +#[tauri::command] +pub async fn start_kiro_social_auth_login( + provider: String, +) -> Result { + // 验证 provider + let provider_normalized = match provider.to_lowercase().as_str() { + "google" => "Google", + "github" => "Github", + _ => { + return Ok(KiroSocialAuthLoginResponse { + success: false, + login_url: None, + state: None, + error: Some(format!("不支持的登录提供商: {}", provider)), + }); + } + }; + + tracing::info!("[Kiro Social Auth] 开始 {} 登录流程", provider_normalized); + + // 生成 PKCE + let code_verifier = generate_code_verifier(); + let code_challenge = generate_code_challenge(&code_verifier); + let oauth_state = generate_oauth_state(); + + // 构建登录 URL + // 使用本地回调服务器接收授权码 + let redirect_uri = "http://127.0.0.1:19823/kiro-social-callback"; + + let login_url = format!( + "{}/login?idp={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}", + KIRO_AUTH_ENDPOINT, + provider_normalized, + urlencoding::encode(redirect_uri), + urlencoding::encode(&code_challenge), + urlencoding::encode(&oauth_state) + ); + + tracing::info!("[Kiro Social Auth] 登录 URL: {}", login_url); + + // 保存登录状态(10 分钟过期) + let expires_at = chrono::Utc::now().timestamp() + 600; + { + let mut state = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state = Some(KiroSocialAuthLoginState { + provider: provider_normalized.to_string(), + code_verifier, + code_challenge, + oauth_state: oauth_state.clone(), + expires_at, + }); + } + + Ok(KiroSocialAuthLoginResponse { + success: true, + login_url: Some(login_url), + state: Some(oauth_state), + error: None, + }) +} + +/// 交换 Kiro Social Auth Token +/// +/// 用授权码交换 access_token 和 refresh_token +#[tauri::command] +pub async fn exchange_kiro_social_auth_token( + code: String, + state: String, +) -> Result { + tracing::info!("[Kiro Social Auth] 交换 Token..."); + + // 获取并验证登录状态 + let login_state = { + let state_guard = KIRO_SOCIAL_AUTH_LOGIN_STATE.read().await; + match state_guard.as_ref() { + Some(s) => s.clone(), + None => { + return Ok(KiroSocialAuthTokenResponse { + success: false, + error: Some("没有进行中的社交登录".to_string()), + }); + } + } + }; + + // 验证 state + if state != login_state.oauth_state { + // 清除状态 + { + let mut state_guard = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state_guard = None; + } + return Ok(KiroSocialAuthTokenResponse { + success: false, + error: Some("状态参数不匹配,可能存在安全风险".to_string()), + }); + } + + // 检查是否过期 + if chrono::Utc::now().timestamp() > login_state.expires_at { + // 清除状态 + { + let mut state_guard = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state_guard = None; + } + return Ok(KiroSocialAuthTokenResponse { + success: false, + error: Some("登录已过期,请重新开始".to_string()), + }); + } + + let redirect_uri = "http://127.0.0.1:19823/kiro-social-callback"; + + // 交换 Token + let client = reqwest::Client::new(); + let token_body = serde_json::json!({ + "code": code, + "code_verifier": login_state.code_verifier, + "redirect_uri": redirect_uri + }); + + let token_res = client + .post(format!("{}/oauth/token", KIRO_AUTH_ENDPOINT)) + .header("Content-Type", "application/json") + .json(&token_body) + .send() + .await + .map_err(|e| format!("Token 交换请求失败: {}", e))?; + + if !token_res.status().is_success() { + let err_text = token_res.text().await.unwrap_or_default(); + // 清除状态 + { + let mut state_guard = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state_guard = None; + } + return Ok(KiroSocialAuthTokenResponse { + success: false, + error: Some(format!("Token 交换失败: {}", err_text)), + }); + } + + let token_data: serde_json::Value = token_res + .json() + .await + .map_err(|e| format!("解析 Token 响应失败: {}", e))?; + + tracing::info!("[Kiro Social Auth] Token 交换成功!"); + + // 提取凭证 + let access_token = token_data["accessToken"].as_str().unwrap_or("").to_string(); + let refresh_token = token_data["refreshToken"] + .as_str() + .unwrap_or("") + .to_string(); + let profile_arn = token_data["profileArn"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expiresIn"].as_i64().unwrap_or(3600); + + // 创建凭证 JSON + let creds_json = serde_json::json!({ + "accessToken": access_token, + "refreshToken": refresh_token, + "profileArn": profile_arn, + "authMethod": "social", + "provider": login_state.provider, + "expiresAt": chrono::Utc::now().timestamp() + expires_in + }); + + // 保存到临时状态 + { + let mut creds = KIRO_BUILDER_ID_CREDENTIALS.write().await; + creds.insert("pending".to_string(), creds_json); + } + + // 清除登录状态 + { + let mut state_guard = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state_guard = None; + } + + Ok(KiroSocialAuthTokenResponse { + success: true, + error: None, + }) +} + +/// 取消 Kiro Social Auth 登录 +#[tauri::command] +pub async fn cancel_kiro_social_auth_login() -> Result { + tracing::info!("[Kiro Social Auth] 取消登录"); + { + let mut state = KIRO_SOCIAL_AUTH_LOGIN_STATE.write().await; + *state = None; + } + Ok(true) +} + +// ============ Playwright 指纹浏览器登录相关命令 ============ + +/// Playwright 可用性状态 +/// +/// Requirements: 2.1, 2.2 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PlaywrightStatus { + /// 浏览器是否可用 + pub available: bool, + /// 浏览器可执行文件路径 + pub browser_path: Option, + /// 浏览器来源: "system" 或 "playwright" + pub browser_source: Option, + /// 错误信息 + pub error: Option, +} + +/// 获取系统 Chrome 可执行文件路径 +fn get_system_chrome_path() -> Option { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + let paths = [ + PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), + home.join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + #[cfg(target_os = "windows")] + { + let paths = [ + PathBuf::from("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"), + PathBuf::from("C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"), + home.join("AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + #[cfg(target_os = "linux")] + { + let paths = [ + PathBuf::from("/usr/bin/google-chrome"), + PathBuf::from("/usr/bin/google-chrome-stable"), + PathBuf::from("/usr/bin/chromium"), + PathBuf::from("/usr/bin/chromium-browser"), + PathBuf::from("/snap/bin/chromium"), + ]; + for path in paths { + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + None +} + +/// 获取 Playwright 浏览器缓存目录 +fn get_playwright_cache_dir() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + home.join("Library").join("Caches").join("ms-playwright") + } + + #[cfg(target_os = "windows")] + { + home.join("AppData").join("Local").join("ms-playwright") + } + + #[cfg(target_os = "linux")] + { + home.join(".cache").join("ms-playwright") + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + home.join(".cache").join("ms-playwright") + } +} + +/// 获取 Playwright Chromium 浏览器可执行文件路径 +/// +/// 搜索常见的 Chromium 版本目录 +fn get_playwright_browser_path() -> Option { + let cache_dir = get_playwright_cache_dir(); + + // Playwright 常见的 Chromium 版本目录 + let chromium_versions = [ + "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 version in chromium_versions { + #[cfg(target_os = "macos")] + let exec_path = cache_dir + .join(version) + .join("chrome-mac") + .join("Chromium.app") + .join("Contents") + .join("MacOS") + .join("Chromium"); + + #[cfg(target_os = "windows")] + let exec_path = cache_dir + .join(version) + .join("chrome-win") + .join("chrome.exe"); + + #[cfg(target_os = "linux")] + let exec_path = cache_dir.join(version).join("chrome-linux").join("chrome"); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + let exec_path = cache_dir.join(version).join("chrome-linux").join("chrome"); + + if exec_path.exists() { + return Some(exec_path.to_string_lossy().to_string()); + } + } + + None +} + +/// 获取可用的浏览器路径(优先系统 Chrome) +fn get_available_browser_path() -> Option<(String, String)> { + // 优先使用系统 Chrome + if let Some(path) = get_system_chrome_path() { + return Some((path, "system".to_string())); + } + + // 其次使用 Playwright Chromium + if let Some(path) = get_playwright_browser_path() { + return Some((path, "playwright".to_string())); + } + + None +} + +/// 检查浏览器是否可用(优先系统 Chrome) +/// +/// 检测系统 Chrome 或 Playwright Chromium 是否存在 +/// Requirements: 2.1, 2.2 +#[tauri::command] +pub async fn check_playwright_available() -> Result { + tracing::info!("[Browser] 检查浏览器可用性..."); + + match get_available_browser_path() { + Some((browser_path, source)) => { + tracing::info!("[Browser] 找到 {} 浏览器: {}", source, browser_path); + Ok(PlaywrightStatus { + available: true, + browser_path: Some(browser_path), + browser_source: Some(source), + error: None, + }) + } + None => { + let error_msg = + "未找到可用的浏览器。请安装 Google Chrome 或运行: npx playwright install chromium" + .to_string(); + tracing::warn!("[Browser] {}", error_msg); + Ok(PlaywrightStatus { + available: false, + browser_path: None, + browser_source: None, + error: Some(error_msg), + }) + } + } +} + +/// Playwright 安装进度事件 +/// +/// 用于向前端发送安装进度信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PlaywrightInstallProgress { + /// 进度消息 + pub message: String, + /// 是否完成 + pub done: bool, + /// 是否成功(仅在 done=true 时有效) + pub success: Option, +} + +/// 安装 Playwright Chromium 浏览器 +/// +/// 执行 npm install playwright && npx playwright install chromium +/// Requirements: 6.1, 6.2 +#[tauri::command] +pub async fn install_playwright(app: tauri::AppHandle) -> Result { + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::process::Command; + + tracing::info!("[Playwright] 开始安装 Playwright..."); + + // 发送进度事件 + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: "正在查找 Playwright 脚本目录...".to_string(), + done: false, + success: None, + }, + ); + + // 尝试多个可能的脚本目录路径 + let possible_paths = vec![ + // 开发模式:从 CARGO_MANIFEST_DIR 推导 + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(&PathBuf::from(".")) + .join("scripts") + .join("playwright-login"), + // 生产模式:应用数据目录 + dirs::data_dir() + .unwrap_or_default() + .join("proxycast") + .join("scripts") + .join("playwright-login"), + // 当前工作目录 + std::env::current_dir() + .unwrap_or_default() + .join("scripts") + .join("playwright-login"), + ]; + + let mut script_dir: Option = None; + for path in &possible_paths { + tracing::info!("[Playwright] 检查路径: {:?}", path); + if path.join("package.json").exists() { + script_dir = Some(path.clone()); + break; + } + } + + let script_dir = match script_dir { + Some(dir) => dir, + None => { + let error = format!( + "找不到 Playwright 脚本目录。已检查路径:\n{}", + possible_paths + .iter() + .map(|p| format!(" - {:?}", p)) + .collect::>() + .join("\n") + ); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + }; + + tracing::info!("[Playwright] 使用脚本目录: {:?}", script_dir); + + // 步骤 1: 安装 npm 依赖 + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: format!("正在安装 npm 依赖... ({})", script_dir.display()), + done: false, + success: None, + }, + ); + + let npm_install = Command::new("npm") + .arg("install") + .current_dir(&script_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + + match npm_install { + Ok(mut child) => { + // 收集 stderr 输出用于错误报告 + let mut stderr_output = String::new(); + if let Some(stderr) = child.stderr.take() { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + tracing::debug!("[Playwright npm] {}", line); + stderr_output.push_str(&line); + stderr_output.push('\n'); + } + } + + let status = child.wait().await; + match status { + Ok(s) if s.success() => { + tracing::info!("[Playwright] npm install 成功"); + // 发送成功消息 + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: "npm 依赖安装成功,准备安装 Chromium 浏览器...".to_string(), + done: false, + success: None, + }, + ); + } + Ok(s) => { + let error = if stderr_output.is_empty() { + format!("npm install 失败,退出码: {:?}", s.code()) + } else { + format!("npm install 失败: {}", stderr_output.trim()) + }; + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + Err(e) => { + let error = format!("npm install 执行失败: {}", e); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + } + } + Err(e) => { + let error = format!("无法启动 npm: {}。请确保已安装 Node.js", e); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + } + + // 步骤 2: 安装 Chromium 浏览器 + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: "正在安装 Chromium 浏览器 (npx playwright install chromium)...".to_string(), + done: false, + success: None, + }, + ); + + let playwright_install = Command::new("npx") + .args(["playwright", "install", "chromium"]) + .current_dir(&script_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + + match playwright_install { + Ok(mut child) => { + // 同时收集 stdout 和 stderr + let mut stdout_output = String::new(); + let mut stderr_output = String::new(); + + // 读取 stdout 并发送进度 + if let Some(stdout) = child.stdout.take() { + let app_clone = app.clone(); + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + tracing::info!("[Playwright install] {}", line); + stdout_output.push_str(&line); + stdout_output.push('\n'); + // 发送下载进度 + if line.contains("Downloading") + || line.contains("%") + || line.contains("chromium") + { + let _ = app_clone.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: line.clone(), + done: false, + success: None, + }, + ); + } + } + } + + // 读取 stderr + if let Some(stderr) = child.stderr.take() { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + tracing::warn!("[Playwright install stderr] {}", line); + stderr_output.push_str(&line); + stderr_output.push('\n'); + } + } + + let status = child.wait().await; + match status { + Ok(s) if s.success() => { + tracing::info!("[Playwright] Chromium 安装成功"); + } + Ok(s) => { + // 优先使用 stderr,如果为空则使用 stdout + let output = if !stderr_output.is_empty() { + stderr_output.trim().to_string() + } else if !stdout_output.is_empty() { + stdout_output.trim().to_string() + } else { + format!("退出码: {:?}", s.code()) + }; + let error = format!("Chromium 安装失败: {}", output); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + Err(e) => { + let error = format!("Chromium 安装执行失败: {}", e); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + } + } + Err(e) => { + let error = format!("无法启动 npx: {}", e); + tracing::error!("[Playwright] {}", error); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + } + + // 验证安装结果 + let status = check_playwright_available().await?; + + if status.available { + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: "Playwright 安装成功!".to_string(), + done: true, + success: Some(true), + }, + ); + tracing::info!( + "[Playwright] 安装完成,浏览器路径: {:?}", + status.browser_path + ); + } else { + let error = + "安装完成但未检测到浏览器,请手动运行: npx playwright install chromium".to_string(); + let _ = app.emit( + "playwright-install-progress", + PlaywrightInstallProgress { + message: error.clone(), + done: true, + success: Some(false), + }, + ); + return Err(error); + } + + Ok(status) +} + +/// Playwright 登录进度事件 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PlaywrightLoginProgress { + pub message: String, +} + +/// Playwright 登录结果 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PlaywrightLoginResult { + pub success: bool, + pub code: Option, + pub state: Option, + pub error: Option, +} + +/// 全局 Playwright 登录进程状态 +static PLAYWRIGHT_LOGIN_PROCESS: Lazy>> = + Lazy::new(|| RwLock::new(None)); + +/// 获取 Playwright 登录脚本路径 +fn get_playwright_script_path() -> PathBuf { + // 开发模式下使用项目目录中的脚本 + let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(&PathBuf::from(".")) + .join("scripts") + .join("playwright-login") + .join("index.js"); + + if dev_path.exists() { + return dev_path; + } + + // 生产模式下使用打包的资源 + if let Some(data_dir) = dirs::data_dir() { + let prod_path = data_dir + .join("proxycast") + .join("scripts") + .join("playwright-login") + .join("index.js"); + if prod_path.exists() { + return prod_path; + } + } + + // 回退到开发路径 + dev_path +} + +/// 启动 Kiro Playwright 登录 +/// +/// 使用 Playwright 指纹浏览器进行 OAuth 登录 +/// Requirements: 3.1, 3.4, 3.5, 4.3, 4.4 +#[tauri::command] +pub async fn start_kiro_playwright_login( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + provider: String, + name: Option, +) -> Result { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + + // 验证 provider + let provider_normalized = match provider.to_lowercase().as_str() { + "google" => "Google", + "github" => "Github", + "builderid" => "BuilderId", + _ => { + return Err(format!("不支持的登录提供商: {}", provider)); + } + }; + + tracing::info!("[Playwright Login] 开始 {} 登录流程", provider_normalized); + + // 检查 Playwright 是否可用 + let status = check_playwright_available().await?; + if !status.available { + return Err(status + .error + .unwrap_or_else(|| "Playwright 不可用".to_string())); + } + + // 生成 PKCE + let code_verifier = generate_code_verifier(); + let code_challenge = generate_code_challenge(&code_verifier); + let oauth_state = generate_oauth_state(); + + // 构建 OAuth URL + let redirect_uri = "http://localhost:19824/callback"; + let auth_url = format!( + "{}/login?idp={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}", + KIRO_AUTH_ENDPOINT, + provider_normalized, + urlencoding::encode(redirect_uri), + urlencoding::encode(&code_challenge), + urlencoding::encode(&oauth_state) + ); + + tracing::info!("[Playwright Login] OAuth URL: {}", auth_url); + + // 获取脚本路径 + let script_path = get_playwright_script_path(); + if !script_path.exists() { + return Err(format!("Playwright 登录脚本不存在: {:?}", script_path)); + } + + tracing::info!("[Playwright Login] 脚本路径: {:?}", script_path); + + // 启动 Node.js 进程 + let mut child = Command::new("node") + .arg(&script_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("启动 Playwright 进程失败: {}", e))?; + + let stdin = child.stdin.take().ok_or("无法获取 stdin")?; + let stdout = child.stdout.take().ok_or("无法获取 stdout")?; + + // 保存进程引用 + { + let mut process_guard = PLAYWRIGHT_LOGIN_PROCESS.write().await; + *process_guard = Some(child); + } + + let mut stdin = tokio::io::BufWriter::new(stdin); + let mut reader = BufReader::new(stdout); + + // 等待就绪信号 + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .map_err(|e| format!("读取就绪信号失败: {}", e))?; + + let ready_response: serde_json::Value = + serde_json::from_str(&line.trim()).map_err(|e| format!("解析就绪信号失败: {}", e))?; + + if ready_response.get("action").and_then(|v| v.as_str()) != Some("ready") { + return Err("Playwright 脚本未就绪".to_string()); + } + + tracing::info!("[Playwright Login] Sidecar 已就绪"); + + // 发送登录请求 + let login_request = serde_json::json!({ + "action": "login", + "provider": provider_normalized, + "authUrl": auth_url, + "callbackUrl": redirect_uri + }); + + let request_str = + serde_json::to_string(&login_request).map_err(|e| format!("序列化请求失败: {}", e))?; + + stdin + .write_all(request_str.as_bytes()) + .await + .map_err(|e| format!("发送请求失败: {}", e))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| format!("发送换行失败: {}", e))?; + stdin + .flush() + .await + .map_err(|e| format!("刷新 stdin 失败: {}", e))?; + + tracing::info!("[Playwright Login] 已发送登录请求"); + + // 读取响应 + let mut code: Option = None; + let mut state: Option = None; + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => { + // EOF + break; + } + Ok(_) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + match serde_json::from_str::(trimmed) { + Ok(response) => { + let action = response + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let success = response + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + match action { + "progress" => { + if let Some(data) = response.get("data") { + if let Some(message) = + data.get("message").and_then(|v| v.as_str()) + { + tracing::info!("[Playwright Login] 进度: {}", message); + let _ = app.emit( + "playwright-login-progress", + PlaywrightLoginProgress { + message: message.to_string(), + }, + ); + } + } + } + "login" => { + if success { + if let Some(data) = response.get("data") { + code = data + .get("code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + state = data + .get("state") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } else { + let error = response + .get("data") + .and_then(|d| d.get("error")) + .and_then(|v| v.as_str()) + .unwrap_or("未知错误"); + + // 清理进程 + { + let mut process_guard = + PLAYWRIGHT_LOGIN_PROCESS.write().await; + *process_guard = None; + } + + return Err(format!("Playwright 登录失败: {}", error)); + } + break; + } + "error" => { + let error = response + .get("data") + .and_then(|d| d.get("error")) + .and_then(|v| v.as_str()) + .unwrap_or("未知错误"); + + // 清理进程 + { + let mut process_guard = PLAYWRIGHT_LOGIN_PROCESS.write().await; + *process_guard = None; + } + + return Err(format!("Playwright 错误: {}", error)); + } + _ => {} + } + } + Err(e) => { + tracing::warn!("[Playwright Login] 解析响应失败: {} - {}", e, trimmed); + } + } + } + Err(e) => { + // 清理进程 + { + let mut process_guard = PLAYWRIGHT_LOGIN_PROCESS.write().await; + *process_guard = None; + } + return Err(format!("读取响应失败: {}", e)); + } + } + } + + // 清理进程 + { + let mut process_guard = PLAYWRIGHT_LOGIN_PROCESS.write().await; + *process_guard = None; + } + + // 验证结果 + let auth_code = code.ok_or("未获取到授权码")?; + + // 验证 state + if let Some(returned_state) = &state { + if returned_state != &oauth_state { + return Err("状态参数不匹配,可能存在安全风险".to_string()); + } + } + + tracing::info!("[Playwright Login] 获取到授权码,开始交换 Token"); + + // 交换 Token + let client = reqwest::Client::new(); + let token_body = serde_json::json!({ + "code": auth_code, + "code_verifier": code_verifier, + "redirect_uri": redirect_uri + }); + + let token_res = client + .post(format!("{}/oauth/token", KIRO_AUTH_ENDPOINT)) + .header("Content-Type", "application/json") + .json(&token_body) + .send() + .await + .map_err(|e| format!("Token 交换请求失败: {}", e))?; + + if !token_res.status().is_success() { + let err_text = token_res.text().await.unwrap_or_default(); + return Err(format!("Token 交换失败: {}", err_text)); + } + + let token_data: serde_json::Value = token_res + .json() + .await + .map_err(|e| format!("解析 Token 响应失败: {}", e))?; + + tracing::info!("[Playwright Login] Token 交换成功!"); + + // 提取凭证 + let access_token = token_data["accessToken"].as_str().unwrap_or("").to_string(); + let refresh_token = token_data["refreshToken"] + .as_str() + .unwrap_or("") + .to_string(); + let profile_arn = token_data["profileArn"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expiresIn"].as_i64().unwrap_or(3600); + + // 创建凭证 JSON + let creds_json = serde_json::json!({ + "accessToken": access_token, + "refreshToken": refresh_token, + "profileArn": profile_arn, + "authMethod": "social", + "provider": provider_normalized, + "loginMethod": "playwright", + "expiresAt": chrono::Utc::now().timestamp() + expires_in + }); + + // 将凭证 JSON 转换为字符串并创建凭证文件 + let json_content = + serde_json::to_string_pretty(&creds_json).map_err(|e| format!("序列化凭证失败: {}", e))?; + + let stored_file_path = create_kiro_credential_from_json(&json_content)?; + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "kiro", + CredentialData::KiroOAuth { + creds_file_path: stored_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Playwright Login] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 取消 Kiro Playwright 登录 +/// +/// 终止正在进行的 Playwright 登录进程 +/// Requirements: 5.3 +#[tauri::command] +pub async fn cancel_kiro_playwright_login() -> Result { + tracing::info!("[Playwright Login] 取消登录"); + + let mut process_guard = PLAYWRIGHT_LOGIN_PROCESS.write().await; + + if let Some(mut child) = process_guard.take() { + // 尝试发送取消命令 + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + + let cancel_request = serde_json::json!({ + "action": "cancel" + }); + + if let Ok(request_str) = serde_json::to_string(&cancel_request) { + let _ = stdin.write_all(request_str.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + let _ = stdin.flush().await; + } + } + + // 等待一小段时间让进程优雅退出 + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // 强制终止进程 + let _ = child.kill().await; + + tracing::info!("[Playwright Login] 登录进程已终止"); + Ok(true) + } else { + tracing::info!("[Playwright Login] 没有正在进行的登录"); + Ok(false) + } +} + +/// 启动 Kiro Social Auth 回调服务器 +/// +/// 启动一个本地 HTTP 服务器来接收 OAuth 回调 +#[tauri::command] +pub async fn start_kiro_social_auth_callback_server(app: tauri::AppHandle) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + tracing::info!("[Kiro Social Auth] 启动回调服务器..."); + + // 尝试绑定端口 + let listener = TcpListener::bind("127.0.0.1:19823") + .await + .map_err(|e| format!("无法启动回调服务器: {}", e))?; + + tracing::info!("[Kiro Social Auth] 回调服务器已启动在 127.0.0.1:19823"); + + // 在后台处理连接 + let app_handle = app.clone(); + tokio::spawn(async move { + // 只处理一个连接 + if let Ok((mut socket, _)) = listener.accept().await { + let mut buffer = [0u8; 4096]; + if let Ok(n) = socket.read(&mut buffer).await { + let request = String::from_utf8_lossy(&buffer[..n]); + + // 解析请求获取 code 和 state + if let Some(path_line) = request.lines().next() { + if let Some(path) = path_line.split_whitespace().nth(1) { + if path.starts_with("/kiro-social-callback") { + // 解析查询参数 + let mut code = None; + let mut state = None; + + if let Some(query_start) = path.find('?') { + let query = &path[query_start + 1..]; + for param in query.split('&') { + let parts: Vec<&str> = param.splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "code" => { + code = Some( + urlencoding::decode(parts[1]) + .unwrap_or_default() + .to_string(), + ) + } + "state" => { + state = Some( + urlencoding::decode(parts[1]) + .unwrap_or_default() + .to_string(), + ) + } + _ => {} + } + } + } + } + + // 发送成功响应页面 + let html = r#" + + + + 登录成功 + + + +
+

✓ 登录成功

+

您可以关闭此窗口并返回应用

+
+ +"#; + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ); + + let _ = socket.write_all(response.as_bytes()).await; + + // 发送事件到前端 + if let (Some(code), Some(state)) = (code, state) { + let _ = app_handle.emit( + "kiro-social-auth-callback", + serde_json::json!({ + "code": code, + "state": state + }), + ); + } + } + } + } + } + } + }); + + Ok(true) +} + +// ============ Playwright 可用性检测测试 ============ + +#[cfg(test)] +mod playwright_tests { + use super::*; + + /// **Property 1: Playwright 可用性检测正确性** + /// **Validates: Requirements 2.2** + /// + /// *For any* 文件系统状态,Playwright 可用性检测函数应该: + /// - 当 Playwright 浏览器可执行文件存在时返回 `available: true` + /// - 当可执行文件不存在时返回 `available: false` + /// - 返回的 `browserPath` 应该是实际检测到的路径或 `None` + + #[test] + fn test_get_playwright_cache_dir_returns_valid_path() { + // Feature: playwright-fingerprint-login, Property 1: Playwright 可用性检测正确性 + // 测试缓存目录路径生成 + let cache_dir = get_playwright_cache_dir(); + + // 路径应该包含 ms-playwright + assert!( + cache_dir.to_string_lossy().contains("ms-playwright"), + "缓存目录应包含 ms-playwright: {:?}", + cache_dir + ); + + // 路径应该是绝对路径或相对于 home 目录 + #[cfg(target_os = "macos")] + assert!( + cache_dir.to_string_lossy().contains("Library/Caches"), + "macOS 缓存目录应在 Library/Caches 下: {:?}", + cache_dir + ); + + #[cfg(target_os = "windows")] + assert!( + cache_dir.to_string_lossy().contains("AppData\\Local"), + "Windows 缓存目录应在 AppData\\Local 下: {:?}", + cache_dir + ); + + #[cfg(target_os = "linux")] + assert!( + cache_dir.to_string_lossy().contains(".cache"), + "Linux 缓存目录应在 .cache 下: {:?}", + cache_dir + ); + } + + #[test] + fn test_get_playwright_browser_path_returns_none_when_not_installed() { + // Feature: playwright-fingerprint-login, Property 1: Playwright 可用性检测正确性 + // 当 Playwright 未安装时,应返回 None + // 注意:这个测试在 Playwright 已安装的环境中可能会失败 + // 我们主要测试函数不会 panic + let result = get_playwright_browser_path(); + + // 函数应该正常返回(不 panic) + // 结果可能是 Some 或 None,取决于环境 + match result { + Some(path) => { + // 如果找到了路径,验证路径格式 + assert!(!path.is_empty(), "浏览器路径不应为空"); + assert!( + path.contains("chromium") + || path.contains("Chromium") + || path.contains("chrome"), + "路径应包含 chromium/chrome: {}", + path + ); + } + None => { + // 未找到浏览器,这是预期的情况之一 + } + } + } + + #[test] + fn test_playwright_status_serialization() { + // Feature: playwright-fingerprint-login, Property 1: Playwright 可用性检测正确性 + // 测试 PlaywrightStatus 结构体的序列化 + + // 测试可用状态 + let available_status = PlaywrightStatus { + available: true, + browser_path: Some("/path/to/chromium".to_string()), + browser_source: Some("playwright".to_string()), + error: None, + }; + + let json = serde_json::to_string(&available_status).unwrap(); + assert!(json.contains("\"available\":true")); + assert!(json.contains("\"browser_path\":\"/path/to/chromium\"")); + + // 测试不可用状态 + let unavailable_status = PlaywrightStatus { + available: false, + browser_path: None, + browser_source: None, + error: Some("未安装".to_string()), + }; + + let json = serde_json::to_string(&unavailable_status).unwrap(); + assert!(json.contains("\"available\":false")); + assert!(json.contains("\"error\":\"未安装\"")); + } + + #[test] + fn test_playwright_status_deserialization() { + // Feature: playwright-fingerprint-login, Property 1: Playwright 可用性检测正确性 + // 测试 PlaywrightStatus 结构体的反序列化 + + let json = r#"{"available":true,"browser_path":"/test/path","error":null}"#; + let status: PlaywrightStatus = serde_json::from_str(json).unwrap(); + + assert!(status.available); + assert_eq!(status.browser_path, Some("/test/path".to_string())); + assert!(status.error.is_none()); + } + + #[test] + fn test_playwright_status_invariants() { + // Feature: playwright-fingerprint-login, Property 1: Playwright 可用性检测正确性 + // 测试状态不变量: + // - 当 available=true 时,browser_path 应该有值 + // - 当 available=false 时,error 应该有值 + + // 可用状态的不变量 + let available_status = PlaywrightStatus { + available: true, + browser_path: Some("/path".to_string()), + browser_source: Some("system".to_string()), + error: None, + }; + assert!( + available_status.available && available_status.browser_path.is_some(), + "可用状态应有 browser_path" + ); + + // 不可用状态的不变量 + let unavailable_status = PlaywrightStatus { + available: false, + browser_path: None, + browser_source: None, + error: Some("错误".to_string()), + }; + assert!( + !unavailable_status.available && unavailable_status.error.is_some(), + "不可用状态应有 error" + ); + } +} diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index ab7227b4a..b5c2a14c9 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -218,6 +218,7 @@ fn arb_config() -> impl Strategy { 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 { 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 { proxy_url: None, ampcode: crate::config::AmpConfig::default(), endpoint_providers: crate::config::EndpointProvidersConfig::default(), + minimize_to_tray: true, }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/converter/README.md b/src-tauri/src/converter/README.md new file mode 100644 index 000000000..f486da0d3 --- /dev/null +++ b/src-tauri/src/converter/README.md @@ -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 转换 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0254a3bc..8bf4aaa14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/providers/README.md b/src-tauri/src/providers/README.md new file mode 100644 index 000000000..c0a78a2a2 --- /dev/null +++ b/src-tauri/src/providers/README.md @@ -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` - 单元测试 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md new file mode 100644 index 000000000..cf492e7d9 --- /dev/null +++ b/src-tauri/src/services/README.md @@ -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` - 开关服务 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 932fe3163..233017bae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/components/README.md b/src/components/README.md new file mode 100644 index 000000000..0cf4e8d76 --- /dev/null +++ b/src/components/README.md @@ -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` - 侧边栏导航 + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index 3919181e4..38d2e380f 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.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({

- {providerType === "kiro" && - "默认路径: ~/.aws/sso/cache/kiro-auth-token.json"} {providerType === "gemini" && "默认路径: ~/.gemini/oauth_creds.json"}

@@ -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 ( + + ); + } + + // Kiro 文件模式 + if (providerType === "kiro" && kiroForm.mode === "file") { + return ( + + ); + } + // 其他类型 return ( + + {/* 指纹浏览器选项 */} + + + + ); +} diff --git a/src/components/provider-pool/credential-forms/KiroForm.tsx b/src/components/provider-pool/credential-forms/KiroForm.tsx new file mode 100644 index 000000000..f90cb865c --- /dev/null +++ b/src/components/provider-pool/credential-forms/KiroForm.tsx @@ -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("json"); + const [jsonContent, setJsonContent] = useState(""); + + // 浏览器模式状态 + const [browserMode, setBrowserMode] = useState("system"); + const [playwrightStatus, setPlaywrightStatus] = useState({ + available: false, + }); + const [playwrightChecking, setPlaywrightChecking] = useState(false); + + // 登录相关状态 + const [_loginType, setLoginType] = useState("builderid"); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [builderIdLoginData, setBuilderIdLoginData] = + useState(null); + const [copied, setCopied] = useState(false); + const pollIntervalRef = useRef | null>(null); + const unlistenRef = useRef<(() => void) | null>(null); + + // Playwright 错误状态(用于显示详细错误信息) + // Requirements: 5.1, 5.2, 5.4 + const [playwrightError, setPlaywrightError] = useState(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 = () => ( +
+ + + +
+ ); + + // 在线登录表单 + const renderLoginForm = () => ( +
+ {/* 浏览器模式选择器 */} + {!isLoggingIn && ( + + )} + + {/* Playwright 安装引导(当选择指纹浏览器但未安装时显示) */} + {!isLoggingIn && + browserMode === "playwright" && + !playwrightStatus.available && + !playwrightChecking && ( + + )} + + {/* Playwright 错误显示(当有错误且不在登录中时显示) */} + {/* Requirements: 5.1, 5.2, 5.4 */} + {!isLoggingIn && + playwrightError !== null && + browserMode === "playwright" && ( + + )} + + {/* 登录中状态 - Builder ID */} + {isLoggingIn && builderIdLoginData && ( +
+
+

+ 请在浏览器中完成登录,并输入以下代码: +

+
+ + {builderIdLoginData.userCode} + + +
+
+ + 等待授权中... +
+
+ +
+ + +
+
+ )} + + {/* 登录中状态 - Social Auth / Playwright */} + {isLoggingIn && !builderIdLoginData && ( +
+
+ +

+ {browserMode === "playwright" + ? "正在使用指纹浏览器登录..." + : "请在浏览器中完成登录..."} +

+

+ {browserMode === "playwright" + ? "请在弹出的浏览器窗口中完成登录" + : "登录完成后会自动返回"} +

+
+ + +
+ )} + + {/* 未登录状态 - 显示登录选项 */} + {!isLoggingIn && ( +
+ {/* 第一行:Google 和 GitHub */} +
+ {/* Google */} + + + {/* GitHub */} + +
+ + {/* 第二行:AWS Builder ID */} + +
+ )} +
+ ); + + // JSON 粘贴表单 + const renderJsonForm = () => ( +
+
+

+ 直接粘贴 Kiro 凭证 JSON 内容,无需选择文件。 +

+

+ 凭证 JSON 通常包含 accessToken、refreshToken 等字段。 +

+
+ +
+ +