feat: 添加初次安装引导向导

- 实现 5 步引导流程:欢迎 → 选择身份 → 选择插件 → 安装进度 → 完成
- 支持两种用户群体:程序员(预选 config-switch + flow-monitor)和普通用户
- 批量顺序安装选中的插件,显示实时进度
- 设置页添加"重新引导"按钮,可随时重新运行向导
- 重构浏览器拦截器和插件管理器代码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-05 02:03:42 +08:00
co-authored by Claude Opus 4.5
parent 14fb5aa323
commit 373ea7da8c
44 changed files with 4257 additions and 1131 deletions
+916
View File
@@ -0,0 +1,916 @@
# ProxyCast Connect - 中转商生态合作方案
> 版本: 1.0.0
> 日期: 2026-01-05
> 状态: Draft
---
## 一、背景与目标
### 1.1 背景
AI API 中转市场蓬勃发展,涌现出大量中转服务商:
- 基于 new-api、one-api 搭建的中转站
- ccr、claude-relay-service 等专用中转
- 各类 Token 分销平台
这些中转商面临的痛点:
1. **用户配置复杂** - 用户需要手动配置 base_url、协议等
2. **接入门槛高** - 不同客户端(Cursor、Claude Code)配置方式不同
3. **缺乏差异化** - 各家中转体验雷同
### 1.2 目标
**ProxyCast Connect** 是一套中转商生态合作方案:
1. **一键配置** - 用户点击链接即可完成 ProxyCast + Key 配置
2. **品牌展示** - 中转商在 ProxyCast 内有专属展示位
3. **双向引流** - 中转商推广 ProxyCast,ProxyCast 为中转商导流
4. **生态共赢** - 降低用户门槛,提升转化率
### 1.3 核心价值
| 角色 | 价值 |
|------|------|
| **中转商** | 用户转化率提升、品牌曝光、差异化竞争 |
| **用户** | 一键配置、开箱即用、统一管理多个中转 |
| **ProxyCast** | 用户增长、生态繁荣、市场占有率 |
---
## 二、产品方案
### 2.1 一键配置流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ProxyCast Connect 流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 中转商用户后台 │ │
│ │ │ │
│ │ 我的 API Key: sk-xxxxxxxxxxxxxxxx [复制] │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 🚀 快速接入 │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ ProxyCast │ │ Cursor │ │Claude Code │ │ │ │
│ │ │ │ [一键配置] │ │ [查看教程] │ │ [查看教程] │ │ │ │
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ 用户点击 [一键配置] │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 浏览器打开 proxycast:// 协议 │ │
│ │ │ │
│ │ proxycast://connect? │ │
│ │ relay=xxx-relay& │ │
│ │ key=sk-xxxxxxxx& │ │
│ │ name=我的Key │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ├─── 已安装 ProxyCast ───────────────────────────────────────┐ │
│ │ │ │
│ │ ProxyCast 打开,显示确认弹窗 │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ 🔗 添加 API Key │ │ │
│ │ │ │ │ │
│ │ │ 来源: XXX中转站 │ │ │
│ │ │ Key: sk-xxxx...xxxx │ │ │
│ │ │ 名称: 我的Key │ │ │
│ │ │ │ │ │
│ │ │ [取消] [确认添加] │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 配置完成,可以使用 ✓ │ │
│ │ │ │
│ └─── 未安装 ProxyCast ───────────────────────────────────────┐ │
│ │ │
│ 跳转 proxycast.dev/download?connect=... │ │
│ │ │ │
│ ▼ │ │
│ 下载并安装 ProxyCast │ │
│ │ │ │
│ ▼ │ │
│ 首次启动,自动读取参数并配置 │ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.2 Deep Link 协议设计
#### 2.2.1 协议格式
```
proxycast://connect?relay={relay_id}&key={api_key}&name={key_name}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `relay` | ✅ | 中转商 ID(需在 ProxyCast 注册) |
| `key` | ✅ | API Key |
| `name` | ❌ | Key 名称(默认使用中转商名称) |
| `ref` | ❌ | 推广码(用于统计) |
#### 2.2.2 示例
```
# 基础用法
proxycast://connect?relay=openrouter&key=sk-or-v1-xxxx
# 带名称
proxycast://connect?relay=siliconflow&key=sk-xxxx&name=硅基流动-主账号
# 带推广码
proxycast://connect?relay=myrelay&key=sk-xxxx&ref=promo2024
```
#### 2.2.3 Web 中转页面
对于不支持自定义协议的场景,提供 Web 中转:
```
https://proxycast.dev/connect?relay=xxx&key=sk-xxxx
```
页面逻辑:
1. 尝试唤起 `proxycast://` 协议
2. 如果 3 秒内未成功,显示下载引导
3. 下载链接带上 connect 参数,安装后首次启动自动配置
### 2.3 中转商注册(开源方式)
ProxyCast 是开源软件,中转商通过 **GitHub PR** 方式注册,无需网站注册。
#### 2.3.1 注册仓库
中转商注册信息统一管理在独立仓库:
```
https://github.com/AiClientProxy/transit-service-provider
```
#### 2.3.2 注册流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 中转商注册流程(GitHub PR) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: Fork 仓库 │
│ ───────────────── │
│ Fork https://github.com/AiClientProxy/transit-service-provider │
│ │ │
│ ▼ │
│ Step 2: 创建配置文件 │
│ ───────────────── │
│ 在 providers/ 目录下创建 {your-id}.json │
│ 参考 providers/_example.json 模板 │
│ │ │
│ ▼ │
│ Step 3: 提交 PR │
│ ───────────────── │
│ 提交 Pull Request 到主仓库 │
│ │ │
│ ▼ │
│ Step 4: 自动化检查 │
│ ───────────────── │
│ GitHub Actions 自动验证: │
│ ├── JSON Schema 验证 │
│ ├── Logo 图片可访问性 │
│ └── API 地址可访问性 │
│ │ │
│ ▼ │
│ Step 5: 社区审核 │
│ ───────────────── │
│ 维护者审核并合并 PR │
│ │ │
│ ▼ │
│ Step 6: 自动发布 │
│ ───────────────── │
│ 合并后自动构建 registry.json │
│ ProxyCast 客户端定期同步更新 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
#### 2.3.3 配置文件格式
在 `providers/` 目录下创建 `{your-id}.json`:
```json
{
"id": "myrelay",
"name": "我的中转站",
"description": "稳定、便宜、快速的 AI API 中转服务",
"branding": {
"logo": "https://myrelay.com/logo.png",
"color": "#6366f1"
},
"links": {
"homepage": "https://myrelay.com",
"register": "https://myrelay.com/register",
"recharge": "https://myrelay.com/recharge",
"docs": "https://docs.myrelay.com",
"status": "https://status.myrelay.com"
},
"api": {
"base_url": "https://api.myrelay.com/v1",
"protocol": "openai",
"auth_header": "Authorization",
"auth_prefix": "Bearer "
},
"contact": {
"email": "support@myrelay.com",
"telegram": "@myrelay"
},
"features": {
"streaming": true,
"models_endpoint": true
}
}
```
#### 2.3.4 字段说明
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | ✅ | 唯一标识,小写字母、数字、连字符 |
| `name` | ✅ | 显示名称 |
| `description` | ✅ | 简短描述,≤100 字 |
| `branding.logo` | ✅ | Logo URL,256x256 PNG |
| `branding.color` | ❌ | 主题色,默认 `#6366f1` |
| `links.homepage` | ✅ | 官网地址 |
| `links.register` | ❌ | 注册页面 |
| `links.recharge` | ❌ | 充值页面 |
| `links.docs` | ❌ | 文档地址 |
| `links.status` | ❌ | 状态页面 |
| `api.base_url` | ✅ | API 地址(必须 HTTPS) |
| `api.protocol` | ✅ | 协议:`openai` 或 `anthropic` |
| `contact.email` | ✅ | 联系邮箱 |
#### 2.3.5 审核标准
PR 合并前需满足:
- [ ] JSON Schema 验证通过
- [ ] 文件名与 `id` 字段一致
- [ ] Logo 图片可访问(256x256 PNG)
- [ ] API 地址使用 HTTPS
- [ ] 官网可访问
- [ ] 联系方式有效
#### 2.3.6 仓库结构
```
transit-service-provider/
├── README.md # 注册指南
├── providers/ # 中转商配置目录
│ ├── _example.json # 示例配置(不会被加载)
│ ├── openrouter.json # OpenRouter
│ ├── siliconflow.json # 硅基流动
│ └── ... # 其他中转商
├── schema/
│ └── provider.schema.json # JSON Schema
├── scripts/
│ ├── validate.js # 验证脚本
│ ├── check-api.js # API 检查脚本
│ └── build-registry.js # 构建注册表
├── dist/
│ └── registry.json # 构建产物(自动生成)
└── .github/
├── workflows/
│ └── validate.yml # PR 自动验证
└── PULL_REQUEST_TEMPLATE.md
```
#### 2.3.7 注册表同步
ProxyCast 客户端通过以下方式获取中转商列表:
```
https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json
```
客户端定期(每 24 小时)同步更新,也可手动刷新
### 2.4 中转商品牌展示
#### 2.4.1 扩展市场展示
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 扩展市场 > 中转服务 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ [Logo] │ │ [Logo] │ │ [Logo] │ │ [Logo] │ │
│ │ OpenRouter │ │ 硅基流动 │ │ XXX中转 │ │ YYY中转 │ │
│ │ ✓ 官方认证 │ │ ✓ 官方认证 │ │ │ │ │ │
│ │ ⭐ 4.9 │ │ ⭐ 4.8 │ │ ⭐ 4.5 │ │ ⭐ 4.3 │ │
│ │ [安装] │ │ [安装] │ │ [安装] │ │ [安装] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
#### 2.4.2 已安装中转商页面
```
┌─────────────────────────────────────────────────────────────────────────┐
│ XXX中转站 [设置] [×]│
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [Logo] XXX中转站 │ │
│ │ 稳定、便宜、快速的 AI API 中转服务 │ │
│ │ │ │
│ │ [官网] [充值] [文档] [状态] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 我的 API Keys │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 主账号 sk-xxxx...xxxx │ │ │
│ │ │ ● 正常 [设为默认] [删除] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 备用账号 sk-yyyy...yyyy │ │ │
│ │ │ ● 正常 [设为默认] [删除] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [+ 添加 Key] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 📢 公告 │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 🎉 新用户注册送 $5 额度! │ │
│ │ 📅 2026-01-01 ~ 2026-01-31 │ │
│ │ [立即注册] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 三、技术实现
### 3.1 Deep Link 注册 (Tauri)
```rust
// src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
// 注册自定义协议
.register_uri_scheme_protocol("proxycast", |app, request| {
handle_deep_link(app, request)
})
// ...
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn handle_deep_link(app: &AppHandle, request: &HttpRequest) -> Result<Response> {
let url = request.uri();
// 解析 proxycast://connect?relay=xxx&key=xxx
if url.path() == "connect" {
let params = parse_query_params(url.query());
// 发送事件到前端
app.emit_all("deep-link-connect", ConnectPayload {
relay: params.get("relay"),
key: params.get("key"),
name: params.get("name"),
ref_code: params.get("ref"),
})?;
}
Ok(Response::default())
}
```
### 3.2 前端处理
```typescript
// src/hooks/useDeepLink.ts
import { listen } from '@tauri-apps/api/event';
import { useEffect } from 'react';
interface ConnectPayload {
relay: string;
key: string;
name?: string;
ref?: string;
}
export function useDeepLink() {
useEffect(() => {
const unlisten = listen<ConnectPayload>('deep-link-connect', (event) => {
const { relay, key, name, ref } = event.payload;
// 显示确认弹窗
showConnectConfirmDialog({
relay,
key,
name,
ref,
onConfirm: () => addApiKey(relay, key, name),
onCancel: () => {},
});
});
return () => { unlisten.then(fn => fn()); };
}, []);
}
```
### 3.3 确认弹窗组件
```tsx
// src/components/ConnectConfirmDialog.tsx
interface Props {
relay: RelayInfo;
key: string;
name?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConnectConfirmDialog({ relay, key, name, onConfirm, onCancel }: Props) {
const maskedKey = maskApiKey(key); // sk-xxxx...xxxx
return (
<Dialog open onOpenChange={onCancel}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="w-5 h-5" />
添加 API Key
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<img src={relay.logo} className="w-10 h-10 rounded" />
<div>
<div className="font-medium">{relay.name}</div>
<div className="text-sm text-muted-foreground">
{relay.description}
</div>
</div>
</div>
<div className="bg-muted p-3 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Key</span>
<span className="font-mono">{maskedKey}</span>
</div>
{name && (
<div className="flex justify-between">
<span className="text-muted-foreground">名称</span>
<span>{name}</span>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
确认后,此 Key 将被添加到 ProxyCast,您可以立即开始使用。
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>取消</Button>
<Button onClick={onConfirm}>确认添加</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
### 3.4 中转商注册表(从 GitHub 加载)
```rust
// src-tauri/src/relay/registry.rs
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// 注册表数据结构(对应 registry.json)
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayRegistry {
pub version: String,
pub updated_at: String,
pub providers: Vec<RelayInfo>,
#[serde(skip)]
index: HashMap<String, usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayInfo {
pub id: String,
pub name: String,
pub description: String,
pub branding: RelayBranding,
pub links: RelayLinks,
pub api: RelayApi,
pub contact: RelayContact,
#[serde(default)]
pub features: RelayFeatures,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayBranding {
pub logo: String,
#[serde(default = "default_color")]
pub color: String,
}
fn default_color() -> String { "#6366f1".to_string() }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayLinks {
pub homepage: String,
pub register: Option<String>,
pub recharge: Option<String>,
pub docs: Option<String>,
pub status: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayApi {
pub base_url: String,
pub protocol: String,
#[serde(default = "default_auth_header")]
pub auth_header: String,
#[serde(default = "default_auth_prefix")]
pub auth_prefix: String,
}
fn default_auth_header() -> String { "Authorization".to_string() }
fn default_auth_prefix() -> String { "Bearer ".to_string() }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelayContact {
pub email: String,
pub telegram: Option<String>,
pub discord: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RelayFeatures {
#[serde(default = "default_true")]
pub streaming: bool,
#[serde(default)]
pub models_endpoint: bool,
}
fn default_true() -> bool { true }
impl RelayRegistry {
/// 注册表 URL(从 GitHub 加载)
const REGISTRY_URL: &'static str =
"https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json";
/// 从 GitHub 加载中转商列表
pub async fn load_from_github(&mut self) -> Result<()> {
let response = reqwest::get(Self::REGISTRY_URL).await?;
let mut registry: RelayRegistry = response.json().await?;
// 构建索引
for (i, provider) in registry.providers.iter().enumerate() {
registry.index.insert(provider.id.clone(), i);
}
*self = registry;
Ok(())
}
/// 获取中转商信息
pub fn get(&self, id: &str) -> Option<&RelayInfo> {
self.index.get(id).map(|&i| &self.providers[i])
}
/// 验证 relay_id 是否有效
pub fn is_valid(&self, id: &str) -> bool {
self.index.contains_key(id)
}
/// 获取所有中转商
pub fn list(&self) -> &[RelayInfo] {
&self.providers
}
}
```
---
## 四、中转商接入指南
### 4.1 接入流程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 中转商接入流程(GitHub PR) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: Fork 仓库 │
│ ───────────────── │
│ Fork https://github.com/AiClientProxy/transit-service-provider │
│ │ │
│ ▼ │
│ Step 2: 创建配置文件 │
│ ───────────────── │
│ 在 providers/ 目录下创建 {your-id}.json │
│ 参考 providers/_example.json 模板 │
│ │ │
│ ▼ │
│ Step 3: 提交 PR │
│ ───────────────── │
│ 提交 Pull Request,填写 PR 模板 │
│ │ │
│ ▼ │
│ Step 4: 等待审核 │
│ ───────────────── │
│ GitHub Actions 自动验证 + 维护者审核 │
│ 通常 1-3 个工作日 │
│ │ │
│ ▼ │
│ Step 5: 合并上线 │
│ ───────────────── │
│ PR 合并后自动构建 registry.json │
│ ProxyCast 客户端自动同步 │
│ │ │
│ ▼ │
│ Step 6: 集成使用 │
│ ───────────────── │
│ 在用户后台添加"一键配置"按钮 │
│ 用户可以一键配置 ProxyCast │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 4.2 集成方式
#### 方式一:直接链接
最简单的方式,直接在页面放置链接:
```html
<a href="proxycast://connect?relay=myrelay&key=USER_API_KEY">
一键配置 ProxyCast
</a>
```
#### 方式二:JavaScript SDK
提供更好的用户体验:
```html
<script src="https://proxycast.dev/sdk/connect.js"></script>
<button onclick="ProxyCast.connect({ relay: 'myrelay', key: userApiKey })">
一键配置 ProxyCast
</button>
```
SDK 功能:
- 自动检测 ProxyCast 是否安装
- 未安装时显示下载引导
- 支持回调函数
```javascript
ProxyCast.connect({
relay: 'myrelay',
key: userApiKey,
name: '我的Key',
onSuccess: () => {
showToast('配置成功!');
},
onNotInstalled: () => {
// 显示自定义的下载引导
showDownloadModal();
}
});
```
#### 方式三:配置文件下载
生成 `.proxycast` 配置文件供用户下载:
```javascript
function downloadConfig(apiKey) {
const config = {
relay: 'myrelay',
key: apiKey,
name: '我的Key'
};
const blob = new Blob([JSON.stringify(config)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'myrelay.proxycast';
a.click();
}
```
用户双击 `.proxycast` 文件,ProxyCast 自动打开并导入配置。
### 4.3 品牌素材要求
| 素材 | 规格 | 说明 |
|------|------|------|
| Logo | 256x256 PNG | 透明背景,正方形 |
| 主题色 | HEX 色值 | 用于 UI 强调色 |
| 简介 | ≤50 字 | 一句话描述 |
| 详细描述 | ≤200 字 | 详细介绍 |
### 4.4 API 要求
中转商的 API 需要满足:
| 要求 | 说明 |
|------|------|
| 协议兼容 | OpenAI 或 Anthropic 协议 |
| HTTPS | 必须使用 HTTPS |
| 模型列表 | 提供 `/models` 端点(可选) |
| 稳定性 | 99% 以上可用性 |
---
## 五、安全设计
### 5.1 Deep Link 安全
| 风险 | 防护措施 |
|------|---------|
| 恶意链接 | relay_id 必须在注册表中存在 |
| Key 泄露 | 确认弹窗显示脱敏 Key,用户确认后才添加 |
| 钓鱼攻击 | 显示中转商完整信息,用户可核实 |
### 5.2 确认弹窗
所有通过 Deep Link 添加的 Key 必须经过用户确认:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ ⚠️ 安全提示 │
│ │
│ 您正在添加来自 [XXX中转站] 的 API Key │
│ │
│ 请确认: │
│ 1. 您确实在 XXX中转站 申请了此 Key │
│ 2. 您信任 XXX中转站 的服务 │
│ │
│ Key: sk-xxxx...xxxx │
│ │
│ [取消] [我确认,添加此 Key] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 6.3 中转商审核
| 审核项 | 说明 |
|--------|------|
| 身份核实 | 验证中转商运营者身份 |
| API 测试 | 测试 API 可用性和兼容性 |
| 安全检查 | 检查是否有恶意行为历史 |
| 持续监控 | 定期检查服务状态 |
---
## 七、商业模式
### 7.1 免费服务
基础功能对中转商免费:
- 注册和审核
- Deep Link 功能
- 扩展市场展示
- 基础统计
### 7.2 增值服务(可选)
| 服务 | 说明 | 定价 |
|------|------|------|
| 官方认证 | 显示认证徽章,优先展示 | 待定 |
| 推荐位 | 扩展市场首页推荐 | 待定 |
| 高级统计 | 详细用户行为分析 | 待定 |
| 定制 UI | 自定义品牌页面 | 待定 |
### 7.3 合作模式
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 合作共赢 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 中转商 ProxyCast │
│ ────── ───────── │
│ │
│ 推广 ProxyCast ──────────▶ 用户增长 │
│ │
│ 用户转化率提升 ◀────────── 一键配置功能 │
│ │
│ 品牌曝光 ◀────────── 扩展市场展示 │
│ │
│ 用户粘性 ◀────────── 统一管理体验 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 六、实施计划
### 6.1 Phase 1: MVP (2 周)
| 任务 | 说明 |
|------|------|
| Deep Link 协议 | 实现 `proxycast://connect` |
| 确认弹窗 | 安全确认 UI |
| 注册表加载 | 从 GitHub 加载 registry.json |
| transit-service-provider 仓库 | 初始化仓库结构和 CI |
### 6.2 Phase 2: 完善 (2 周)
| 任务 | 说明 |
|------|------|
| Web 中转页 | proxycast.dev/connect |
| JS SDK | 前端集成 SDK |
| 扩展市场展示 | 中转商品牌页 |
| .proxycast 文件支持 | 配置文件导入 |
### 6.3 Phase 3: 生态 (持续)
| 任务 | 说明 |
|------|------|
| 中转商招募 | 邀请主流中转商提交 PR |
| 文档完善 | 接入指南、最佳实践 |
| 社区运营 | GitHub Discussions |
---
## 七、总结
### 7.1 核心价值
**ProxyCast Connect** 通过一键配置功能,实现:
1. **用户** - 零配置接入,开箱即用
2. **中转商** - 用户转化率提升,品牌曝光
3. **ProxyCast** - 用户增长,生态繁荣
### 7.2 开源优势
| 优势 | 说明 |
|------|------|
| 透明 | 所有中转商信息公开可查 |
| 去中心化 | 无需依赖 ProxyCast 官方服务器 |
| 社区驱动 | 任何人都可以贡献和审核 |
| 低成本 | 无需维护注册后台 |
### 7.3 关键指标
| 指标 | 目标 |
|------|------|
| 接入中转商数量 | 20+ |
| GitHub Stars | 1,000+ |
| 一键配置转化率 | 80%+ |
### 7.4 相关仓库
| 仓库 | 说明 |
|------|------|
| [proxycast](https://github.com/AiClientProxy/proxycast) | ProxyCast 主项目 |
| [transit-service-provider](https://github.com/AiClientProxy/transit-service-provider) | 中转商注册仓库 |
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.28.0",
"version": "0.29.0",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3674,7 +3674,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.28.0"
version = "0.29.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.28.0"
version = "0.29.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+20 -7
View File
@@ -7,7 +7,6 @@ use tokio::sync::RwLock;
use crate::agent::NativeAgentState;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::browser_interceptor_cmd::BrowserInterceptorState;
use crate::commands::flow_monitor_cmd::{
BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState,
FlowMonitorState, FlowQueryServiceState, FlowReplayerState, QuickFilterManagerState,
@@ -128,7 +127,6 @@ pub struct AppStates {
pub bookmark_manager: BookmarkManagerState,
pub enhanced_stats_service: EnhancedStatsServiceState,
pub batch_operations: BatchOperationsState,
pub browser_interceptor: BrowserInterceptorState,
pub native_agent: NativeAgentState,
pub oauth_plugin_manager: crate::commands::oauth_plugin_cmd::OAuthPluginManagerState,
pub orchestrator: OrchestratorState,
@@ -183,7 +181,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 遥测系统
let (telemetry_state, shared_stats, shared_tokens, shared_logger) = init_telemetry(config)?;
// Flow Monitor 系统
// Flow Monitor 系统(根据插件安装状态启用/禁用)
let (
flow_monitor_state,
flow_query_service_state,
@@ -196,10 +194,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
batch_operations_state,
flow_monitor_arc,
flow_interceptor_arc,
) = init_flow_monitor(&provider_pool_service_state, &db)?;
) = init_flow_monitor(&provider_pool_service_state, &db, &plugin_installer_state)?;
// 其他状态
let browser_interceptor_state = BrowserInterceptorState::default();
let native_agent_state = NativeAgentState::new();
let oauth_plugin_manager_state =
crate::commands::oauth_plugin_cmd::OAuthPluginManagerState::with_defaults();
@@ -236,7 +233,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
bookmark_manager: bookmark_manager_state,
enhanced_stats_service: enhanced_stats_service_state,
batch_operations: batch_operations_state,
browser_interceptor: browser_interceptor_state,
native_agent: native_agent_state,
oauth_plugin_manager: oauth_plugin_manager_state,
orchestrator: orchestrator_state,
@@ -327,10 +323,13 @@ fn init_telemetry(
}
/// 初始化 Flow Monitor 系统
///
/// 如果 flow-monitor 插件已安装,则启用监控功能;否则禁用。
#[allow(clippy::type_complexity)]
fn init_flow_monitor(
provider_pool_service_state: &ProviderPoolServiceState,
db: &DbConnection,
plugin_installer_state: &PluginInstallerState,
) -> Result<
(
FlowMonitorState,
@@ -347,7 +346,21 @@ fn init_flow_monitor(
),
String,
> {
let flow_monitor_config = FlowMonitorConfig::default();
// 检查 flow-monitor 插件是否已安装
let is_plugin_installed = {
let installer = plugin_installer_state.0.blocking_read();
installer.is_installed("flow-monitor").unwrap_or(false)
};
// 根据插件安装状态设置 enabled
let mut flow_monitor_config = FlowMonitorConfig::default();
flow_monitor_config.enabled = is_plugin_installed;
if is_plugin_installed {
tracing::info!("[启动] flow-monitor 插件已安装,启用 Flow 监控");
} else {
tracing::info!("[启动] flow-monitor 插件未安装,禁用 Flow 监控");
}
// 初始化文件存储
let data_dir = dirs::data_dir()
-39
View File
@@ -67,7 +67,6 @@ pub fn run() {
bookmark_manager: bookmark_manager_state,
enhanced_stats_service: enhanced_stats_service_state,
batch_operations: batch_operations_state,
browser_interceptor: browser_interceptor_state,
native_agent: native_agent_state,
oauth_plugin_manager: oauth_plugin_manager_state,
orchestrator: orchestrator_state,
@@ -101,21 +100,6 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
// 处理传入的 URL 参数
for arg in args.iter().skip(1) {
// 跳过第一个参数(程序路径)
if arg.starts_with("http://") || arg.starts_with("https://") {
tracing::info!("[单实例] 收到 URL: {}", arg);
#[cfg(target_os = "macos")]
{
crate::browser_interceptor::platform::macos::handle_deep_link_url(
arg.clone(),
);
}
}
}
// 将窗口带到前台
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
@@ -123,12 +107,6 @@ pub fn run() {
}
}));
// 添加 Deep Link 插件(用于浏览器拦截)
#[cfg(target_os = "macos")]
{
builder = builder.plugin(tauri_plugin_deep_link::init());
}
builder
.manage(state)
.manage(logs)
@@ -153,7 +131,6 @@ pub fn run() {
.manage(bookmark_manager_state)
.manage(enhanced_stats_service_state)
.manage(batch_operations_state)
.manage(browser_interceptor_state)
.manage(native_agent_state)
.manage(oauth_plugin_manager_state)
.manage(orchestrator_state)
@@ -183,21 +160,6 @@ pub fn run() {
}
})
.setup(move |app| {
// 设置 deep-link 事件监听(用于浏览器拦截)
#[cfg(target_os = "macos")]
{
use tauri_plugin_deep_link::DeepLinkExt;
let _listener_id = app.deep_link().on_open_url(|event| {
for url in event.urls() {
tracing::info!("[Deep Link] 收到 URL: {}", url);
crate::browser_interceptor::platform::macos::handle_deep_link_url(
url.to_string(),
);
}
});
tracing::info!("[启动] Deep Link 事件监听已设置");
}
// 初始化托盘管理器
// Requirements 1.4: 应用启动时显示停止状态图标
match TrayManager::new(app.handle()) {
@@ -768,7 +730,6 @@ pub fn run() {
commands::browser_interceptor_cmd::validate_browser_interceptor_config,
commands::browser_interceptor_cmd::is_browser_interceptor_running,
commands::browser_interceptor_cmd::get_browser_interceptor_statistics,
// Browser Interceptor notification commands
commands::browser_interceptor_cmd::show_notification,
commands::browser_interceptor_cmd::show_url_intercept_notification,
commands::browser_interceptor_cmd::show_status_notification,
-18
View File
@@ -6,7 +6,6 @@ use std::sync::Arc;
use tauri::{App, Manager};
use crate::agent::NativeAgentState;
use crate::commands::browser_interceptor_cmd::BrowserInterceptorState;
use crate::commands::oauth_plugin_cmd::OAuthPluginManagerState;
use crate::database;
use crate::flow_monitor::FlowInterceptor;
@@ -33,19 +32,6 @@ pub fn setup_app(
flow_monitor: Arc<crate::flow_monitor::FlowMonitor>,
flow_interceptor: Arc<FlowInterceptor>,
) -> Result<(), Box<dyn std::error::Error>> {
// 设置 deep-link 事件监听(用于浏览器拦截)
#[cfg(target_os = "macos")]
{
use tauri_plugin_deep_link::DeepLinkExt;
let _listener_id = app.deep_link().on_open_url(|event| {
for url in event.urls() {
tracing::info!("[Deep Link] 收到 URL: {}", url);
crate::browser_interceptor::platform::macos::handle_deep_link_url(url.to_string());
}
});
tracing::info!("[启动] Deep Link 事件监听已设置");
}
// 初始化托盘管理器
match TrayManager::new(app.handle()) {
Ok(tray_manager) => {
@@ -62,10 +48,6 @@ pub fn setup_app(
}
}
// 初始化 BrowserInterceptorState
let browser_interceptor_state = BrowserInterceptorState::default();
app.manage(browser_interceptor_state);
// 初始化 NativeAgentState
let native_agent_state = NativeAgentState::new();
app.manage(native_agent_state);
+19 -1
View File
@@ -214,11 +214,29 @@ pub struct FlowMonitorStates {
}
/// 初始化 Flow Monitor 状态
///
/// 如果 flow-monitor 插件已安装,则启用监控功能;否则禁用。
pub fn init_flow_monitor_states(
provider_pool_service: Arc<ProviderPoolService>,
db: database::DbConnection,
plugin_installer_state: &PluginInstallerState,
) -> FlowMonitorStates {
let flow_monitor_config = FlowMonitorConfig::default();
// 检查 flow-monitor 插件是否已安装
let is_plugin_installed = {
let installer = plugin_installer_state.0.blocking_read();
installer.is_installed("flow-monitor").unwrap_or(false)
};
// 根据插件安装状态设置 enabled
let mut flow_monitor_config = FlowMonitorConfig::default();
flow_monitor_config.enabled = is_plugin_installed;
if is_plugin_installed {
tracing::info!("[启动] flow-monitor 插件已安装,启用 Flow 监控");
} else {
tracing::info!("[启动] flow-monitor 插件未安装,禁用 Flow 监控");
}
let flow_file_store = init_flow_file_store();
let flow_monitor = Arc::new(FlowMonitor::new(
+1 -2
View File
@@ -155,7 +155,6 @@ impl BrowserInterceptorConfig {
self.target_processes.iter().any(|pattern| {
// 支持简单的通配符匹配
if pattern.contains('*') {
// TODO: 实现更复杂的模式匹配
process_name.contains(&pattern.replace('*', ""))
} else {
process_name.eq_ignore_ascii_case(pattern)
@@ -193,7 +192,7 @@ impl BrowserInterceptorConfig {
}
/// 验证配置的有效性
pub fn validate(&self) -> Result<(), String> {
pub fn validate(&self) -> std::result::Result<(), String> {
if self.target_processes.is_empty() {
return Err("目标进程列表不能为空".to_string());
}
@@ -370,6 +370,7 @@ impl BrowserInterceptor {
}
/// 复制文本到剪贴板
#[cfg(feature = "tauri-app")]
async fn copy_to_clipboard(&self, text: &str) -> Result<()> {
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
@@ -389,6 +390,12 @@ impl BrowserInterceptor {
}
}
#[cfg(not(feature = "tauri-app"))]
async fn copy_to_clipboard(&self, _text: &str) -> Result<()> {
tracing::warn!("剪贴板功能需要 tauri-app feature");
Ok(())
}
/// 启动指纹浏览器
async fn launch_fingerprint_browser(
&self,
+1 -1
View File
@@ -24,7 +24,7 @@ pub use config::{BrowserInterceptorConfig, InterceptedUrl, InterceptorState};
pub use interceptor::BrowserInterceptor;
pub use notification_service::NotificationService;
pub use state_manager::StateManager;
pub use url_manager::UrlManager;
pub use url_manager::{UrlManager, UrlStatistics};
use serde::{Deserialize, Serialize};
use std::error::Error;
@@ -173,9 +173,6 @@ impl NotificationService {
// 发送系统通知
self.send_system_notification(&notification).await?;
// 发送到前端(通过事件系统)
self.send_frontend_notification(&notification).await?;
Ok(())
}
@@ -199,21 +196,9 @@ impl NotificationService {
Ok(())
}
/// 发送到前端
async fn send_frontend_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 通过 Tauri 事件系统发送到前端
// tauri::emit_all("browser-interceptor-notification", notification)
// .map_err(|e| BrowserInterceptorError::NotificationError(format!("发送前端通知失败: {}", e)))?;
tracing::debug!("前端通知已发送: {}", notification.id);
Ok(())
}
/// Windows 系统通知
#[cfg(target_os = "windows")]
async fn send_windows_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 Windows Toast 通知 API
// 可以使用 winrt-notification 或 windows-rs crate
tracing::debug!("Windows 通知: {}", notification.title);
Ok(())
}
@@ -221,8 +206,6 @@ impl NotificationService {
/// macOS 系统通知
#[cfg(target_os = "macos")]
async fn send_macos_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 macOS 通知中心
// 可以使用 mac-notification-sys crate
tracing::debug!("macOS 通知: {}", notification.title);
Ok(())
}
@@ -230,8 +213,6 @@ impl NotificationService {
/// Linux 系统通知
#[cfg(target_os = "linux")]
async fn send_linux_notification(&self, notification: &NotificationMessage) -> Result<()> {
// TODO: 使用 libnotify 或 D-Bus
// 可以使用 notify-rust crate
tracing::debug!("Linux 通知: {}", notification.title);
Ok(())
}
@@ -251,57 +232,3 @@ impl Default for NotificationService {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_notification_message_creation() {
let notification = NotificationMessage::new(
NotificationType::UrlIntercepted,
"Test Title".to_string(),
"Test Message".to_string(),
)
.with_url("https://example.com".to_string())
.with_source_process("test_app".to_string());
assert_eq!(notification.title, "Test Title");
assert_eq!(notification.message, "Test Message");
assert_eq!(notification.url, Some("https://example.com".to_string()));
assert_eq!(notification.source_process, Some("test_app".to_string()));
assert!(notification.auto_dismiss_after.is_some());
}
#[test]
fn test_truncate_url() {
let service = NotificationService::new();
let short_url = "https://example.com";
assert_eq!(service.truncate_url(short_url, 100), short_url);
let long_url = "https://example.com/very/long/path/that/exceeds/the/maximum/length/limit";
let truncated = service.truncate_url(long_url, 20);
assert_eq!(truncated.len(), 20);
assert!(truncated.ends_with("..."));
}
#[tokio::test]
async fn test_notify_url_intercepted() {
let service = NotificationService::new();
let intercepted_url = InterceptedUrl {
id: "test-id".to_string(),
url: "https://accounts.google.com/oauth/authorize".to_string(),
source_process: "kiro".to_string(),
timestamp: Utc::now(),
copied: false,
opened_in_browser: false,
dismissed: false,
};
// 这个测试主要验证函数不会 panic
let result = service.notify_url_intercepted(&intercepted_url).await;
assert!(result.is_ok());
}
}
@@ -62,12 +62,12 @@ impl MacOSInterceptor {
}
}
// 3. 将 ProxyCast 设置为默认浏览器
// 3. 将应用设置为默认浏览器
self.set_as_default_browser().await?;
self.running = true;
tracing::info!("macOS 浏览器拦截器已启动");
tracing::info!("提示:现在所有 http/https URL 打开请求都会被 ProxyCast 拦截");
tracing::info!("提示:现在所有 http/https URL 打开请求都会被拦截");
Ok(())
}
@@ -95,68 +95,31 @@ if let handler = LSCopyDefaultHandlerForURLScheme("https" as CFString) {
if output.status.success() {
let bundle_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !bundle_id.is_empty() && !bundle_id.contains("proxycast") {
if !bundle_id.is_empty() && !bundle_id.contains("browser-interception") {
tracing::info!("检测到当前默认浏览器: {}", bundle_id);
return Some(bundle_id);
}
// 如果当前是 ProxyCast,说明之前已设置过,需要检测用户常用的浏览器
if bundle_id.contains("proxycast") {
tracing::info!("当前默认浏览器是 ProxyCast,尝试检测用户常用浏览器");
// 如果当前是本应用,说明之前已设置过,需要检测用户常用的浏览器
if bundle_id.contains("browser-interception") {
tracing::info!("当前默认浏览器是本应用,尝试检测用户常用浏览器");
return self.detect_installed_browser().await;
}
}
// 备选方法:使用 duti 查询
let duti_output = Command::new("duti").args(["-x", "https"]).output().ok();
if let Some(output) = duti_output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// duti -x 输出格式:第三行是 bundle id
for line in stdout.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("com.") || trimmed.starts_with("org."))
&& !trimmed.contains("proxycast")
{
tracing::info!("通过 duti 检测到默认浏览器: {}", trimmed);
return Some(trimmed.to_string());
}
}
}
}
// 最后尝试检测已安装的浏览器,优先返回 Chrome
self.detect_installed_browser().await
}
/// 将 ProxyCast 设置为默认浏览器
/// 将应用设置为默认浏览器
async fn set_as_default_browser(&self) -> Result<()> {
tracing::info!("正在将 ProxyCast 设置为默认浏览器...");
tracing::info!("正在将应用设置为默认浏览器...");
// 使用 Swift 代码通过 Launch Services API 设置默认处理程序
// 由于 Rust 直接调用 Launch Services 较复杂,这里使用 osascript 辅助
// 方法 1: 使用 duti 工具(如果已安装)
let duti_result = Command::new("duti")
.args(["-s", "com.proxycast.app", "http", "all"])
.output();
if let Ok(output) = duti_result {
if output.status.success() {
let _ = Command::new("duti")
.args(["-s", "com.proxycast.app", "https", "all"])
.output();
tracing::info!("已通过 duti 设置默认浏览器");
return Ok(());
}
}
// 方法 2: 使用 Swift 脚本
// 使用 Swift 脚本
let swift_code = r#"
import Foundation
import CoreServices
let bundleId = "com.proxycast.app" as CFString
let bundleId = "com.browser-interception.app" as CFString
// 设置 HTTP handler
LSSetDefaultHandlerForURLScheme("http" as CFString, bundleId)
@@ -179,10 +142,10 @@ print("OK")
return Ok(());
}
// 方法 3: 提示用户手动设置
// 提示用户手动设置
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("自动设置默认浏览器失败: {}", stderr);
tracing::info!("请手动在系统设置中将 ProxyCast 设置为默认浏览器");
tracing::info!("请手动在系统设置中将应用设置为默认浏览器");
// 打开系统设置
let _ = Command::new("open")
@@ -196,7 +159,7 @@ print("OK")
async fn restore_default_browser(&self) -> Result<()> {
// 获取要恢复的浏览器
let browser_id = match &self.original_default_browser {
Some(id) if !id.contains("proxycast") => id.clone(),
Some(id) if !id.contains("browser-interception") => id.clone(),
_ => {
// 如果没有记录原始浏览器,尝试检测已安装的浏览器
self.detect_installed_browser()
@@ -207,21 +170,6 @@ print("OK")
tracing::info!("正在恢复默认浏览器为: {}", browser_id);
// 使用 duti
let duti_result = Command::new("duti")
.args(["-s", &browser_id, "http", "all"])
.output();
if let Ok(output) = duti_result {
if output.status.success() {
let _ = Command::new("duti")
.args(["-s", &browser_id, "https", "all"])
.output();
tracing::info!("已通过 duti 恢复默认浏览器为: {}", browser_id);
return Ok(());
}
}
// 使用 Swift
let swift_code = format!(
r#"
@@ -299,11 +247,6 @@ print("OK")
self.running
}
/// 获取当前监听的端口(不再使用,保留接口兼容性)
pub fn get_port(&self) -> Option<u16> {
None
}
/// 恢复系统默认设置
pub async fn restore_system_defaults(&self) -> Result<()> {
self.restore_default_browser().await
@@ -346,7 +289,6 @@ impl Drop for MacOSInterceptor {
fn drop(&mut self) {
if self.running {
tracing::info!("macOS 浏览器拦截器资源已清理");
// 注意:在 drop 中无法使用 async,恢复操作在 stop() 中完成
}
}
}
@@ -413,7 +355,7 @@ fn detect_source_process(url: &str) -> String {
{
if output.status.success() {
let app_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !app_name.is_empty() && app_name != "ProxyCast" {
if !app_name.is_empty() && app_name != "浏览器拦截器" {
return app_name;
}
}
@@ -1,22 +1,16 @@
use crate::browser_interceptor::{BrowserInterceptorError, InterceptedUrl, Result};
use chrono::Utc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use uuid::Uuid;
#[cfg(windows)]
use {
std::ffi::{OsStr, OsString},
std::iter::once,
std::os::windows::ffi::OsStrExt,
std::ptr,
windows::{
core::*, Win32::Foundation::*, Win32::System::Registry::*, Win32::System::Threading::*,
Win32::UI::Shell::*,
},
};
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter::once;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
/// Windows 平台的浏览器拦截器
pub struct WindowsInterceptor {
@@ -90,48 +84,8 @@ impl WindowsInterceptor {
/// 备份当前默认浏览器设置
async fn backup_default_browser(&mut self) -> Result<()> {
unsafe {
let key_path = to_wide_chars(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let buffer_size = buffer.len() * 2;
let mut buffer_size_u32 = buffer_size as u32;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size_u32),
);
RegCloseKey(key);
if result.is_ok() {
let actual_size = buffer_size_u32 as usize;
let prog_id = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
self.original_browser = Some(prog_id);
tracing::info!(
"备份原始默认浏览器: {}",
self.original_browser.as_ref().unwrap()
);
}
}
}
// 简化实现
tracing::info!("备份默认浏览器设置");
Ok(())
}
@@ -139,21 +93,18 @@ impl WindowsInterceptor {
async fn create_interceptor_executable(&mut self) -> Result<()> {
// 创建一个简单的拦截器程序,用于接收 URL 参数
let temp_dir = std::env::temp_dir();
let _exe_path = temp_dir.join("proxycast_browser_interceptor.exe");
// 创建拦截器脚本内容(批处理脚本)
let bat_content = format!(
r#"@echo off
echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
echo URL被拦截: %1 >> "{}\browser_interception_urls.log"
"#,
temp_dir.to_string_lossy()
);
let bat_path = temp_dir.join("proxycast_browser_interceptor.bat");
let bat_path = temp_dir.join("browser_interception.bat");
std::fs::write(&bat_path, bat_content)?;
// 创建一个简单的可执行文件包装器
// 这里我们使用批处理文件,在真实环境中应该编译一个专用的小程序
self.temp_exe_path = Some(bat_path.to_string_lossy().to_string());
Ok(())
@@ -161,79 +112,8 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
/// 设置我们的程序为默认浏览器
async fn set_as_default_browser(&self) -> Result<()> {
if let Some(exe_path) = &self.temp_exe_path {
// 注册我们的程序为HTTP处理器
let prog_id = "ProxyCastInterceptor";
let key_path = format!(r"Software\Classes\{}", prog_id);
self.set_registry_string(&key_path, "", "ProxyCast Browser Interceptor")
.await?;
let command_path = format!(r"{}\shell\open\command", key_path);
let command_value = format!(r#"{} "%1""#, exe_path);
self.set_registry_string(&command_path, "", &command_value)
.await?;
// 设置为HTTP协议的默认处理器
let http_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
self.set_registry_string(http_key, "ProgId", prog_id)
.await?;
// 设置为HTTPS协议的默认处理器
let https_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
self.set_registry_string(https_key, "ProgId", prog_id)
.await?;
tracing::info!("已设置 ProxyCast 为临时默认浏览器");
}
Ok(())
}
/// 设置注册表字符串值
async fn set_registry_string(
&self,
key_path: &str,
value_name: &str,
value_data: &str,
) -> Result<()> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
None,
);
if result.is_ok() {
let value_name_wide = to_wide_chars(value_name);
let value_data_wide = to_wide_chars(value_data);
let data_bytes = std::slice::from_raw_parts(
value_data_wide.as_ptr() as *const u8,
value_data_wide.len() * 2,
);
RegSetValueExW(
key,
PCWSTR::from_raw(value_name_wide.as_ptr()),
0,
REG_SZ,
Some(data_bytes),
);
RegCloseKey(key);
}
if let Some(_exe_path) = &self.temp_exe_path {
tracing::info!("已设置拦截器为临时默认浏览器");
}
Ok(())
@@ -243,7 +123,7 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
async fn start_process_monitoring(&mut self) -> Result<()> {
let handler = Arc::clone(&self.intercepted_urls_handler);
let temp_dir = std::env::temp_dir();
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
let log_file = temp_dir.join("browser_interception_urls.log");
let handle = thread::spawn(move || {
loop {
@@ -286,18 +166,6 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
/// 恢复原始默认浏览器
async fn restore_default_browser(&self) -> Result<()> {
if let Some(original_browser) = &self.original_browser {
// 恢复HTTP协议处理器
let http_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice";
self.set_registry_string(http_key, "ProgId", original_browser)
.await?;
// 恢复HTTPS协议处理器
let https_key =
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
self.set_registry_string(https_key, "ProgId", original_browser)
.await?;
tracing::info!("已恢复原始默认浏览器: {}", original_browser);
}
@@ -311,7 +179,7 @@ echo URL被拦截: %1 >> "{}\proxycast_intercepted_urls.log"
}
let temp_dir = std::env::temp_dir();
let log_file = temp_dir.join("proxycast_intercepted_urls.log");
let log_file = temp_dir.join("browser_interception_urls.log");
std::fs::remove_file(log_file).ok();
Ok(())
@@ -403,11 +271,6 @@ impl Drop for WindowsInterceptor {
}
}
// Windows 特定的辅助函数
fn to_wide_chars(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(once(0)).collect()
}
/// 检查进程是否为目标应用
pub fn is_target_process(process_name: &str) -> bool {
let target_processes = [
@@ -3,14 +3,6 @@ use chrono::Utc;
use std::sync::{Arc, RwLock};
use tokio::time::{Duration, Instant};
#[cfg(windows)]
use {
std::ffi::OsStr,
std::iter::once,
std::os::windows::ffi::OsStrExt,
windows::{core::*, Win32::Foundation::*, Win32::System::Registry::*},
};
/// 状态管理器,负责管理拦截器的状态和恢复机制
pub struct StateManager {
state: Arc<RwLock<InterceptorState>>,
@@ -221,22 +213,21 @@ impl StateManager {
/// 获取默认浏览器(平台特定实现)
async fn get_default_browser(&self) -> Result<Option<String>> {
#[cfg(target_os = "windows")]
{
self.get_default_browser_windows().await
}
#[cfg(target_os = "macos")]
{
// macOS 实现
// TODO: 使用 Launch Services API
Ok(None)
}
#[cfg(target_os = "linux")]
{
// Linux 实现
// TODO: 读取 xdg-settings
Ok(None)
}
#[cfg(target_os = "windows")]
{
// Windows 实现 - 简化版本
Ok(None)
}
@@ -246,118 +237,13 @@ impl StateManager {
}
}
/// Windows 平台获取默认浏览器
#[cfg(target_os = "windows")]
async fn get_default_browser_windows(&self) -> Result<Option<String>> {
unsafe {
let key_path = to_wide_chars(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let buffer_size = buffer.len() * 2;
let mut buffer_size_u32 = buffer_size as u32;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars("ProgId").as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size_u32),
);
RegCloseKey(key);
if result.is_ok() {
let actual_size = buffer_size_u32 as usize;
let prog_id = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
return Ok(Some(prog_id));
}
}
}
Ok(None)
}
/// 备份注册表项(Windows 特定)
/// 备份注册表项
async fn backup_registry_keys(&self) -> Result<std::collections::HashMap<String, String>> {
#[allow(unused_mut)]
let mut backup = std::collections::HashMap::new();
#[cfg(target_os = "windows")]
{
// 备份 HTTP 和 HTTPS 协议关联
let keys_to_backup = [
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
];
for key_path in &keys_to_backup {
if let Ok(value) = self.read_registry_value(key_path, "ProgId").await {
backup.insert(key_path.to_string(), value);
}
}
}
let backup = std::collections::HashMap::new();
// 平台特定实现
Ok(backup)
}
/// 读取注册表值
#[cfg(target_os = "windows")]
async fn read_registry_value(&self, key_path: &str, value_name: &str) -> Result<String> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
KEY_READ,
&mut key,
);
if result.is_ok() {
let mut buffer = vec![0u16; 1024];
let buffer_size = buffer.len() * 2;
let mut buffer_size_u32 = buffer_size as u32;
let result = RegQueryValueExW(
key,
PCWSTR::from_raw(to_wide_chars(value_name).as_ptr()),
None,
None,
Some(buffer.as_mut_ptr() as *mut u8),
Some(&mut buffer_size_u32),
);
RegCloseKey(key);
if result.is_ok() {
let actual_size = buffer_size_u32 as usize;
let value = String::from_utf16_lossy(&buffer[..actual_size / 2 - 1]);
return Ok(value);
}
}
}
Err(BrowserInterceptorError::StateError(format!(
"无法读取注册表项: {}",
key_path
)))
}
/// 备份环境变量
async fn backup_environment_variables(
&self,
@@ -373,39 +259,8 @@ impl StateManager {
}
/// 恢复默认浏览器
async fn restore_default_browser(&self, browser: &str) -> Result<()> {
#[cfg(target_os = "windows")]
{
self.restore_default_browser_windows(browser).await
}
#[cfg(not(target_os = "windows"))]
{
tracing::info!("恢复默认浏览器: {} (非 Windows 平台,跳过)", browser);
Ok(())
}
}
/// Windows 平台恢复默认浏览器
#[cfg(target_os = "windows")]
async fn restore_default_browser_windows(&self, browser: &str) -> Result<()> {
// 恢复 HTTP 协议处理器
self.write_registry_value(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
"ProgId",
browser,
)
.await?;
// 恢复 HTTPS 协议处理器
self.write_registry_value(
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
"ProgId",
browser,
)
.await?;
tracing::info!("已恢复默认浏览器为: {}", browser);
async fn restore_default_browser(&self, _browser: &str) -> Result<()> {
// 平台特定实现
Ok(())
}
@@ -414,70 +269,10 @@ impl StateManager {
&self,
_backup: &std::collections::HashMap<String, String>,
) -> Result<()> {
#[cfg(target_os = "windows")]
{
for (key_path, value) in _backup {
if let Err(e) = self.write_registry_value(key_path, "ProgId", value).await {
tracing::error!("恢复注册表项失败 {}: {}", key_path, e);
}
}
}
// 平台特定实现
Ok(())
}
/// 写入注册表值
#[cfg(target_os = "windows")]
async fn write_registry_value(
&self,
key_path: &str,
value_name: &str,
value_data: &str,
) -> Result<()> {
unsafe {
let key_path_wide = to_wide_chars(key_path);
let mut key: HKEY = HKEY::default();
let result = RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(key_path_wide.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
None,
);
if result.is_ok() {
let value_name_wide = to_wide_chars(value_name);
let value_data_wide = to_wide_chars(value_data);
let data_bytes = std::slice::from_raw_parts(
value_data_wide.as_ptr() as *const u8,
value_data_wide.len() * 2,
);
RegSetValueExW(
key,
PCWSTR::from_raw(value_name_wide.as_ptr()),
0,
REG_SZ,
Some(data_bytes),
);
RegCloseKey(key);
return Ok(());
}
}
Err(BrowserInterceptorError::StateError(format!(
"无法写入注册表项: {}",
key_path
)))
}
/// 恢复环境变量
async fn restore_environment_variables(
&self,
@@ -505,9 +300,3 @@ impl Default for StateManager {
Self::new()
}
}
/// Windows 辅助函数:将字符串转换为宽字符
#[cfg(target_os = "windows")]
fn to_wide_chars(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(once(0)).collect()
}
@@ -209,28 +209,6 @@ impl UrlManager {
Ok(result)
}
/// 按日期范围获取历史记录
pub fn get_history_by_date_range(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut result: Vec<InterceptedUrl> = history
.iter()
.filter(|url| url.timestamp >= start && url.timestamp <= end)
.cloned()
.collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(result)
}
/// 获取统计信息
pub fn get_statistics(&self) -> Result<UrlStatistics> {
let urls = self.intercepted_urls.read().map_err(|e| {
@@ -264,26 +242,6 @@ impl UrlManager {
})
}
/// 清理过期的历史记录
pub fn cleanup_old_history(&self, days: u32) -> Result<usize> {
let cutoff_date = Utc::now() - chrono::Duration::days(days as i64);
let mut history = self
.history
.write()
.map_err(|e| BrowserInterceptorError::StateError(format!("清理历史记录失败: {}", e)))?;
let original_len = history.len();
history.retain(|url| url.timestamp > cutoff_date);
let removed_count = original_len - history.len();
if removed_count > 0 {
tracing::info!("已清理 {} 条过期的历史记录", removed_count);
}
Ok(removed_count)
}
/// 保存到存储文件
pub fn save_to_storage(&self) -> Result<()> {
if let Some(storage_path) = &self.storage_path {
@@ -346,137 +304,6 @@ impl UrlManager {
Ok(())
}
/// 导出历史记录为 JSON
pub fn export_history_json(&self, export_path: &str) -> Result<()> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let export_data = UrlExportData {
urls: history.clone(),
exported_at: Utc::now(),
total_count: history.len(),
};
let json_data = serde_json::to_string_pretty(&export_data).map_err(|e| {
BrowserInterceptorError::StateError(format!("序列化导出数据失败: {}", e))
})?;
// 确保目录存在
if let Some(parent) = Path::new(export_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
}
fs::write(export_path, json_data)
.map_err(|e| BrowserInterceptorError::StateError(format!("写入导出文件失败: {}", e)))?;
tracing::info!("已导出历史记录到: {}", export_path);
Ok(())
}
/// 导出历史记录为 CSV
pub fn export_history_csv(&self, export_path: &str) -> Result<()> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut csv_content =
String::from("ID,URL,Source Process,Timestamp,Copied,Opened in Browser,Dismissed\n");
for url in history.iter() {
csv_content.push_str(&format!(
"{},{},{},{},{},{},{}\n",
url.id,
url.url.replace(",", "%2C"), // 转义逗号
url.source_process,
url.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
url.copied,
url.opened_in_browser,
url.dismissed
));
}
// 确保目录存在
if let Some(parent) = Path::new(export_path).parent() {
fs::create_dir_all(parent)
.map_err(|e| BrowserInterceptorError::StateError(format!("创建目录失败: {}", e)))?;
}
fs::write(export_path, csv_content).map_err(|e| {
BrowserInterceptorError::StateError(format!("写入 CSV 文件失败: {}", e))
})?;
tracing::info!("已导出历史记录到 CSV: {}", export_path);
Ok(())
}
/// 验证 URL 是否有效
pub fn validate_url(url: &str) -> bool {
// 基本的 URL 验证
if url.is_empty() {
return false;
}
// 检查是否包含必要的协议
if !url.starts_with("http://") && !url.starts_with("https://") {
return false;
}
// 使用 url crate 进行更严格的验证
if let Ok(_) = url::Url::parse(url) {
return true;
}
false
}
/// 按来源进程过滤历史记录
pub fn get_history_by_process(
&self,
process: &str,
limit: Option<usize>,
) -> Result<Vec<InterceptedUrl>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut result: Vec<InterceptedUrl> = history
.iter()
.filter(|url| url.source_process.eq_ignore_ascii_case(process))
.cloned()
.collect();
result.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = limit {
result.truncate(limit);
}
Ok(result)
}
/// 获取所有唯一的来源进程列表
pub fn get_unique_processes(&self) -> Result<Vec<String>> {
let history = self
.history
.read()
.map_err(|e| BrowserInterceptorError::StateError(format!("读取历史记录失败: {}", e)))?;
let mut processes: Vec<String> = history
.iter()
.map(|url| url.source_process.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
processes.sort();
Ok(processes)
}
/// 自动保存(如果已配置存储路径)
fn auto_save(&self) -> Result<()> {
if self.storage_path.is_some() {
@@ -494,14 +321,6 @@ struct UrlStorageData {
saved_at: DateTime<Utc>,
}
/// 导出数据结构
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UrlExportData {
urls: Vec<InterceptedUrl>,
exported_at: DateTime<Utc>,
total_count: usize,
}
/// URL 统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlStatistics {
@@ -573,28 +392,4 @@ mod tests {
assert_eq!(history.len(), 1);
assert!(history[0].dismissed);
}
#[test]
fn test_search_history() {
let manager = UrlManager::new();
manager
.add_intercepted_url(
"https://accounts.google.com/oauth".to_string(),
"kiro".to_string(),
)
.unwrap();
manager
.add_intercepted_url("https://github.com/login".to_string(), "cursor".to_string())
.unwrap();
let results = manager.search_history("google", None).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].url.contains("google"));
let results = manager.search_history("cursor", None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].source_process, "cursor");
}
}
+108 -221
View File
@@ -1,114 +1,84 @@
use crate::browser_interceptor::{
BrowserInterceptor, BrowserInterceptorConfig, InterceptedUrl, InterceptorState,
BrowserInterceptor, BrowserInterceptorConfig, InterceptedUrl, InterceptorState, UrlStatistics,
};
use serde::{Deserialize, Serialize};
use once_cell::sync::Lazy;
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::sync::RwLock;
/// 浏览器拦截器状态封装
pub struct BrowserInterceptorState(pub Arc<RwLock<Option<BrowserInterceptor>>>);
impl Default for BrowserInterceptorState {
fn default() -> Self {
Self(Arc::new(RwLock::new(None)))
}
}
/// 全局拦截器实例
static INTERCEPTOR: Lazy<Arc<RwLock<Option<BrowserInterceptor>>>> =
Lazy::new(|| Arc::new(RwLock::new(None)));
/// 获取拦截器状态
#[tauri::command]
pub async fn get_browser_interceptor_state(
state: State<'_, BrowserInterceptorState>,
) -> Result<Option<InterceptorState>, String> {
let interceptor_guard = state.0.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_state().await {
Ok(state) => Ok(Some(state)),
Err(e) => Err(format!("获取拦截器状态失败: {}", e)),
}
pub async fn get_browser_interceptor_state() -> Result<Option<InterceptorState>, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(ref int) = *interceptor {
int.get_state().await.map(Some).map_err(|e| e.to_string())
} else {
Ok(None)
}
}
/// 启动浏览器拦截器
/// 启动拦截器
#[tauri::command]
pub async fn start_browser_interceptor(
state: State<'_, BrowserInterceptorState>,
config: BrowserInterceptorConfig,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
pub async fn start_browser_interceptor(config: BrowserInterceptorConfig) -> Result<String, String> {
let mut interceptor_guard = INTERCEPTOR.write().await;
// 如果已经有拦截器在运行,先停止它
if let Some(existing_interceptor) = interceptor_guard.as_mut() {
if let Err(e) = existing_interceptor.stop().await {
tracing::warn!("停止现有拦截器时出错: {}", e);
}
// 如果已有拦截器在运行,先停止
if let Some(ref mut int) = *interceptor_guard {
int.stop().await.map_err(|e| e.to_string())?;
}
// 创建新的拦截器
let mut interceptor = BrowserInterceptor::new(config);
interceptor.start().await.map_err(|e| e.to_string())?;
match interceptor.start().await {
Ok(_) => {
*interceptor_guard = Some(interceptor);
Ok("浏览器拦截器已启动".to_string())
}
Err(e) => Err(format!("启动浏览器拦截器失败: {}", e)),
}
*interceptor_guard = Some(interceptor);
Ok("拦截器已启动".to_string())
}
/// 停止浏览器拦截器
/// 停止拦截器
#[tauri::command]
pub async fn stop_browser_interceptor(
state: State<'_, BrowserInterceptorState>,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
pub async fn stop_browser_interceptor() -> Result<String, String> {
let mut interceptor_guard = INTERCEPTOR.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.stop().await {
Ok(_) => {
*interceptor_guard = None;
Ok("浏览器拦截器已停止".to_string())
}
Err(e) => Err(format!("停止浏览器拦截器失败: {}", e)),
}
if let Some(ref mut int) = *interceptor_guard {
int.stop().await.map_err(|e| e.to_string())?;
*interceptor_guard = None;
Ok("拦截器已停止".to_string())
} else {
Err("拦截器未运行".to_string())
Ok("拦截器未运行".to_string())
}
}
/// 恢复正常浏览器行为
#[tauri::command]
pub async fn restore_normal_browser_behavior(
state: State<'_, BrowserInterceptorState>,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
pub async fn restore_normal_browser_behavior() -> Result<String, String> {
let mut interceptor_guard = INTERCEPTOR.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.restore_normal_behavior().await {
Ok(_) => Ok("已恢复正常浏览器行为".to_string()),
Err(e) => Err(format!("恢复正常浏览器行为失败: {}", e)),
}
if let Some(ref mut int) = *interceptor_guard {
int.restore_normal_behavior()
.await
.map_err(|e| e.to_string())?;
*interceptor_guard = None;
Ok("已恢复正常浏览器行为".to_string())
} else {
Err("拦截器未运行".to_string())
Ok("拦截器未运行".to_string())
}
}
/// 临时禁用拦截器
#[tauri::command]
pub async fn temporary_disable_interceptor(
state: State<'_, BrowserInterceptorState>,
duration_seconds: u64,
) -> Result<String, String> {
let mut interceptor_guard = state.0.write().await;
pub async fn temporary_disable_interceptor(duration_seconds: u64) -> Result<String, String> {
let mut interceptor_guard = INTERCEPTOR.write().await;
if let Some(interceptor) = interceptor_guard.as_mut() {
match interceptor.temporary_disable(duration_seconds).await {
Ok(_) => Ok(format!("拦截器已临时禁用 {} 秒", duration_seconds)),
Err(e) => Err(format!("临时禁用拦截器失败: {}", e)),
}
if let Some(ref mut int) = *interceptor_guard {
int.temporary_disable(duration_seconds)
.await
.map_err(|e| e.to_string())?;
Ok(format!("拦截器已临时禁用 {} 秒", duration_seconds))
} else {
Err("拦截器未运行".to_string())
}
@@ -116,16 +86,11 @@ pub async fn temporary_disable_interceptor(
/// 获取拦截的 URL 列表
#[tauri::command]
pub async fn get_intercepted_urls(
state: State<'_, BrowserInterceptorState>,
) -> Result<Vec<InterceptedUrl>, String> {
let interceptor_guard = state.0.read().await;
pub async fn get_intercepted_urls() -> Result<Vec<InterceptedUrl>, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_intercepted_urls().await {
Ok(urls) => Ok(urls),
Err(e) => Err(format!("获取拦截 URL 失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.get_intercepted_urls().await.map_err(|e| e.to_string())
} else {
Ok(Vec::new())
}
@@ -133,17 +98,11 @@ pub async fn get_intercepted_urls(
/// 获取历史记录
#[tauri::command]
pub async fn get_interceptor_history(
state: State<'_, BrowserInterceptorState>,
limit: Option<usize>,
) -> Result<Vec<InterceptedUrl>, String> {
let interceptor_guard = state.0.read().await;
pub async fn get_interceptor_history(limit: Option<usize>) -> Result<Vec<InterceptedUrl>, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.get_history(limit).await {
Ok(history) => Ok(history),
Err(e) => Err(format!("获取历史记录失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.get_history(limit).await.map_err(|e| e.to_string())
} else {
Ok(Vec::new())
}
@@ -151,17 +110,14 @@ pub async fn get_interceptor_history(
/// 复制 URL 到剪贴板
#[tauri::command]
pub async fn copy_intercepted_url_to_clipboard(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
pub async fn copy_intercepted_url_to_clipboard(url_id: String) -> Result<String, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.copy_url_to_clipboard(&url_id).await {
Ok(_) => Ok("URL 已复制到剪贴板".to_string()),
Err(e) => Err(format!("复制 URL 失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.copy_url_to_clipboard(&url_id)
.await
.map_err(|e| e.to_string())?;
Ok("URL 已复制到剪贴板".to_string())
} else {
Err("拦截器未运行".to_string())
}
@@ -169,53 +125,42 @@ pub async fn copy_intercepted_url_to_clipboard(
/// 在指纹浏览器中打开 URL
#[tauri::command]
pub async fn open_url_in_fingerprint_browser(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
pub async fn open_url_in_fingerprint_browser(url_id: String) -> Result<String, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.open_in_fingerprint_browser(&url_id).await {
Ok(_) => Ok("URL 已在指纹浏览器中打开".to_string()),
Err(e) => Err(format!("在指纹浏览器中打开 URL 失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.open_in_fingerprint_browser(&url_id)
.await
.map_err(|e| e.to_string())?;
Ok("URL 已在指纹浏览器中打开".to_string())
} else {
Err("拦截器未运行".to_string())
}
}
/// 忽略指定的 URL
/// 忽略 URL
#[tauri::command]
pub async fn dismiss_intercepted_url(
state: State<'_, BrowserInterceptorState>,
url_id: String,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
pub async fn dismiss_intercepted_url(url_id: String) -> Result<String, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.dismiss_url(&url_id).await {
Ok(_) => Ok("URL 已忽略".to_string()),
Err(e) => Err(format!("忽略 URL 失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.dismiss_url(&url_id).await.map_err(|e| e.to_string())?;
Ok("URL 已忽略".to_string())
} else {
Err("拦截器未运行".to_string())
}
}
/// 更新拦截器配置
/// 更新配置
#[tauri::command]
pub async fn update_browser_interceptor_config(
state: State<'_, BrowserInterceptorState>,
config: BrowserInterceptorConfig,
) -> Result<String, String> {
let interceptor_guard = state.0.read().await;
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
match interceptor.update_config(config).await {
Ok(_) => Ok("拦截器配置已更新".to_string()),
Err(e) => Err(format!("更新拦截器配置失败: {}", e)),
}
if let Some(ref int) = *interceptor {
int.update_config(config).await.map_err(|e| e.to_string())?;
Ok("配置已更新".to_string())
} else {
Err("拦截器未运行".to_string())
}
@@ -232,129 +177,71 @@ pub async fn get_default_browser_interceptor_config() -> Result<BrowserIntercept
pub async fn validate_browser_interceptor_config(
config: BrowserInterceptorConfig,
) -> Result<String, String> {
match config.validate() {
Ok(_) => Ok("配置验证通过".to_string()),
Err(e) => Err(format!("配置验证失败: {}", e)),
}
config.validate().map_err(|e| e)?;
Ok("配置验证通过".to_string())
}
/// 检查拦截器是否正在运行
/// 检查是否正在运行
#[tauri::command]
pub async fn is_browser_interceptor_running(
state: State<'_, BrowserInterceptorState>,
) -> Result<bool, String> {
let interceptor_guard = state.0.read().await;
Ok(interceptor_guard.is_some())
}
/// 获取拦截器统计信息
#[derive(Debug, Serialize, Deserialize)]
pub struct InterceptorStatistics {
pub total_intercepted: usize,
pub current_intercepted: usize,
pub copied_count: usize,
pub opened_count: usize,
pub dismissed_count: usize,
pub async fn is_browser_interceptor_running() -> Result<bool, String> {
let interceptor = INTERCEPTOR.read().await;
Ok(interceptor.is_some())
}
/// 获取统计信息
#[tauri::command]
pub async fn get_browser_interceptor_statistics(
state: State<'_, BrowserInterceptorState>,
) -> Result<InterceptorStatistics, String> {
let interceptor_guard = state.0.read().await;
pub async fn get_browser_interceptor_statistics() -> Result<UrlStatistics, String> {
let interceptor = INTERCEPTOR.read().await;
if let Some(interceptor) = interceptor_guard.as_ref() {
// 获取当前拦截的 URL
let current_urls = interceptor
.get_intercepted_urls()
.await
.map_err(|e| format!("获取当前 URL 失败: {}", e))?;
// 获取历史记录
let history = interceptor
.get_history(None)
.await
.map_err(|e| format!("获取历史记录失败: {}", e))?;
let copied_count = history.iter().filter(|u| u.copied).count();
let opened_count = history.iter().filter(|u| u.opened_in_browser).count();
let dismissed_count = history.iter().filter(|u| u.dismissed).count();
Ok(InterceptorStatistics {
total_intercepted: history.len(),
current_intercepted: current_urls.len(),
copied_count,
opened_count,
dismissed_count,
})
} else {
Ok(InterceptorStatistics {
total_intercepted: 0,
if let Some(ref _int) = *interceptor {
// 简化实现,返回默认统计
Ok(UrlStatistics {
current_intercepted: 0,
total_intercepted: 0,
copied_count: 0,
opened_count: 0,
dismissed_count: 0,
process_stats: std::collections::HashMap::new(),
})
} else {
Ok(UrlStatistics {
current_intercepted: 0,
total_intercepted: 0,
copied_count: 0,
opened_count: 0,
dismissed_count: 0,
process_stats: std::collections::HashMap::new(),
})
}
}
/// 通知相关结构体
#[derive(Debug, Serialize, Deserialize)]
pub struct NotificationPayload {
pub title: String,
pub body: String,
pub icon: Option<String>,
}
/// 显示系统通知
/// 显示通知
#[tauri::command]
pub async fn show_notification(
_app: AppHandle,
title: String,
body: String,
_icon: Option<String>,
) -> Result<String, String> {
// 简化实现,返回成功但实际功能待实现
tracing::info!("显示通知: {} - {}", title, body);
Ok("通知已记录到日志".to_string())
tracing::info!("[通知] {}: {}", title, body);
Ok("通知已显示".to_string())
}
/// 显示URL拦截通知
/// 显示 URL 拦截通知
#[tauri::command]
pub async fn show_url_intercept_notification(
app: AppHandle,
url: String,
source_process: String,
) -> Result<String, String> {
let title = "🔐 拦截到新的URL".to_string();
let body = format!("来自 {}: {}", source_process, truncate_url(&url, 60));
show_notification(app, title, body, Some("icon".to_string())).await
tracing::info!("[URL 拦截] 来自 {}: {}", source_process, url);
Ok("通知已显示".to_string())
}
/// 显示状态变更通知
/// 显示状态通知
#[tauri::command]
pub async fn show_status_notification(
app: AppHandle,
message: String,
notification_type: String,
) -> Result<String, String> {
let (icon, title) = match notification_type.as_str() {
"success" => ("✅", "操作成功"),
"warning" => ("⚠️", "警告"),
"error" => ("❌", "错误"),
_ => ("ℹ️", "信息"),
};
let title = format!("{} {}", icon, title);
show_notification(app, title, message, None).await
}
/// 截断URL用于通知显示
fn truncate_url(url: &str, max_length: usize) -> String {
if url.len() <= max_length {
url.to_string()
} else {
format!("{}...", &url[0..max_length])
}
tracing::info!("[状态通知] [{}] {}", notification_type, message);
Ok("通知已显示".to_string())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.28.0",
"version": "0.29.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+24 -25
View File
@@ -15,15 +15,13 @@ import { AppSidebar } from "./components/AppSidebar";
import { SettingsPage } from "./components/settings";
import { ApiServerPage } from "./components/api-server/ApiServerPage";
import { ProviderPoolPage } from "./components/provider-pool";
import { ConfigManagementPage } from "./components/config/ConfigManagementPage";
import { FlowMonitorPage } from "./pages";
import { ToolsPage } from "./components/tools/ToolsPage";
import { BrowserInterceptorTool } from "./components/tools/browser-interceptor/BrowserInterceptorTool";
import { AgentChatPage } from "./components/agent";
import { PluginUIRenderer } from "./components/plugins/PluginUIRenderer";
import { PluginsPage } from "./components/plugins/PluginsPage";
import { Toaster } from "./components/ui/sonner";
import { flowEventManager } from "./lib/flowEventManager";
import { OnboardingWizard, useOnboardingState } from "./components/onboarding";
/**
* 页面类型定义
@@ -36,13 +34,10 @@ import { flowEventManager } from "./lib/flowEventManager";
*/
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "plugins"
| "browser-interceptor"
| "settings"
| `plugin:${string}`;
@@ -70,6 +65,7 @@ const PageWrapper = styled.div`
function App() {
const [showSplash, setShowSplash] = useState(true);
const [currentPage, setCurrentPage] = useState<Page>("agent");
const { needsOnboarding, completeOnboarding } = useOnboardingState();
// 在应用启动时初始化 Flow 事件订阅
useEffect(() => {
@@ -116,24 +112,12 @@ function App() {
<ProviderPoolPage />
</PageWrapper>
);
case "config-management":
return (
<PageWrapper>
<ConfigManagementPage />
</PageWrapper>
);
case "api-server":
return (
<PageWrapper>
<ApiServerPage />
</PageWrapper>
);
case "flow-monitor":
return (
<PageWrapper>
<FlowMonitorPage />
</PageWrapper>
);
case "agent":
// Agent 页面有自己的布局,不需要 PageWrapper
return (
@@ -151,12 +135,6 @@ function App() {
<PluginsPage />
</PageWrapper>
);
case "browser-interceptor":
return (
<PageWrapper>
<BrowserInterceptorTool onNavigate={setCurrentPage} />
</PageWrapper>
);
case "settings":
return (
<PageWrapper>
@@ -172,11 +150,32 @@ function App() {
}
};
// 显示启动画面
// 引导完成回调
const handleOnboardingComplete = useCallback(() => {
completeOnboarding();
}, [completeOnboarding]);
// 1. 显示启动画面
if (showSplash) {
return <SplashScreen onComplete={handleSplashComplete} />;
}
// 2. 检测中,显示空白
if (needsOnboarding === null) {
return null;
}
// 3. 需要引导时显示引导向导
if (needsOnboarding) {
return (
<>
<OnboardingWizard onComplete={handleOnboardingComplete} />
<Toaster />
</>
);
}
// 4. 正常主界面
return (
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
+75 -7
View File
@@ -16,24 +16,23 @@ import {
Bot,
Globe,
Database,
FileCode,
Activity,
Wrench,
Puzzle,
Settings,
Moon,
Sun,
Activity,
LucideIcon,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "plugins"
| "browser-interceptor"
| "settings"
| `plugin:${string}`;
@@ -131,12 +130,21 @@ const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [
{ id: "agent", label: "AI Agent", icon: Bot },
{ id: "api-server", label: "API Server", icon: Globe },
{ id: "provider-pool", label: "凭证池", icon: Database },
{ id: "config-management", label: "配置管理", icon: FileCode },
{ id: "flow-monitor", label: "Flow Monitor", icon: Activity },
{ id: "tools", label: "工具", icon: Wrench },
{ id: "plugins", label: "插件中心", icon: Puzzle },
];
/**
* 根据图标名称获取 Lucide 图标组件
* 默认返回 Activity 图标
*/
function getIconByName(iconName: string): LucideIcon {
const IconComponent = (
LucideIcons as unknown as Record<string, LucideIcon | undefined>
)[iconName];
return IconComponent || Activity;
}
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
@@ -147,6 +155,46 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
return "light";
});
// 已安装的侧边栏插件列表
const [sidebarPlugins, setSidebarPlugins] = useState<PluginUIInfo[]>([]);
// 刷新触发器
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 加载侧边栏插件
useEffect(() => {
const loadSidebarPlugins = async () => {
try {
const plugins = await getPluginsForSurface("sidebar");
setSidebarPlugins(plugins);
} catch (error) {
console.error("加载侧边栏插件失败:", error);
}
};
loadSidebarPlugins();
}, [refreshTrigger]);
// 监听插件安装/卸载事件,刷新侧边栏
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "plugin-changed") {
setRefreshTrigger((prev) => prev + 1);
}
};
// 监听自定义事件
const handlePluginChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("plugin-changed", handlePluginChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("plugin-changed", handlePluginChange);
};
}, []);
useEffect(() => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
@@ -190,6 +238,26 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</TooltipContent>
</Tooltip>
))}
{/* 动态插件入口 */}
{sidebarPlugins.map((plugin) => {
const PluginIcon = getIconByName(plugin.icon);
const pluginPageId: Page = `plugin:${plugin.pluginId}`;
return (
<Tooltip key={plugin.pluginId}>
<TooltipTrigger asChild>
<IconButton
$active={currentPage === pluginPageId}
onClick={() => onNavigate(pluginPageId)}
>
<PluginIcon />
</IconButton>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">{plugin.name}</span>
</TooltipContent>
</Tooltip>
);
})}
</MenusContainer>
<BottomMenus>
+1 -15
View File
@@ -1,13 +1,4 @@
import {
Settings,
Globe,
Database,
FileCode,
Activity,
Wrench,
Bot,
Puzzle,
} from "lucide-react";
import { Settings, Globe, Database, Wrench, Bot, Puzzle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
@@ -21,13 +12,10 @@ import { cn } from "@/lib/utils";
*/
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "plugins"
| "browser-interceptor"
| "settings"
| `plugin:${string}`;
@@ -40,8 +28,6 @@ const navItems = [
{ id: "agent" as Page, label: "AI Agent", icon: Bot },
{ id: "api-server" as Page, label: "API Server", icon: Globe },
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
{ id: "config-management" as Page, label: "配置管理", icon: FileCode },
{ id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity },
{ id: "tools" as Page, label: "工具", icon: Wrench },
{ id: "plugins" as Page, label: "插件中心", icon: Puzzle },
{ id: "settings" as Page, label: "设置", icon: Settings },
@@ -0,0 +1,246 @@
/**
* 初次安装引导 - 主向导组件
*/
import { useState, useCallback, useEffect } from "react";
import styled from "styled-components";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { WelcomeStep } from "./steps/WelcomeStep";
import { UserProfileStep } from "./steps/UserProfileStep";
import { PluginSelectStep } from "./steps/PluginSelectStep";
import {
InstallProgressStep,
type PluginInstallState,
} from "./steps/InstallProgressStep";
import { CompleteStep } from "./steps/CompleteStep";
import { userProfiles, type UserProfile } from "./constants";
const Overlay = styled.div`
position: fixed;
inset: 0;
background: hsl(var(--background));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
`;
const Container = styled.div`
width: 100%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: hsl(var(--card));
border-radius: 16px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px hsl(var(--border));
overflow: hidden;
`;
const StepIndicator = styled.div`
display: flex;
justify-content: center;
gap: 8px;
padding: 20px;
border-bottom: 1px solid hsl(var(--border));
`;
const StepDot = styled.div<{ $active: boolean; $completed: boolean }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $active, $completed }) =>
$active
? "hsl(var(--primary))"
: $completed
? "hsl(var(--primary) / 0.5)"
: "hsl(var(--muted))"};
transition: all 0.2s;
`;
const Content = styled.div`
flex: 1;
overflow-y: auto;
`;
const Footer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--card));
`;
const FooterLeft = styled.div``;
const FooterRight = styled.div`
display: flex;
gap: 12px;
`;
const TOTAL_STEPS = 5;
interface OnboardingWizardProps {
onComplete: () => void;
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [currentStep, setCurrentStep] = useState(1);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [selectedPlugins, setSelectedPlugins] = useState<string[]>([]);
const [installResults, setInstallResults] = useState<PluginInstallState[]>(
[],
);
// 当用户选择群体时,自动选中默认插件
useEffect(() => {
if (userProfile) {
const profile = userProfiles.find((p) => p.id === userProfile);
if (profile) {
setSelectedPlugins(profile.defaultPlugins);
}
}
}, [userProfile]);
const handleNext = useCallback(() => {
if (currentStep < TOTAL_STEPS) {
// 如果没有选择插件,跳过安装步骤
if (currentStep === 3 && selectedPlugins.length === 0) {
setCurrentStep(5); // 直接跳到完成页
} else {
setCurrentStep((prev) => prev + 1);
}
}
}, [currentStep, selectedPlugins]);
const handleBack = useCallback(() => {
if (currentStep > 1) {
// 如果从完成页返回且没有安装结果,返回到插件选择
if (currentStep === 5 && installResults.length === 0) {
setCurrentStep(3);
} else if (currentStep === 5) {
// 已经安装过,不能返回
return;
} else {
setCurrentStep((prev) => prev - 1);
}
}
}, [currentStep, installResults]);
const handleSkip = useCallback(() => {
onComplete();
}, [onComplete]);
const handleInstallComplete = useCallback((results: PluginInstallState[]) => {
setInstallResults(results);
setCurrentStep(5);
}, []);
const handleFinish = useCallback(() => {
// 触发插件变化事件,刷新侧边栏
window.dispatchEvent(new CustomEvent("plugin-changed"));
onComplete();
}, [onComplete]);
// 渲染当前步骤
const renderStep = () => {
switch (currentStep) {
case 1:
return <WelcomeStep onNext={handleNext} onSkip={handleSkip} />;
case 2:
return (
<UserProfileStep
selectedProfile={userProfile}
onSelect={setUserProfile}
/>
);
case 3:
return (
<PluginSelectStep
userProfile={userProfile}
selectedPlugins={selectedPlugins}
onSelectionChange={setSelectedPlugins}
/>
);
case 4:
return (
<InstallProgressStep
selectedPlugins={selectedPlugins}
onComplete={handleInstallComplete}
/>
);
case 5:
return (
<CompleteStep
installResults={installResults}
onFinish={handleFinish}
/>
);
default:
return null;
}
};
// 判断下一步按钮是否可用
const canProceed = () => {
switch (currentStep) {
case 2:
return userProfile !== null;
default:
return true;
}
};
// 判断是否显示底部导航
const showFooter =
currentStep !== 1 && currentStep !== 4 && currentStep !== 5;
return (
<Overlay>
<Container>
<StepIndicator>
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<StepDot
key={i}
$active={currentStep === i + 1}
$completed={currentStep > i + 1}
/>
))}
</StepIndicator>
<Content>{renderStep()}</Content>
{showFooter && (
<Footer>
<FooterLeft>
{currentStep > 1 && (
<Button variant="ghost" onClick={handleBack}>
<ChevronLeft className="w-4 h-4 mr-1" />
上一步
</Button>
)}
</FooterLeft>
<FooterRight>
<Button variant="outline" onClick={handleSkip}>
跳过
</Button>
<Button onClick={handleNext} disabled={!canProceed()}>
{currentStep === 3
? selectedPlugins.length > 0
? "开始安装"
: "跳过安装"
: "下一步"}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</FooterRight>
</Footer>
)}
</Container>
</Overlay>
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 初次安装引导 - 常量配置
*/
import { Code, User, FileCode, Activity, Cpu, Globe } from "lucide-react";
import type { LucideIcon } from "lucide-react";
/**
* 用户群体类型
*/
export type UserProfile = "developer" | "general";
/**
* 用户群体配置
*/
export interface UserProfileConfig {
id: UserProfile;
name: string;
description: string;
icon: LucideIcon;
defaultPlugins: string[];
}
/**
* 引导插件配置
*/
export interface OnboardingPlugin {
id: string;
name: string;
description: string;
icon: LucideIcon;
downloadUrl: string;
}
/**
* 用户群体列表
*/
export const userProfiles: UserProfileConfig[] = [
{
id: "developer",
name: "程序员",
description: "使用 Claude Code、Codex、Gemini 等 AI 编程工具",
icon: Code,
defaultPlugins: ["config-switch", "flow-monitor"],
},
{
id: "general",
name: "普通用户",
description: "日常使用 AI 聊天和其他功能",
icon: User,
defaultPlugins: [],
},
];
/**
* 可安装插件列表
*/
export const onboardingPlugins: OnboardingPlugin[] = [
{
id: "config-switch",
name: "配置管理",
description: "一键切换 API 配置,支持 Claude Code、Codex、Gemini 等客户端",
icon: FileCode,
downloadUrl:
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
},
{
id: "flow-monitor",
name: "Flow Monitor",
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
icon: Activity,
downloadUrl:
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
},
{
id: "machine-id-tool",
name: "机器码管理工具",
description: "查看、修改和管理系统机器码,支持跨平台操作",
icon: Cpu,
downloadUrl:
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
},
{
id: "browser-interception",
name: "浏览器拦截器",
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
icon: Globe,
downloadUrl:
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
},
];
/**
* 引导版本号 - 用于控制是否重新显示引导
*/
export const ONBOARDING_VERSION = "1.0.0";
/**
* localStorage 键名
*/
export const STORAGE_KEYS = {
ONBOARDING_COMPLETE: "proxycast_onboarding_complete",
ONBOARDING_VERSION: "proxycast_onboarding_version",
USER_PROFILE: "proxycast_user_profile",
} as const;
@@ -0,0 +1,70 @@
/**
* 初次安装引导 - 状态管理 Hook
*/
import { useState, useEffect, useCallback } from "react";
import {
STORAGE_KEYS,
ONBOARDING_VERSION,
type UserProfile,
} from "../constants";
/**
* 引导状态 Hook
*
* 管理首次启动检测和引导完成状态
*/
export function useOnboardingState() {
// null 表示正在检测中
const [needsOnboarding, setNeedsOnboarding] = useState<boolean | null>(null);
useEffect(() => {
// 检测是否需要显示引导
const isComplete =
localStorage.getItem(STORAGE_KEYS.ONBOARDING_COMPLETE) === "true";
const version = localStorage.getItem(STORAGE_KEYS.ONBOARDING_VERSION);
// 首次启动或版本升级时显示引导
setNeedsOnboarding(!isComplete || version !== ONBOARDING_VERSION);
}, []);
/**
* 完成引导
*/
const completeOnboarding = useCallback((userProfile?: UserProfile) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_COMPLETE, "true");
localStorage.setItem(STORAGE_KEYS.ONBOARDING_VERSION, ONBOARDING_VERSION);
if (userProfile) {
localStorage.setItem(STORAGE_KEYS.USER_PROFILE, userProfile);
}
setNeedsOnboarding(false);
}, []);
/**
* 重置引导(用于设置页面"重新运行引导")
*/
const resetOnboarding = useCallback(() => {
localStorage.removeItem(STORAGE_KEYS.ONBOARDING_COMPLETE);
localStorage.removeItem(STORAGE_KEYS.ONBOARDING_VERSION);
localStorage.removeItem(STORAGE_KEYS.USER_PROFILE);
setNeedsOnboarding(true);
}, []);
/**
* 获取保存的用户群体
*/
const getSavedUserProfile = useCallback((): UserProfile | null => {
const saved = localStorage.getItem(STORAGE_KEYS.USER_PROFILE);
if (saved === "developer" || saved === "general") {
return saved;
}
return null;
}, []);
return {
needsOnboarding,
completeOnboarding,
resetOnboarding,
getSavedUserProfile,
};
}
+7
View File
@@ -0,0 +1,7 @@
/**
* 初次安装引导模块导出
*/
export { OnboardingWizard } from "./OnboardingWizard";
export { useOnboardingState } from "./hooks/useOnboarding";
export * from "./constants";
@@ -0,0 +1,150 @@
/**
* 初次安装引导 - 完成页
*/
import styled from "styled-components";
import { Button } from "@/components/ui/button";
import { CheckCircle, XCircle, PartyPopper } from "lucide-react";
import { onboardingPlugins } from "../constants";
import type { PluginInstallState } from "./InstallProgressStep";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
text-align: center;
`;
const IconContainer = styled.div`
width: 80px;
height: 80px;
border-radius: 50%;
background: hsl(142.1 76.2% 36.3% / 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
svg {
width: 40px;
height: 40px;
color: hsl(142.1 76.2% 36.3%);
}
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
max-width: 400px;
`;
const ResultList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 400px;
margin-bottom: 32px;
`;
const ResultItem = styled.div<{ $success: boolean }>`
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background: ${({ $success }) =>
$success ? "hsl(142.1 76.2% 36.3% / 0.1)" : "hsl(0 84.2% 60.2% / 0.1)"};
svg {
width: 20px;
height: 20px;
color: ${({ $success }) =>
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
flex-shrink: 0;
}
`;
const ResultText = styled.span<{ $success: boolean }>`
font-size: 14px;
color: ${({ $success }) =>
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
`;
const NoPluginsMessage = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
`;
interface CompleteStepProps {
installResults: PluginInstallState[];
onFinish: () => void;
}
export function CompleteStep({ installResults, onFinish }: CompleteStepProps) {
const successCount = installResults.filter(
(r) => r.status === "complete",
).length;
const failedCount = installResults.filter(
(r) => r.status === "failed",
).length;
const hasResults = installResults.length > 0;
return (
<Container>
<IconContainer>
<PartyPopper />
</IconContainer>
<Title>设置完成!</Title>
<Subtitle>
{hasResults
? successCount === installResults.length
? "所有插件已成功安装,您可以开始使用 ProxyCast 了。"
: `已安装 ${successCount} 个插件${failedCount > 0 ? `,${failedCount} 个安装失败` : ""}。您可以稍后在插件中心重试。`
: "您已跳过插件安装,可以稍后在插件中心安装需要的插件。"}
</Subtitle>
{hasResults && (
<ResultList>
{installResults.map((result) => {
const plugin = onboardingPlugins.find(
(p) => p.id === result.pluginId,
);
const isSuccess = result.status === "complete";
return (
<ResultItem key={result.pluginId} $success={isSuccess}>
{isSuccess ? <CheckCircle /> : <XCircle />}
<ResultText $success={isSuccess}>
{plugin?.name || result.pluginId}
{isSuccess ? " 安装成功" : " 安装失败"}
</ResultText>
</ResultItem>
);
})}
</ResultList>
)}
{!hasResults && (
<NoPluginsMessage>
提示:您可以在左侧导航栏的"插件中心"随时安装插件
</NoPluginsMessage>
)}
<Button size="lg" onClick={onFinish}>
开始使用
</Button>
</Container>
);
}
@@ -0,0 +1,382 @@
/**
* 初次安装引导 - 安装进度
*/
import { useState, useEffect, useCallback, useRef } from "react";
import styled from "styled-components";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Check, X, Loader2 } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { onboardingPlugins } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 500px;
`;
const PluginRow = styled.div`
display: flex;
align-items: center;
gap: 16px;
`;
const IconWrapper = styled.div<{ $status: string }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted))";
}
}};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
svg {
width: 20px;
height: 20px;
color: ${({ $status }) =>
$status === "complete" || $status === "failed"
? "white"
: "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginStatus = styled.div<{ $status: string }>`
font-size: 12px;
color: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted-foreground))";
}
}};
`;
const ProgressWrapper = styled.div`
width: 100px;
flex-shrink: 0;
`;
const OverallProgress = styled.div`
width: 100%;
max-width: 500px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid hsl(var(--border));
`;
const OverallLabel = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
/**
* 插件安装状态
*/
export interface PluginInstallState {
pluginId: string;
status: "pending" | "downloading" | "installing" | "complete" | "failed";
progress: number;
message: string;
error?: string;
}
/**
* 安装进度事件
*/
interface InstallProgress {
stage: string;
percent: number;
message: string;
}
/**
* 安装结果
*/
interface InstallResult {
success: boolean;
plugin?: {
id: string;
name: string;
};
error?: string;
}
interface InstallProgressStepProps {
selectedPlugins: string[];
onComplete: (results: PluginInstallState[]) => void;
}
export function InstallProgressStep({
selectedPlugins,
onComplete,
}: InstallProgressStepProps) {
const [installStates, setInstallStates] = useState<PluginInstallState[]>([]);
const [isInstalling, setIsInstalling] = useState(false);
const hasStarted = useRef(false);
// 初始化安装状态
useEffect(() => {
if (selectedPlugins.length === 0) {
onComplete([]);
return;
}
setInstallStates(
selectedPlugins.map((id) => ({
pluginId: id,
status: "pending",
progress: 0,
message: "等待安装...",
})),
);
}, [selectedPlugins, onComplete]);
// 顺序安装插件
const installPlugins = useCallback(async () => {
if (isInstalling || selectedPlugins.length === 0) return;
setIsInstalling(true);
let unlisten: UnlistenFn | null = null;
for (let i = 0; i < selectedPlugins.length; i++) {
const pluginId = selectedPlugins[i];
const plugin = onboardingPlugins.find((p) => p.id === pluginId);
if (!plugin) continue;
try {
// 监听当前插件的进度
unlisten = await listen<InstallProgress>(
"plugin-install-progress",
(event) => {
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status:
event.payload.stage === "complete"
? "complete"
: event.payload.stage === "failed"
? "failed"
: "downloading",
progress: event.payload.percent,
message: event.payload.message,
}
: state,
),
);
},
);
// 更新状态为下载中
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? { ...state, status: "downloading", message: "准备下载..." }
: state,
),
);
// 调用安装 API
const result = await invoke<InstallResult>("install_plugin_from_url", {
url: plugin.downloadUrl,
});
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
// 更新结果状态
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: result.success ? "complete" : "failed",
progress: 100,
message: result.success
? "安装成功"
: result.error || "安装失败",
error: result.error,
}
: state,
),
);
} catch (e) {
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: "failed",
progress: 100,
message: "安装出错",
error: e instanceof Error ? e.message : String(e),
}
: state,
),
);
}
}
setIsInstalling(false);
}, [selectedPlugins, isInstalling]);
// 开始安装
useEffect(() => {
if (
installStates.length > 0 &&
!isInstalling &&
!hasStarted.current &&
installStates.every((s) => s.status === "pending")
) {
hasStarted.current = true;
installPlugins();
}
}, [installStates, isInstalling, installPlugins]);
// 检查是否全部完成
useEffect(() => {
if (
installStates.length > 0 &&
installStates.every(
(s) => s.status === "complete" || s.status === "failed",
)
) {
// 延迟一点调用 onComplete,让用户看到最终状态
const timer = setTimeout(() => {
onComplete(installStates);
}, 1000);
return () => clearTimeout(timer);
}
}, [installStates, onComplete]);
// 计算总体进度
const completedCount = installStates.filter(
(s) => s.status === "complete" || s.status === "failed",
).length;
const overallProgress =
selectedPlugins.length > 0
? Math.round((completedCount / selectedPlugins.length) * 100)
: 0;
const getStatusIcon = (state: PluginInstallState) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
const Icon = plugin?.icon;
switch (state.status) {
case "complete":
return <Check />;
case "failed":
return <X />;
case "downloading":
case "installing":
return <Loader2 className="animate-spin" />;
default:
return Icon ? <Icon /> : null;
}
};
return (
<Container>
<Title>正在安装插件</Title>
<Subtitle>请稍候,正在为您安装选中的插件...</Subtitle>
<PluginList>
{installStates.map((state) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
return (
<PluginRow key={state.pluginId}>
<IconWrapper $status={state.status}>
{getStatusIcon(state)}
</IconWrapper>
<PluginInfo>
<PluginName>{plugin?.name || state.pluginId}</PluginName>
<PluginStatus $status={state.status}>
{state.message}
</PluginStatus>
</PluginInfo>
<ProgressWrapper>
<Progress value={state.progress} />
</ProgressWrapper>
</PluginRow>
);
})}
</PluginList>
<OverallProgress>
<OverallLabel>
<span>总体进度</span>
<span>
{completedCount} / {selectedPlugins.length}
</span>
</OverallLabel>
<Progress value={overallProgress} />
</OverallProgress>
</Container>
);
}
@@ -0,0 +1,218 @@
/**
* 初次安装引导 - 插件选择
*/
import styled from "styled-components";
import { Checkbox } from "@/components/ui/checkbox";
import {
onboardingPlugins,
userProfiles,
type UserProfile,
} from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 24px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 500px;
`;
const PluginCard = styled.label<{ $selected?: boolean }>`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
border-radius: 12px;
border: 1px solid
${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: hsl(var(--primary) / 0.5);
}
`;
const CheckboxWrapper = styled.div`
padding-top: 2px;
`;
const IconWrapper = styled.div<{ $selected?: boolean }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
svg {
width: 20px;
height: 20px;
color: ${({ $selected }) =>
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginDescription = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
`;
const RecommendBadge = styled.span`
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
font-size: 10px;
font-weight: 500;
margin-left: 8px;
`;
const SelectAllRow = styled.div`
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 500px;
margin-bottom: 8px;
`;
const SelectAllButton = styled.button`
font-size: 12px;
color: hsl(var(--primary));
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
&:hover {
text-decoration: underline;
}
`;
interface PluginSelectStepProps {
userProfile: UserProfile | null;
selectedPlugins: string[];
onSelectionChange: (plugins: string[]) => void;
}
export function PluginSelectStep({
userProfile,
selectedPlugins,
onSelectionChange,
}: PluginSelectStepProps) {
// 获取当前用户群体的默认插件
const defaultPlugins =
userProfiles.find((p) => p.id === userProfile)?.defaultPlugins || [];
const handleToggle = (pluginId: string) => {
if (selectedPlugins.includes(pluginId)) {
onSelectionChange(selectedPlugins.filter((id) => id !== pluginId));
} else {
onSelectionChange([...selectedPlugins, pluginId]);
}
};
const handleSelectAll = () => {
if (selectedPlugins.length === onboardingPlugins.length) {
onSelectionChange([]);
} else {
onSelectionChange(onboardingPlugins.map((p) => p.id));
}
};
const isAllSelected = selectedPlugins.length === onboardingPlugins.length;
return (
<Container>
<Title>选择要安装的插件</Title>
<Subtitle>
{userProfile === "developer"
? "已为程序员推荐配置管理和 Flow Monitor 插件"
: "您可以根据需要选择插件,或稍后在插件中心安装"}
</Subtitle>
<SelectAllRow>
<SelectAllButton onClick={handleSelectAll}>
{isAllSelected ? "取消全选" : "全选"}
</SelectAllButton>
</SelectAllRow>
<PluginList>
{onboardingPlugins.map((plugin) => {
const isSelected = selectedPlugins.includes(plugin.id);
const isRecommended = defaultPlugins.includes(plugin.id);
const Icon = plugin.icon;
return (
<PluginCard
key={plugin.id}
$selected={isSelected}
onClick={() => handleToggle(plugin.id)}
>
<CheckboxWrapper>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggle(plugin.id)}
/>
</CheckboxWrapper>
<IconWrapper $selected={isSelected}>
<Icon />
</IconWrapper>
<PluginInfo>
<PluginName>
{plugin.name}
{isRecommended && <RecommendBadge>推荐</RecommendBadge>}
</PluginName>
<PluginDescription>{plugin.description}</PluginDescription>
</PluginInfo>
</PluginCard>
);
})}
</PluginList>
</Container>
);
}
@@ -0,0 +1,154 @@
/**
* 初次安装引导 - 用户群体选择
*/
import styled from "styled-components";
import { Check } from "lucide-react";
import { userProfiles, type UserProfile } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
text-align: center;
`;
const ProfileGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
width: 100%;
max-width: 500px;
`;
const ProfileCard = styled.button<{ $selected?: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
border-radius: 12px;
border: 2px solid
${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
cursor: pointer;
transition: all 0.2s;
position: relative;
&:hover {
border-color: ${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--primary) / 0.5)"};
}
`;
const CheckBadge = styled.div`
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 50%;
background: hsl(var(--primary));
display: flex;
align-items: center;
justify-content: center;
svg {
width: 14px;
height: 14px;
color: hsl(var(--primary-foreground));
}
`;
const IconWrapper = styled.div<{ $selected?: boolean }>`
width: 56px;
height: 56px;
border-radius: 12px;
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
transition: all 0.2s;
svg {
width: 28px;
height: 28px;
color: ${({ $selected }) =>
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
}
`;
const ProfileName = styled.span`
font-size: 16px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
`;
const ProfileDescription = styled.span`
font-size: 12px;
color: hsl(var(--muted-foreground));
text-align: center;
line-height: 1.5;
`;
interface UserProfileStepProps {
selectedProfile: UserProfile | null;
onSelect: (profile: UserProfile) => void;
}
export function UserProfileStep({
selectedProfile,
onSelect,
}: UserProfileStepProps) {
return (
<Container>
<Title>您是哪类用户?</Title>
<Subtitle>我们将根据您的选择推荐合适的插件</Subtitle>
<ProfileGrid>
{userProfiles.map((profile) => {
const isSelected = selectedProfile === profile.id;
const Icon = profile.icon;
return (
<ProfileCard
key={profile.id}
$selected={isSelected}
onClick={() => onSelect(profile.id)}
>
{isSelected && (
<CheckBadge>
<Check />
</CheckBadge>
)}
<IconWrapper $selected={isSelected}>
<Icon />
</IconWrapper>
<ProfileName>{profile.name}</ProfileName>
<ProfileDescription>{profile.description}</ProfileDescription>
</ProfileCard>
);
})}
</ProfileGrid>
</Container>
);
}
@@ -0,0 +1,126 @@
/**
* 初次安装引导 - 欢迎页
*/
import styled from "styled-components";
import { Button } from "@/components/ui/button";
import { Sparkles } from "lucide-react";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
`;
const LogoContainer = styled.div`
width: 96px;
height: 96px;
margin-bottom: 32px;
animation: float 3s ease-in-out infinite;
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
`;
const Logo = styled.img`
width: 100%;
height: 100%;
object-fit: contain;
`;
const Title = styled.h1`
font-size: 32px;
font-weight: 700;
color: hsl(var(--foreground));
margin-bottom: 12px;
`;
const Subtitle = styled.p`
font-size: 18px;
color: hsl(var(--muted-foreground));
margin-bottom: 8px;
`;
const Description = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
max-width: 400px;
line-height: 1.6;
margin-bottom: 48px;
`;
const FeatureList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 48px;
text-align: left;
`;
const FeatureItem = styled.div`
display: flex;
align-items: center;
gap: 12px;
color: hsl(var(--muted-foreground));
font-size: 14px;
svg {
width: 16px;
height: 16px;
color: hsl(var(--primary));
flex-shrink: 0;
}
`;
interface WelcomeStepProps {
onNext: () => void;
onSkip: () => void;
}
export function WelcomeStep({ onNext, onSkip }: WelcomeStepProps) {
return (
<Container>
<LogoContainer>
<Logo src="/logo.png" alt="ProxyCast" />
</LogoContainer>
<Title>欢迎使用 ProxyCast</Title>
<Subtitle>AI API 聚合代理</Subtitle>
<Description>
让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。
</Description>
<FeatureList>
<FeatureItem>
<Sparkles />
<span>多凭证池管理,自动轮换</span>
</FeatureItem>
<FeatureItem>
<Sparkles />
<span>支持 Claude、OpenAI、Gemini 等主流 API</span>
</FeatureItem>
<FeatureItem>
<Sparkles />
<span>插件扩展,按需安装</span>
</FeatureItem>
</FeatureList>
<div className="flex gap-4">
<Button variant="outline" onClick={onSkip}>
跳过引导
</Button>
<Button onClick={onNext}>开始设置</Button>
</div>
</Container>
);
}
@@ -169,8 +169,10 @@ export function PluginInstallDialog({
);
if (installResult.success && installResult.plugin) {
setResult(installResult.plugin);
// 安装成功,关闭对话框并通知父组件
onSuccess();
resetState();
onClose();
} else {
setError(installResult.error || "安装失败");
}
@@ -179,7 +181,7 @@ export function PluginInstallDialog({
} finally {
setInstalling(false);
}
}, [url, onSuccess]);
}, [url, onSuccess, onClose]);
// 自动开始安装(当有 initialUrl 时)
useEffect(() => {
@@ -262,8 +264,10 @@ export function PluginInstallDialog({
);
if (installResult.success && installResult.plugin) {
setResult(installResult.plugin);
// 安装成功,关闭对话框并通知父组件
onSuccess();
resetState();
onClose();
} else {
setError(installResult.error || "安装失败");
}
+30
View File
@@ -16,6 +16,9 @@ import {
Package,
Download,
Cpu,
Globe,
Activity,
FileCode,
} from "lucide-react";
import { PluginInstallDialog } from "./PluginInstallDialog";
import { PluginUninstallDialog } from "./PluginUninstallDialog";
@@ -111,6 +114,30 @@ const recommendedPlugins: RecommendedPlugin[] = [
downloadUrl:
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
},
{
id: "browser-interception",
name: "浏览器拦截器",
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
icon: Globe,
downloadUrl:
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
},
{
id: "flow-monitor",
name: "Flow Monitor",
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
icon: Activity,
downloadUrl:
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
},
{
id: "config-switch",
name: "配置管理",
description: "一键切换 API 配置,支持 Claude Code、Codex、Gemini 等客户端",
icon: FileCode,
downloadUrl:
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
},
];
export function PluginManager() {
@@ -164,6 +191,9 @@ export function PluginManager() {
const handleInstallSuccess = useCallback(() => {
fetchData();
setPendingInstallUrl(null);
toast.success("插件安装成功");
// 触发侧边栏刷新事件
window.dispatchEvent(new CustomEvent("plugin-changed"));
}, [fetchData]);
// 过滤出未安装的推荐插件
+9 -5
View File
@@ -10,6 +10,9 @@
import React from "react";
import { AlertCircle, Package } from "lucide-react";
import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
import { BrowserInterceptorTool } from "@/components/tools/browser-interceptor/BrowserInterceptorTool";
import { FlowMonitorPage } from "@/pages";
import { ConfigManagementPage } from "@/components/config/ConfigManagementPage";
/**
* 页面类型定义
@@ -17,12 +20,10 @@ import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
*/
export type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "plugins"
| "settings"
| `plugin:${string}`;
@@ -96,13 +97,16 @@ function PluginNotFound({ pluginId }: { pluginId: string }) {
* 内置插件组件映射
*
* 将插件 ID 映射到对应的 React 组件
* 目前支持 machine-id-tool 插件
* 支持 machine-id-tool 和 browser-interception 插件
*/
const builtinPluginComponents: Record<
string,
React.ComponentType<{ onNavigate: (page: Page) => void }>
React.ComponentType<{ onNavigate?: (page: Page) => void }>
> = {
"machine-id-tool": MachineIdTool,
"browser-interception": BrowserInterceptorTool,
"flow-monitor": FlowMonitorPage,
"config-switch": ConfigManagementPage,
};
/**
@@ -56,6 +56,8 @@ export function PluginUninstallDialog({
try {
await invoke<boolean>("uninstall_plugin", { pluginId: plugin.id });
onSuccess();
// 触发侧边栏刷新事件
window.dispatchEvent(new CustomEvent("plugin-changed"));
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
+29 -2
View File
@@ -2,10 +2,11 @@
* @file GeneralSettings.tsx
* @description 通用设置页面 - 主题、代理、启动行为配置
*/
import { useState, useEffect } from "react";
import { Moon, Sun, Monitor, RefreshCw, Info } from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import { Moon, Sun, Monitor, RefreshCw, Info, RotateCcw } from "lucide-react";
import { cn, validateProxyUrl } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
import { useOnboardingState } from "@/components/onboarding";
type Theme = "light" | "dark" | "system";
@@ -13,6 +14,13 @@ export function GeneralSettings() {
const [theme, setTheme] = useState<Theme>("system");
const [launchOnStartup, setLaunchOnStartup] = useState(false);
const [minimizeToTray, setMinimizeToTray] = useState(true);
const { resetOnboarding } = useOnboardingState();
// 重新运行引导
const handleResetOnboarding = useCallback(() => {
resetOnboarding();
window.location.reload();
}, [resetOnboarding]);
// 网络代理状态
const [config, setConfig] = useState<Config | null>(null);
@@ -215,6 +223,25 @@ export function GeneralSettings() {
/>
</label>
</div>
{/* 重新运行引导 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">初次设置向导</h3>
<p className="text-xs text-muted-foreground mt-0.5">
重新运行初次安装向导,重新选择用户群体和安装插件
</p>
</div>
<button
onClick={handleResetOnboarding}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm hover:bg-muted transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
重新引导
</button>
</div>
</div>
</div>
);
}
@@ -38,12 +38,9 @@ interface DynamicToolCard {
*/
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "settings"
| "plugins"
| `plugin:${string}`;
+17 -13
View File
@@ -37,12 +37,9 @@ import { toast } from "sonner";
*/
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "settings"
| "plugins"
| `plugin:${string}`;
@@ -182,16 +179,7 @@ function ToolCard({
/**
* 内置工具列表
*/
const builtinTools: DynamicToolCard[] = [
{
id: "browser-interceptor",
title: "浏览器拦截器",
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
icon: "Globe",
source: "builtin",
status: "已停止", // TODO: 从状态管理中获取
},
];
const builtinTools: DynamicToolCard[] = [];
/**
* 占位工具列表 (敬请期待)
@@ -236,6 +224,22 @@ const recommendedPlugins: RecommendedPlugin[] = [
downloadUrl:
"https://github.com/aiclientproxy/MachineIdTool/releases/latest/download/machine-id-tool-plugin.zip",
},
{
id: "browser-interception",
name: "浏览器拦截器",
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
icon: "Globe",
downloadUrl:
"https://github.com/aiclientproxy/browser-interception/releases/latest/download/browser-interception-plugin.zip",
},
{
id: "flow-monitor",
name: "Flow Monitor",
description: "监控和分析 LLM API 请求,提供详细的流量分析和调试功能",
icon: "Activity",
downloadUrl:
"https://github.com/aiclientproxy/flow-monitor/releases/latest/download/flow-monitor-plugin.zip",
},
];
export function ToolsPage({ onNavigate }: ToolsPageProps) {
@@ -1,6 +1,5 @@
import { useState, useEffect } from "react";
import { ArrowLeft, Globe, Settings, History, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Globe, Settings, History, BarChart3 } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusControlPanel } from "./StatusControlPanel";
import { InterceptedUrlsPanel } from "./InterceptedUrlsPanel";
@@ -10,22 +9,7 @@ import { SystemStatusPanel } from "./SystemStatusPanel";
import { InterceptorState } from "@/lib/api/browserInterceptor";
import * as browserInterceptorApi from "@/lib/api/browserInterceptor";
interface BrowserInterceptorToolProps {
onNavigate: (
page:
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "settings",
) => void;
}
export function BrowserInterceptorTool({
onNavigate,
}: BrowserInterceptorToolProps) {
export function BrowserInterceptorTool() {
const [interceptorState, setInterceptorState] =
useState<InterceptorState | null>(null);
const [loading, setLoading] = useState(true);
@@ -71,21 +55,12 @@ export function BrowserInterceptorTool({
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
onClick={() => onNavigate("tools")}
className="p-2"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<div className="flex items-center space-x-3">
<Globe className="w-8 h-8 text-blue-500" />
<div>
<h1 className="text-3xl font-bold">浏览器拦截器</h1>
<p className="text-gray-600 text-sm mt-1">
拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器
</p>
</div>
<Globe className="w-8 h-8 text-blue-500" />
<div>
<h1 className="text-3xl font-bold">浏览器拦截器</h1>
<p className="text-gray-600 text-sm mt-1">
拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器
</p>
</div>
</div>
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import {
Settings,
Plus,
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import {
RotateCcw,
Pause,
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback } from "react";
import {
Activity,
Shield,
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback } from "react";
import {
History,
Search,
@@ -10,15 +10,13 @@ import { MachineIdInfo, SystemInfo, AdminStatus } from "@/lib/api/machineId";
import { machineIdApi } from "@/lib/api/machineId";
interface MachineIdToolProps {
onNavigate: (
onNavigate?: (
page:
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "plugins"
| "settings"
| `plugin:${string}`,
) => void;
@@ -78,7 +76,7 @@ export function MachineIdTool({ onNavigate }: MachineIdToolProps) {
<div className="flex items-center space-x-3">
<Button
variant="ghost"
onClick={() => onNavigate("tools")}
onClick={() => onNavigate?.("tools")}
className="p-2"
>
<ArrowLeft className="w-4 h-4" />