init wxt framework

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent 0c197a791d
commit 83381b9623
34 changed files with 8126 additions and 20 deletions
+578
View File
@@ -0,0 +1,578 @@
---
description: 使用 WXT、React 和 TypeScript 构建代理管理扩展的指南
globs:
alwaysApply: false
---
---
description: 使用 WXT、React 和 TypeScript 构建代理管理扩展的指南
globs: "**/*.ts, **/*.tsx, **/*.js, **/*.jsx"
---
## 概览
](https://wxt.dev/) 是一个为浏览器扩展开发提供现代开发体验的框架。本指南将帮助您使用 WXT、React 和 TypeScript 重构类似 SwitchyOmega 的代理管理扩展。
## 项目结构
推荐使用以下项目结构:
```
.
├── .output/
├── .wxt/
├── modules/
├── public/ # 包含要复制到输出文件夹的任何文件,而无需WXT处理
├── ord/ # 需要重构的旧代码
├── src/
│ │ ├── assets/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── entrypoints/ # 包含所有被捆绑到扩展名的入口点
│ │ ├── hooks/ # 默认自动导入,包含项目用于 React 和 Solid 的钩子的源代码
│ │ ├── utils/
├── .env
├── .env.publish
├── app.config.ts
├── package.json
├── tsconfig.json
├── web-ext.config.ts # 配置浏览器启动
├── wxt.config.ts # WXT项目的主要配置文件
```
Different browsers provide different global variables for accessing the extension APIs (chrome provides chrome, firefox provides browser, etc).
WXT merges these two into a unified API accessed through the browser variable.
```
import { browser } from 'wxt/browser';
browser.action.onClicked.addListener(() => {
// ...
});
```
TIP
With auto-imports enabled, you don't even need to import this variable from wxt/browser!
The browser variable WXT provides is a simple export of the browser or chrome globals provided by the browser at runtime:
export const browser = globalThis.browser?.runtime?.id
? globalThis.browser
: globalThis.chrome;
This means you can use the promise-style API for both MV2 and MV3, and it will work across all browsers (Chromium, Firefox, Safari, etc).
Accessing Types
All types can be accessed via WXT's Browser namespace:
```
import { type Browser } from 'wxt/browser';
function handleMessage(message: any, sender: Browser.runtime.MessageSender) {
// ...
}
```
## 入口点设置
### 后台脚本
```typescript
// entrypoints/background/index.ts
import { defineBackground } from 'wxt/background';
import { setupProxyManagement } from './proxy';
export default defineBackground({
// 设置清单选项
type: 'module',
main() {
// 初始化代理管理
setupProxyManagement();
// 监听消息
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'SWITCH_PROXY') {
return handleProxySwitch(message.proxyId);
}
});
},
});
```
### 弹出窗口
```html
<!-- entrypoints/popup/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代理切换器</title>
<meta
name="manifest.default_icon"
content="{
16: '/icon-16.png',
48: '/icon-48.png'
}"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
```
```tsx
// entrypoints/popup/index.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('app')!);
root.render(<App />);
```
```tsx
// entrypoints/popup/App.tsx
import React, { useState, useEffect } from 'react';
import ProxySelector from '../../components/ProxySelector';
import { getProxyList, getCurrentProxy } from '../../utils/proxy';
import type { Proxy } from '../../types';
const App: React.FC = () => {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [currentProxy, setCurrentProxy] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
const proxyList = await getProxyList();
const current = await getCurrentProxy();
setProxies(proxyList);
setCurrentProxy(current);
};
loadData();
}, []);
const handleProxyChange = async (proxyId: string) => {
await browser.runtime.sendMessage({ type: 'SWITCH_PROXY', proxyId });
setCurrentProxy(proxyId);
};
return (
<div className="popup">
<h1>代理切换器</h1>
<ProxySelector
proxies={proxies}
currentProxy={currentProxy}
onChange={handleProxyChange}
/>
</div>
);
};
export default App;
```
### 选项页面
```html
<!-- entrypoints/options/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代理切换器设置</title>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
```
```tsx
// entrypoints/options/App.tsx
import React, { useState, useEffect } from 'react';
import { getProxyList, saveProxy, deleteProxy } from '../../utils/proxy';
import type { Proxy } from '../../types';
const App: React.FC = () => {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [newProxy, setNewProxy] = useState<Partial<Proxy>>({
name: '',
host: '',
port: '',
protocol: 'http'
});
useEffect(() => {
loadProxies();
}, []);
const loadProxies = async () => {
const list = await getProxyList();
setProxies(list);
};
const handleSaveProxy = async () => {
if (!newProxy.name || !newProxy.host || !newProxy.port) return;
await saveProxy(newProxy as Proxy);
loadProxies();
setNewProxy({
name: '',
host: '',
port: '',
protocol: 'http'
});
};
return (
<div className="options">
<h1>代理管理器设置</h1>
<div className="proxy-list">
{proxies.map(proxy => (
<div key={proxy.id} className="proxy-item">
<span>{proxy.name} ({proxy.protocol}://{proxy.host}:{proxy.port})</span>
<button onClick={() => deleteProxy(proxy.id)}>删除</button>
</div>
))}
</div>
<div className="add-proxy">
<h2>添加新代理</h2>
<input
type="text"
placeholder="名称"
value={newProxy.name}
onChange={e => setNewProxy({...newProxy, name: e.target.value})}
/>
<select
value={newProxy.protocol}
onChange={e => setNewProxy({...newProxy, protocol: e.target.value})}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
<option value="socks5">SOCKS5</option>
</select>
<input
type="text"
placeholder="主机"
value={newProxy.host}
onChange={e => setNewProxy({...newProxy, host: e.target.value})}
/>
<input
type="text"
placeholder="端口"
value={newProxy.port}
onChange={e => setNewProxy({...newProxy, port: e.target.value})}
/>
<button onClick={handleSaveProxy}>保存</button>
</div>
</div>
);
};
export default App;
```
### 内容脚本
```typescript
// entrypoints/content.ts
import { defineContentScript } from 'wxt/content-script';
export default defineContentScript({
matches: ['<all_urls>'],
main() {
// 在页面中执行的内容脚本逻辑
console.log('代理切换器内容脚本已加载');
// 根据需要与后台脚本通信
browser.runtime.sendMessage({ type: 'CONTENT_SCRIPT_LOADED' });
},
});
```
## 最佳实践
1. **使用 WXT 存储模块**: 利用 `@wxt-dev/storage` 管理扩展数据。
```typescript
// 安装: npm install @wxt-dev/storage
// utils/storage.ts
import { createStorage } from '@wxt-dev/storage';
export const storage = createStorage({
proxies: {
defaultValue: [],
schema: z.array(z.object({
id: z.string(),
name: z.string(),
protocol: z.enum(['http', 'https', 'socks4', 'socks5']),
host: z.string(),
port: z.string()
}))
},
currentProxyId: {
defaultValue: null,
schema: z.string().nullable()
}
});
```
2. **组件化开发**: 创建可重用的React组件。
```tsx
// components/ProxySelector.tsx
import React from 'react';
import type { Proxy } from '../types';
interface ProxySelectorProps {
proxies: Proxy[];
currentProxy: string | null;
onChange: (proxyId: string) => void;
}
const ProxySelector: React.FC<ProxySelectorProps> = ({ proxies, currentProxy, onChange }) => {
return (
<div className="proxy-selector">
{proxies.map(proxy => (
<div
key={proxy.id}
className={`proxy-item ${currentProxy === proxy.id ? 'active' : ''}`}
onClick={() => onChange(proxy.id)}
>
{proxy.name}
</div>
))}
</div>
);
};
export default ProxySelector;
```
3. **类型安全**: 为所有对象定义TypeScript接口。
```typescript
// types/index.ts
export interface Proxy {
id: string;
name: string;
protocol: 'http' | 'https' | 'socks4' | 'socks5';
host: string;
port: string;
username?: string;
password?: string;
}
export interface ProxyRule {
id: string;
name: string;
pattern: string;
proxyId: string;
}
```
4. **使用环境变量**: 为不同环境配置不同的设置。
```typescript
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: process.env.NODE_ENV === 'development' ? '[DEV] 代理切换器' : '代理切换器',
version: '1.0.0',
description: '一个强大的浏览器代理管理扩展',
},
// 其他配置...
});
```
5. **消息通信**: 使用结构化消息系统。
```typescript
// utils/messaging.ts
export type MessageType =
| { type: 'SWITCH_PROXY'; proxyId: string }
| { type: 'GET_CURRENT_PROXY' }
| { type: 'PROXY_CHANGED'; proxyId: string };
export function sendMessage<T extends MessageType>(message: T): Promise<any> {
return browser.runtime.sendMessage(message);
}
```
6. **图标状态管理**: 根据当前代理状态更新扩展图标。
```typescript
// background/proxy.ts
function updateExtensionIcon(proxyId: string | null) {
const iconPath = proxyId
? '/icons/proxy-active.png'
: '/icons/proxy-inactive.png';
browser.action.setIcon({ path: iconPath });
}
```
7. **错误处理**: 实现良好的错误捕获和报告。
```typescript
// utils/error.ts
export async function executeWithErrorHandling<T>(
fn: () => Promise<T>,
errorMessage = '执行操作时出错'
): Promise<T | null> {
try {
return await fn();
} catch (error) {
console.error(`${errorMessage}:`, error);
browser.notifications.create({
type: 'basic',
iconUrl: '/icon-48.png',
title: '代理切换器错误',
message: errorMessage
});
return null;
}
}
```
8. **使用现代钩子**: 为React组件编写自定义钩子。
```typescript
// hooks/useProxies.ts
import { useState, useEffect } from 'react';
import { storage } from '../utils/storage';
import type { Proxy } from '../types';
export function useProxies() {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const data = await storage.proxies.get();
setProxies(data);
setLoading(false);
};
load();
return storage.proxies.subscribe(newProxies => {
setProxies(newProxies);
});
}, []);
return { proxies, loading };
}
```
## 版本兼容性
本指南适用于:
- WXT v0.20.0 及以上
- React 18+
- TypeScript 5.0+
## 扩展功能实现
### 代理管理功能
```typescript
// utils/proxy.ts
import { storage } from './storage';
import { v4 as uuidv4 } from 'uuid';
import type { Proxy } from '../types';
export async function getProxyList(): Promise<Proxy[]> {
return await storage.proxies.get();
}
export async function getCurrentProxy(): Promise<string | null> {
return await storage.currentProxyId.get();
}
export async function switchProxy(proxyId: string | null): Promise<void> {
// 更新存储
await storage.currentProxyId.set(proxyId);
if (!proxyId) {
// 清除代理
await browser.proxy.settings.clear({});
return;
}
// 获取代理详情
const proxies = await storage.proxies.get();
const proxy = proxies.find(p => p.id === proxyId);
if (!proxy) return;
// 设置代理
await browser.proxy.settings.set({
value: {
mode: 'fixed_servers',
rules: {
proxyForHttp: {
scheme: proxy.protocol,
host: proxy.host,
port: parseInt(proxy.port)
},
proxyForHttps: {
scheme: proxy.protocol,
host: proxy.host,
port: parseInt(proxy.port)
}
}
},
scope: 'regular'
});
}
export async function saveProxy(proxy: Omit<Proxy, 'id'>): Promise<Proxy> {
const newProxy: Proxy = {
...proxy,
id: uuidv4()
};
const proxies = await storage.proxies.get();
await storage.proxies.set([...proxies, newProxy]);
return newProxy;
}
export async function deleteProxy(proxyId: string): Promise<void> {
const proxies = await storage.proxies.get();
await storage.proxies.set(proxies.filter(p => p.id !== proxyId));
// 如果删除的是当前使用的代理,清除当前代理
const currentProxyId = await storage.currentProxyId.get();
if (currentProxyId === proxyId) {
await storage.currentProxyId.set(null);
await browser.proxy.settings.clear({});
}
}
```
## 相关资源
- [WXT 官方文档](mdc:https:/wxt.dev)
- [WXT GitHub 仓库](mdc:https:/github.com/wxt-dev/wxt)
- [Chrome 扩展 API 文档](mdc:https:/developer.chrome.com/docs/extensions/reference)
- [React 文档](mdc:https:/reactjs.org)
- [TypeScript 文档](mdc:https:/www.typescriptlang.org)
+24 -20
View File
@@ -3,28 +3,32 @@ build.crx
build.zip build.zip
build.pem build.pem
.idea/ .idea/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Logs
logs
# dependencies *.log
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/2.5.21_0
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
ord/ ord/
+3
View File
@@ -0,0 +1,3 @@
# WXT + React
This template should help get you started developing with React in WXT.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "yakit-chrome-client",
"description": "Yakit Browser Extension",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"antd": "^5.24.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"@types/uuid": "^10.0.0",
"@wxt-dev/module-react": "^1.1.3",
"typescript": "^5.8.3",
"wxt": "^0.20.0"
}
}
+5408
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+10
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+235
View File
@@ -0,0 +1,235 @@
.proxy-container {
min-width: 200px;
background: white;
}
.proxy-menu {
border: none !important;
box-shadow: none !important;
}
.menu-item {
height: 40px !important;
line-height: 40px !important;
margin: 0 !important;
padding: 0 16px !important;
}
.menu-item .anticon {
font-size: 16px;
color: var(--yakit-primary);
margin-right: 8px;
}
.menu-item-label {
font-size: 14px;
color: #333;
}
.menu-item:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item:hover .anticon,
.menu-item:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 选中状态 */
.menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
}
.menu-item.ant-menu-item-selected .anticon,
.menu-item.ant-menu-item-selected .menu-item-label {
color: white !important;
}
.menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
/* 分隔线 */
.ant-menu-item-divider {
margin: 4px 0 !important;
border-color: #EAECF3 !important;
}
/* 设置选项 */
.menu-item-setting {
border-top: 1px solid #EAECF3;
margin-top: 4px !important;
}
.menu-item-setting .anticon {
color: #666;
}
.menu-item-setting:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item-setting:hover .anticon,
.menu-item-setting:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 调整图标大小和对齐 */
.anticon {
font-size: 16px;
}
/* 添加以下样式来确保下拉菜单显示在正确的位置 */
.ant-dropdown {
position: absolute !important;
top: 100% !important;
left: 0 !important;
width: 100% !important;
min-width: 200px !important;
}
.dropdown-content {
background: white;
border-radius: 4px;
box-shadow: 0 3px 6px -4px rgba(0,0,0,0.12),
0 6px 16px 0 rgba(0,0,0,0.08),
0 9px 28px 8px rgba(0,0,0,0.05);
padding: 4px;
}
/* 确保容器不会限制弹出层 */
.proxy-container {
min-width: 200px;
background: white;
border-radius: 4px;
position: static;
}
/* 添加这个样式来确保下拉菜单显示在正确的位置 */
body {
position: relative;
}
.ant-menu {
border: none !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important;
padding: 4px 0 !important;
width: 180px !important;
background: white !important;
}
.ant-menu-item {
height: 28px !important;
line-height: 28px !important;
margin: 0 !important;
padding: 0 16px !important;
}
.ant-menu-item:hover {
background-color: #f5f5f5 !important;
}
.menu-icon {
margin-right: 8px;
display: inline-flex;
align-items: center;
}
.menu-item-selected {
background-color: #e6f7ff !important;
}
.ant-menu-item-divider {
margin: 4px 0 !important;
height: 1px !important;
background-color: #f0f0f0 !important;
}
.ant-menu-item:last-child {
margin-top: 4px !important;
border-top: 1px solid #f0f0f0;
}
/* 移除多余的样式 */
.ant-menu-root {
background: transparent !important;
}
/* 调整整体容器大小 */
.ant-menu-root {
width: 180px !important;
min-height: auto !important;
}
/* 添加代理按钮样式 */
.menu-item-add {
color: #666 !important;
}
.menu-item-add:hover {
background-color: #f5f5f5 !important;
color: var(--yakit-primary) !important;
}
.menu-item-add .anticon {
color: #666;
}
.menu-item-add:hover .anticon {
color: var(--yakit-primary);
}
.menu-loading {
pointer-events: none;
opacity: 0.7;
}
.menu-item-loading {
transition: all 0.3s ease;
}
.menu-item-selected {
transition: all 0.3s ease;
background-color: var(--yakit-primary-5) !important;
}
/* 添加过渡效果 */
.ant-menu-item {
transition: all 0.3s ease !important;
}
.ant-menu-item .menu-icon {
transition: color 0.3s ease;
}
.proxy-switch-container {
position: relative;
width: 180px;
overflow: hidden;
}
.panel-watermark {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
object-fit: contain;
object-position: right bottom;
z-index: 0;
}
/* 确保菜单项在水印上层 */
.ant-menu-item {
position: relative;
z-index: 1;
background: transparent !important;
}
/* 确保分割线在水印上层 */
.ant-menu-item-divider {
position: relative;
z-index: 1;
}
+254
View File
@@ -0,0 +1,254 @@
import React, {useEffect, useState} from 'react';
import {Menu} from 'antd';
import {DisconnectOutlined, SettingOutlined, PlusOutlined} from '@ant-design/icons';
import {browser,} from 'wxt/browser';
import type {MenuProps} from 'antd';
import type {ProxyConfig} from '@/types/proxy';
import {ContentActionType, ProxyActionType} from '@/types/action';
import './index.css';
// YAK 图标 URL
const YAK_ICON_URL = browser.runtime.getURL('/yak.svg');
// 固定的代理模式
const FIXED_MODES = [
{
key: 'direct',
name: '[直接连接]',
icon: <DisconnectOutlined/>,
color: '#666',
config: {
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
}
},
{
key: 'system',
name: '[系统代理]',
icon: <SettingOutlined/>,
color: '#666',
config: {
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: false
}
}
];
interface CustomProxy {
key: string;
name: string;
color: string;
config: ProxyConfig;
enabled?: boolean;
}
export const ProxySwitch: React.FC = () => {
const [initialized, setInitialized] = useState<boolean>(false);
const [currentMode, setCurrentMode] = useState<string>('');
const [customProxies, setCustomProxies] = useState<CustomProxy[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
// 监听存储变化
useEffect(() => {
const handleMessage = (message: any) => {
if (message.action === ContentActionType.PROXY_CONFIGS_UPDATED && message.source !== 'proxy_switch') {
loadCustomProxies();
}
};
browser.runtime.onMessage.addListener(handleMessage);
return () => {
browser.runtime.onMessage.removeListener(handleMessage);
};
}, []);
// 初始化
useEffect(() => {
const init = async () => {
await loadProxyStatus();
await loadCustomProxies();
setInitialized(true);
};
init();
}, []);
// 获取当前代理状态
const loadProxyStatus = async () => {
try {
const response = await browser.runtime.sendMessage({
action: ProxyActionType.GET_PROXY_STATUS
});
if (!response) {
console.log('No response from background script');
return;
}
if (response.success) {
const activeMode = response.data.mode;
setCurrentMode(activeMode);
}
} catch (error) {
console.error('Error loading proxy status:', error);
setCurrentMode('direct');
}
};
// 加载自定义代理配置
const loadCustomProxies = async () => {
try {
const DB_NAME = 'yaklang_extension';
const STORE_NAME = 'proxy_configs';
// 打开数据库
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
// 从数据库读取代理配置
const configs = await new Promise<ProxyConfig[]>((resolve, reject) => {
try {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
} catch (error) {
reject(error);
}
});
// 处理代理配置
const proxies = configs
.filter((proxy: ProxyConfig) => !FIXED_MODES.some(mode => mode.key === proxy.id))
.map((proxy: ProxyConfig): CustomProxy => ({
key: proxy.id,
name: proxy.name,
color: '#1890ff',
config: proxy,
enabled: proxy.enabled
}));
setCustomProxies(proxies);
const enabledProxy = configs.find((proxy: ProxyConfig) => proxy.enabled);
if (enabledProxy) {
setCurrentMode(enabledProxy.id);
}
} catch (error) {
console.error('Error loading custom proxies:', error);
setCustomProxies([]);
}
};
// 处理代理模式变更
const handleModeChange = async (mode: string) => {
if (mode === 'setting') {
// 打开设置页面
await browser.runtime.openOptionsPage?.();
return;
}
if (mode === 'add') {
// 打开添加代理表单
try {
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true
});
const optionsUrl = browser.runtime.getURL('/options.html');
if (activeTab?.url === optionsUrl) {
browser.tabs.sendMessage(activeTab.id!, {
action: ContentActionType.TRIGGER_ADD_PROXY
});
} else {
await browser.tabs.create({
url: optionsUrl
});
}
} catch (error) {
console.error('Failed to get current tab:', error);
}
return;
}
try {
setIsLoading(true);
// 发送切换代理请求
const response = await browser.runtime.sendMessage({
action: ProxyActionType.SWITCH_PROXY,
mode
});
if (response && response.success) {
setCurrentMode(mode);
}
} catch (error) {
console.error(`Error switching to proxy mode ${mode}:`, error);
} finally {
setIsLoading(false);
}
};
// 构建菜单项
const buildMenuItems = () => {
const items: MenuProps['items'] = [
...FIXED_MODES.map(mode => ({
key: mode.key,
label: mode.name,
icon: mode.icon,
})),
{type: 'divider'}
];
// 添加自定义代理
if (customProxies.length > 0) {
items.push(
...customProxies.map(proxy => ({
key: proxy.key,
label: proxy.name,
icon: <img src={YAK_ICON_URL} alt="YAK" style={{width: 16, height: 16}}/>,
}))
);
items.push({type: 'divider'});
}
// 添加设置选项
items.push({
key: 'setting',
label: '代理设置',
icon: <SettingOutlined/>,
});
// 添加新建代理选项
items.push({
key: 'add',
label: '添加代理',
icon: <PlusOutlined/>,
});
return items;
};
return (
<div className="proxy-switch-container">
<Menu
className="proxy-menu"
selectedKeys={[currentMode]}
items={buildMenuItems()}
onClick={({key}) => handleModeChange(key)}
/>
{isLoading && <div className="loading-overlay">...</div>}
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { browser, type Browser } from 'wxt/browser';
import {ContentActionType, ProxyActionType} from '@/types/action';
import { getCurrentProxyMode, switchProxyMode } from '@/utils/proxy';
export default defineBackground({
type: 'module',
main() {
// 初始化代理状态监听
browser.runtime.onMessage.addListener((message: any, sender: Browser.runtime.MessageSender, sendResponse: (response?: any) => void) => {
if (message.action === ProxyActionType.GET_PROXY_STATUS) {
// 获取当前代理状态
getCurrentProxyMode().then(mode => {
sendResponse({ success: true, data: { mode } });
});
return true;
} else if (message.action === ProxyActionType.SWITCH_PROXY) {
// 切换代理
switchProxyMode(message.mode).then(success => {
sendResponse({ success });
// 如果切换成功,广播代理状态更改消息
if (success) {
browser.runtime.sendMessage({
action: ContentActionType.PROXY_CONFIGS_UPDATED,
source: 'background'
});
}
});
return true;
}
});
console.log('代理管理后台服务已启动');
},
});
+6
View File
@@ -0,0 +1,6 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.');
},
});
+39
View File
@@ -0,0 +1,39 @@
.options-layout {
min-height: 100vh;
}
.options-header {
background-color: #F28B44;
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
}
.options-content {
padding: 24px;
max-width: 800px;
margin: 0 auto;
background-color: #f5f5f5;
}
.proxy-list-card {
margin-bottom: 24px;
}
.proxy-list-card .ant-list-item {
padding: 12px 24px;
transition: all 0.3s;
}
.proxy-list-card .ant-list-item:hover {
background-color: rgba(242, 139, 68, 0.05);
}
.add-proxy-card {
margin-bottom: 24px;
}
.ant-space {
width: 100%;
}
+242
View File
@@ -0,0 +1,242 @@
import React, { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, List, Button, Form, Input, Select, Space, Card, Divider } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { v4 as uuidv4 } from 'uuid';
import { browser } from 'wxt/browser';
import type { ProxyConfig } from '../../types/proxy';
import { getAllProxyConfigs, saveProxyConfig, deleteProxyConfig, enableProxyConfig } from '../../utils/storage';
import {ContentActionType, ProxyActionType} from '../../types/action';
import './App.css';
const { Header, Content } = Layout;
const { Title } = Typography;
const { Option } = Select;
export default function App() {
const [proxies, setProxies] = useState<ProxyConfig[]>([]);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
loadProxies();
// 监听添加代理请求
const handleMessage = (message: any) => {
if (message.action === ContentActionType.TRIGGER_ADD_PROXY) {
document.getElementById('add-proxy-form')?.scrollIntoView({ behavior: 'smooth' });
}
};
browser.runtime.onMessage.addListener(handleMessage);
return () => {
browser.runtime.onMessage.removeListener(handleMessage);
};
}, []);
const loadProxies = async () => {
try {
const configs = await getAllProxyConfigs();
setProxies(configs.filter(config => config.proxyType !== 'direct' && config.proxyType !== 'system'));
} catch (error) {
console.error('Error loading proxies:', error);
}
};
const handleSave = async (values: any) => {
try {
setLoading(true);
const newProxy: ProxyConfig = {
id: uuidv4(),
name: values.name,
proxyType: values.proxyType,
host: values.host,
port: Number(values.port),
enabled: false
};
// if (values.username) newProxy.username = values.username;
// if (values.password) newProxy.password = values.password;
await saveProxyConfig(newProxy);
// 重新加载代理列表
await loadProxies();
// 重置表单
form.resetFields();
// 通知后台脚本
browser.runtime.sendMessage({
action: ContentActionType.PROXY_CONFIGS_UPDATED,
source: 'options'
});
} catch (error) {
console.error('Error saving proxy:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteProxyConfig(id);
await loadProxies();
// 通知后台脚本
browser.runtime.sendMessage({
action: ContentActionType.PROXY_CONFIGS_UPDATED,
source: 'options'
});
} catch (error) {
console.error(`Error deleting proxy ${id}:`, error);
}
};
const handleActivate = async (id: string) => {
try {
// 启用代理
await enableProxyConfig(id);
// 发送切换代理请求
await browser.runtime.sendMessage({
action: ProxyActionType.SWITCH_PROXY,
mode: id
});
// 重新加载代理列表
await loadProxies();
} catch (error) {
console.error(`Error activating proxy ${id}:`, error);
}
};
return (
<ConfigProvider
theme={{
token: {
colorPrimary: "#F28B44",
},
}}
>
<Layout className="options-layout">
<Header className="options-header">
<Title level={3} style={{ color: 'white', margin: 0 }}>Yaklang </Title>
</Header>
<Content className="options-content">
<Card className="proxy-list-card" title="已保存的代理">
<List
dataSource={proxies}
renderItem={(proxy) => (
<List.Item
key={proxy.id}
actions={[
<Button
type="primary"
onClick={() => handleActivate(proxy.id)}
disabled={proxy.enabled}
>
{proxy.enabled ? '已启用' : '启用'}
</Button>,
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(proxy.id)}
/>
]}
>
<List.Item.Meta
title={proxy.name}
description={`${proxy.proxyType}://${proxy.host}:${proxy.port}`}
/>
</List.Item>
)}
/>
</Card>
<Divider />
<Card id="add-proxy-form" className="add-proxy-card" title="添加新代理">
<Form
form={form}
layout="vertical"
onFinish={handleSave}
>
<Form.Item
name="name"
label="代理名称"
rules={[{ required: true, message: '请输入代理名称' }]}
>
<Input placeholder="例如: 公司内网代理" />
</Form.Item>
<Form.Item
name="proxyType"
label="代理类型"
initialValue="http"
rules={[{ required: true, message: '请选择代理类型' }]}
>
<Select>
<Option value="http">HTTP</Option>
<Option value="https">HTTPS</Option>
<Option value="socks4">SOCKS4</Option>
<Option value="socks5">SOCKS5</Option>
</Select>
</Form.Item>
<Space style={{ display: 'flex' }}>
<Form.Item
name="host"
label="主机地址"
rules={[{ required: true, message: '请输入主机地址' }]}
style={{ flex: 3 }}
>
<Input placeholder="例如: proxy.example.com 或 192.168.1.100" />
</Form.Item>
<Form.Item
name="port"
label="端口"
rules={[{ required: true, message: '请输入端口' }]}
style={{ flex: 1 }}
>
<Input placeholder="例如: 8080" />
</Form.Item>
</Space>
<Space style={{ display: 'flex' }}>
<Form.Item
name="username"
label="用户名 (可选)"
style={{ flex: 1 }}
>
<Input placeholder="认证用户名" />
</Form.Item>
<Form.Item
name="password"
label="密码 (可选)"
style={{ flex: 1 }}
>
<Input.Password placeholder="认证密码" />
</Form.Item>
</Space>
<Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<PlusOutlined />}
loading={loading}
block
>
</Button>
</Form.Item>
</Form>
</Card>
</Content>
</Layout>
</ConfigProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yaklang 代理管理设置</title>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './style.css';
const root = createRoot(document.getElementById('app')!);
root.render(<App />);
+9
View File
@@ -0,0 +1,9 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
* {
box-sizing: border-box;
}
+73
View File
@@ -0,0 +1,73 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #54bc4ae0);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
.popup-container {
width: 320px;
min-height: 400px;
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
.popup-header {
background-color: #F28B44;
color: white;
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.popup-header h1 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.popup-content {
flex: 1;
padding: 0;
background-color: #fff;
overflow: auto;
}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { ConfigProvider } from 'antd';
import { ProxySwitch } from '../../components/ProxySwitch';
import './App.css';
export default function App() {
return (
<ConfigProvider
theme={{
token: {
colorPrimary: "#F28B44",
},
}}
>
<div className="popup-container">
<header className="popup-header">
<h1>Yaklang </h1>
</header>
<main className="popup-content">
<ProxySwitch />
</main>
</div>
</ConfigProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+69
View File
@@ -0,0 +1,69 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+303
View File
@@ -0,0 +1,303 @@
/* Base styles for the proxy panel */
.yak-proxy-root * {
all: initial;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: normal;
margin: 0;
padding: 0;
border: none;
outline: none;
}
.floating-panel {
position: fixed;
top: 30%;
right: 0;
transform: translateY(-30%);
background: white;
z-index: 2147483647;
width: 50px;
height: 40px;
overflow: hidden;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s ease;
box-sizing: border-box;
}
/* Non-expanded state */
.floating-panel:not(.expanded):not(.dragging) {
border-radius: 50px 0 0 50px;
box-shadow: -4px 0 20px rgba(0,0,0,0.15);
border: 1px solid #eee;
border-right: none;
}
/* Dragging state */
.floating-panel.dragging {
cursor: grabbing;
user-select: none;
opacity: 0.95;
transition: none;
}
/* Hover state */
.floating-panel:not(.expanded):hover {
width: 120px;
background: #fff7e6;
border-color: #ffd591;
}
/* Expanded state */
.floating-panel.expanded {
width: 180px;
height: auto;
max-height: 400px;
border-radius: 8px 0 0 8px;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
border: 1px solid #eee;
border-right: none;
}
/* Panel header */
.panel-header {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
padding: 0 8px;
cursor: pointer;
user-select: none;
}
/* Header in expanded state */
.floating-panel.expanded .panel-header {
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.header-content {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
/* Yak icon */
.yak-icon {
width: 36px;
height: 36px;
min-width: 36px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.floating-panel.expanded .yak-icon {
width: 24px;
height: 24px;
min-width: 24px;
}
/* Active proxy info */
.active-proxy-info {
display: flex;
align-items: center;
margin-left: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #ff6b00;
font-size: 13px;
font-weight: 500;
}
.active-proxy-info span:first-child {
margin-right: 6px;
}
.active-proxy-info span:nth-child(2) {
color: #333;
}
/* Panel content */
.panel-content {
display: none;
background: white;
overflow-y: auto;
max-height: 360px;
opacity: 0;
transition: opacity 0.2s ease;
}
.floating-panel.expanded .panel-content {
display: block;
opacity: 1;
}
/* Scrollbar styles */
.panel-content::-webkit-scrollbar {
width: 4px;
}
.panel-content::-webkit-scrollbar-track {
background: #f5f5f5;
}
.panel-content::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 4px;
}
.panel-content::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
/* Proxy item */
.proxy-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
position: relative;
}
.proxy-item:hover {
background: #fff7e6;
}
.proxy-item.active {
background: #fff7e6;
color: #ff6b00;
}
.proxy-item.active span {
color: #ff6b00;
}
.proxy-item span:first-child {
margin-right: 8px;
font-size: 16px;
}
.proxy-item span {
color: #333;
}
.proxy-status {
position: absolute;
right: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #52c41a;
box-shadow: 0 0 4px rgba(82,196,26,0.3);
}
.proxy-item.active .proxy-status {
background: #ff6b00;
box-shadow: 0 0 4px rgba(255,107,0,0.3);
}
/* Divider */
.divider {
height: 1px;
background: #f0f0f0;
margin: 4px 0;
}
/* Action buttons */
.action-button {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
color: #666;
}
.action-button:hover {
background: #fff7e6;
color: #ff6b00;
}
.action-button:hover span {
color: #ff6b00;
}
.action-button span:first-child {
margin-right: 8px;
}
.action-button span {
color: #666;
}
/* Tab container */
.tabs-container {
display: flex;
height: 100%;
min-height: 200px;
}
.tab-list {
width: 40px;
background: #f8f9fa;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 8px;
position: sticky;
top: 0;
align-self: flex-start;
height: 100%;
flex-shrink: 0;
}
.tab-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background: transparent;
border: none;
padding: 0;
}
.tab-button:hover {
background: #fff7e6;
}
.tab-button.active {
background: #fff7e6;
color: #ff6b00;
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
}
.tab-panel {
display: none;
height: 100%;
overflow: hidden;
flex-direction: column;
}
.tab-panel.active {
display: flex;
}
+356
View File
@@ -0,0 +1,356 @@
import React, {useState, useEffect, useRef} from 'react';
import {browser} from 'wxt/browser';
import type {ProxyConfig} from '@/types/proxy.ts';
// Constants - using string literal instead of getURL since it will be replaced at build time
const YAK_ICON_URL = browser.runtime.getURL("/yak.svg");
// Action types from the application
const ProxyActionType = {
SET_PROXY_CONFIG: "SET_PROXY_CONFIG",
CLEAR_PROXY_CONFIG: "CLEAR_PROXY_CONFIG",
GET_PROXY_STATUS: "GET_PROXY_STATUS",
GET_PROXY_CONFIGS: "GET_PROXY_CONFIGS",
UPDATE_PROXY_CONFIG: "UPDATE_PROXY_CONFIG",
};
// Export anonymous component directly as default export
const App: React.FC = () => {
// State
const [expanded, setExpanded] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [activeTab, setActiveTab] = useState('proxy');
const [proxyStatus, setProxyStatus] = useState({
enable: false,
proxy: '',
currentMode: 'direct'
});
const [proxyConfigs, setProxyConfigs] = useState<ProxyConfig[]>([]);
// Refs
const panelRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({y: 0, top: 0});
const timeoutRef = useRef<number | null>(null);
// Setup message listener for updates
useEffect(() => {
const messageListener = async (message: any) => {
if (message.action === "PROXY_STATUS_CHANGED" || message.action === "PROXY_CONFIGS_UPDATED") {
await fetchProxyStatus();
await fetchProxyConfigs();
}
};
browser.runtime.onMessage.addListener(messageListener);
// Initial data fetch
fetchProxyStatus();
fetchProxyConfigs();
// Position from localStorage if available
const savedPosition = localStorage.getItem("yakitProxyPanelPosition");
if (savedPosition && panelRef.current) {
const top = (parseFloat(savedPosition) / 100) * window.innerHeight;
panelRef.current.style.top = `${top}px`;
panelRef.current.style.transform = 'translateY(0)';
}
return () => {
browser.runtime.onMessage.removeListener(messageListener);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Fetch current proxy status
const fetchProxyStatus = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_STATUS,
});
if (response && response.success) {
const status = response.data;
setProxyStatus({
enable: status.enabled,
proxy: status.mode === "system" ? "system" : "",
currentMode: status.mode || "direct",
});
}
} catch (error) {
console.error("Error fetching proxy status:", error);
}
};
// Fetch proxy configurations
const fetchProxyConfigs = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_CONFIGS,
});
if (response && response.success) {
setProxyConfigs(response.data || []);
}
} catch (error) {
console.error("Error fetching proxy configs:", error);
}
};
// Send message with retry logic
const sendMessageWithRetry = async (message: any, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await browser.runtime.sendMessage(message);
} catch (error) {
console.warn(`Attempt ${i + 1} failed:`, error);
if (i === maxRetries - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
};
// Handle switching to a different proxy
const handleProxySwitch = async (config: ProxyConfig) => {
try {
await sendMessageWithRetry({
action: ProxyActionType.SET_PROXY_CONFIG,
config,
});
// Update the UI
await fetchProxyStatus();
} catch (error) {
console.error("Error switching proxy:", error);
}
};
// Open options page
const openOptionsPage = async (triggerAdd = false) => {
try {
await sendMessageWithRetry({
action: "OPEN_OPTIONS_PAGE",
triggerAdd,
});
} catch (error) {
console.error("Error opening options page:", error);
}
};
// Handle dragging functionality
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (expanded) {
setExpanded(false);
return;
}
if (e.button !== 0) return; // Only left mouse button
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
dragStartRef.current = {
y: e.clientY,
top: rect.top,
};
}
e.preventDefault();
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging) return;
const deltaY = e.clientY - dragStartRef.current.y;
const newTop = dragStartRef.current.top + deltaY;
// Limit drag range to viewport
const maxTop = window.innerHeight - (panelRef.current?.offsetHeight || 0);
const boundedTop = Math.max(0, Math.min(newTop, maxTop));
if (panelRef.current) {
panelRef.current.style.top = `${boundedTop}px`;
panelRef.current.style.transform = 'translateY(0)';
}
};
const handleMouseUp = () => {
if (!isDragging) return;
setIsDragging(false);
// Save position
if (panelRef.current) {
const top = panelRef.current.getBoundingClientRect().top;
const percentage = (top / window.innerHeight) * 100;
localStorage.setItem("yakitProxyPanelPosition", percentage.toString());
}
};
// Handle mouse enter to clear any auto-collapse timeouts
const handleMouseEnter = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Handle mouse leave to auto-collapse the panel
const handleMouseLeave = () => {
if (expanded) {
timeoutRef.current = window.setTimeout(() => {
setExpanded(false);
timeoutRef.current = null;
}, 300);
}
};
// Get active proxy name and icon
let proxyIcon = "🟢";
let proxyName = "直接连接";
if (proxyStatus.currentMode === "system") {
proxyIcon = "⚙️";
proxyName = "系统代理";
} else if (proxyStatus.currentMode === "fixed_servers") {
const activeConfig = proxyConfigs.find(c => c.enabled);
if (activeConfig) {
proxyIcon = activeConfig.proxyType === "pac_script" ? "📜" : "🌐";
proxyName = activeConfig.name || "未命名代理";
}
}
return (
<div
ref={panelRef}
className={`floating-panel ${expanded ? 'expanded' : ''} ${isDragging ? 'dragging' : ''}`}
data-active-tab={activeTab}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
<div
className="panel-header"
onMouseDown={handleMouseDown}
onClick={() => !isDragging && setExpanded(!expanded)}
>
<div className="header-content">
<img src={YAK_ICON_URL} className="yak-icon" alt="Yak"/>
<div className="active-proxy-info">
<span>{proxyIcon}</span>
<span>{proxyName}</span>
</div>
</div>
</div>
{expanded && (
<div className="panel-content">
<div className="tabs-container">
<div className="tab-list">
<button
className={`tab-button ${activeTab === 'proxy' ? 'active' : ''}`}
onClick={() => setActiveTab('proxy')}
title="代理设置"
>
🌐
</button>
<button
className={`tab-button ${activeTab === 'links' ? 'active' : ''}`}
onClick={() => setActiveTab('links')}
title="页面链接"
>
🔗
</button>
</div>
<div className="tab-content">
<div className={`tab-panel ${activeTab === 'proxy' ? 'active' : ''}`} data-panel="proxy">
<div
className={`proxy-item ${proxyStatus.currentMode === 'direct' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
})}
title="直接连接"
>
<span>🟢</span>
<span></span>
{proxyStatus.currentMode === 'direct' && <div className="proxy-status"></div>}
</div>
<div
className={`proxy-item ${proxyStatus.currentMode === 'system' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: true
})}
title="系统代理"
>
<span></span>
<span></span>
{proxyStatus.currentMode === 'system' && <div className="proxy-status"></div>}
</div>
<div className="divider"></div>
{proxyConfigs.map(config => {
if (config.proxyType !== 'direct' && config.proxyType !== 'system') {
const isActive = proxyStatus.currentMode === 'fixed_servers' && config.enabled;
const proxyIcon = config.proxyType === 'pac_script' ? '📜' : '🌐';
const tooltipText = config.proxyType === 'pac_script'
? 'PAC Script'
: `${config.scheme ? `${config.scheme.toUpperCase()} ` : ''}${config.host}:${config.port}`;
return (
<div
key={config.id}
className={`proxy-item ${isActive ? 'active' : ''}`}
onClick={() => handleProxySwitch({...config, enabled: true})}
title={tooltipText}
>
<span>{proxyIcon}</span>
<span>{config.name || '未命名代理'}</span>
{isActive && <div className="proxy-status"></div>}
</div>
);
}
return null;
})}
<div className="divider"></div>
<div className="action-button" onClick={() => openOptionsPage(true)}>
<span></span>
<span></span>
</div>
<div className="action-button" onClick={() => openOptionsPage(false)}>
<span></span>
<span></span>
</div>
</div>
<div className={`tab-panel ${activeTab === 'links' ? 'active' : ''}`} data-panel="links">
{/* Links panel content will be added in the future */}
<div className="links-placeholder" style={{padding: '16px', textAlign: 'center'}}>
<p></p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default App;
+39
View File
@@ -0,0 +1,39 @@
import './App.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
cssInjectionMode: 'ui',
async main(ctx) {
console.log("Proxy content script starting...");
// Define your UI with shadow root for isolation
const ui = await createShadowRootUi(ctx, {
name: 'yakit-proxy-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create a wrapper div for the React app
const app = document.createElement('div');
app.id = 'yakit-proxy-root';
app.className = 'yak-proxy-root';
container.append(app);
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root?.unmount();
},
});
// Mount the UI
ui.mount();
},
});
+19
View File
@@ -0,0 +1,19 @@
export const ProxyActionType = {
SET_PROXY_CONFIG: "SET_PROXY_CONFIG",
CLEAR_PROXY_CONFIG: "CLEAR_PROXY_CONFIG",
GET_PROXY_STATUS: "GET_PROXY_STATUS",
CLEAR_PROXY_LOGS: "CLEAR_PROXY_LOGS",
GET_PROXY_LOGS: "GET_PROXY_LOGS",
GET_PROXY_CONFIGS: "GET_PROXY_CONFIGS",
ADD_PROXY_CONFIG: "ADD_PROXY_CONFIG",
UPDATE_PROXY_CONFIG: "UPDATE_PROXY_CONFIG",
SWITCH_PROXY: "SWITCH_PROXY",
} as const;
export const ContentActionType = {
PROXY_CONFIGS_UPDATED: "PROXY_CONFIGS_UPDATED",
PROXY_STATUS_CHANGED: "PROXY_STATUS_CHANGED",
TRIGGER_ADD_PROXY: "TRIGGER_ADD_PROXY",
}
export type ProxyActionType = typeof ProxyActionType[keyof typeof ProxyActionType];
+45
View File
@@ -0,0 +1,45 @@
export interface PacScript {
data?: string;
url?: string;
mandatory?: boolean;
}
export interface ProxyConfig {
id: string;
name: string;
enabled: boolean;
proxyType: "direct" | "system" | "fixed_servers" | "pac_script" | "auto_detect";
mode?: string;
host?: string;
port?: number;
scheme?: "http" | "https" | "socks4" | "socks5";
pacScript?: PacScript;
bypassList?: string[];
matchList?: string[];
}
export interface ProxyLog {
id: string;
timestamp: number;
url: string;
proxyId: string;
proxyName: string;
status: 'success' | 'error';
errorMessage?: string;
method?: string;
requestHeaders?: Record<string, string>;
requestBody?: string;
responseHeaders?: Record<string, string>;
responseBody?: string;
timing?: {
startTime: number;
endTime: number;
duration: number;
};
protocol?: string;
ip?: string;
fromCache?: boolean;
host?: string;
port?: number;
resourceType?: 'xhr' | 'fetch' | 'script' | 'stylesheet' | 'image' | 'other';
}
+106
View File
@@ -0,0 +1,106 @@
import { browser } from 'wxt/browser';
import type { ProxyConfig } from '../types/proxy';
import { getAllProxyConfigs, getProxyConfig, enableProxyConfig, disableAllProxies } from './storage';
/**
* 获取当前激活的代理模式
* @returns 返回当前的代理模式(direct, system, 或代理ID
*/
export async function getCurrentProxyMode(): Promise<string> {
try {
const configs = await getAllProxyConfigs();
const enabledProxy = configs.find(config => config.enabled);
if (enabledProxy) {
return enabledProxy.id;
}
// 如果没有启用的代理,返回直接连接模式
return 'direct';
} catch (error) {
console.error('Error getting current proxy mode:', error);
return 'direct';
}
}
/**
* 切换到指定的代理模式
* @param mode 代理模式(ID, direct, 或 system
* @returns 是否成功切换
*/
export async function switchProxyMode(mode: string): Promise<boolean> {
try {
if (mode === 'direct') {
// 清除所有代理设置
await disableAllProxies();
await browser.proxy.settings.clear({});
return true;
}
if (mode === 'system') {
// 使用系统代理
await disableAllProxies();
await browser.proxy.settings.set({
value: { mode: 'system' },
scope: 'regular'
});
return true;
}
// 使用自定义代理
const config = await getProxyConfig(mode);
if (!config) {
console.error(`Proxy config with ID ${mode} not found`);
return false;
}
// 启用选定的代理配置
await enableProxyConfig(config.id);
// 设置代理
if (config.proxyType === 'direct') {
await browser.proxy.settings.clear({});
} else if (config.proxyType === 'system') {
await browser.proxy.settings.set({
value: { mode: 'system' },
scope: 'regular'
});
} else {
// 构建代理配置
const proxyConfig = {
mode: 'fixed_servers',
rules: {}
};
// 添加代理规则
const proxyRule = {
scheme: config.proxyType,
host: config.host || '',
port: config.port || 80
};
// 添加认证信息
// if (config.username && config.password) {
// proxyRule.username = config.username;
// proxyRule.password = config.password;
// }
// 设置代理规则
proxyConfig.rules = {
singleProxy: proxyRule,
bypassList: ['localhost', '127.0.0.1']
};
// 应用代理设置
await browser.proxy.settings.set({
value: proxyConfig,
scope: 'regular'
});
}
return true;
} catch (error) {
console.error(`Error switching to proxy mode ${mode}:`, error);
return false;
}
}
+133
View File
@@ -0,0 +1,133 @@
import type { ProxyConfig } from '../types/proxy';
// 数据库名称和存储名称
const DB_NAME = 'yaklang_extension';
const PROXY_STORE_NAME = 'proxy_configs';
// 打开数据库
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 如果存储不存在,创建它
if (!db.objectStoreNames.contains(PROXY_STORE_NAME)) {
db.createObjectStore(PROXY_STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
});
}
// 获取所有代理配置
export async function getAllProxyConfigs(): Promise<ProxyConfig[]> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PROXY_STORE_NAME], 'readonly');
const store = transaction.objectStore(PROXY_STORE_NAME);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
} catch (error) {
console.error('Error getting proxy configs:', error);
return [];
}
}
// 获取单个代理配置
export async function getProxyConfig(id: string): Promise<ProxyConfig | null> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PROXY_STORE_NAME], 'readonly');
const store = transaction.objectStore(PROXY_STORE_NAME);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
} catch (error) {
console.error(`Error getting proxy config ${id}:`, error);
return null;
}
}
// 保存代理配置
export async function saveProxyConfig(config: ProxyConfig): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PROXY_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PROXY_STORE_NAME);
const request = store.put(config);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(true);
});
} catch (error) {
console.error('Error saving proxy config:', error);
return false;
}
}
// 删除代理配置
export async function deleteProxyConfig(id: string): Promise<boolean> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([PROXY_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PROXY_STORE_NAME);
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(true);
});
} catch (error) {
console.error(`Error deleting proxy config ${id}:`, error);
return false;
}
}
// 启用指定的代理,禁用其他
export async function enableProxyConfig(id: string): Promise<boolean> {
try {
const configs = await getAllProxyConfigs();
for (const config of configs) {
const updated = { ...config, enabled: config.id === id };
await saveProxyConfig(updated);
}
return true;
} catch (error) {
console.error(`Error enabling proxy config ${id}:`, error);
return false;
}
}
// 禁用所有代理
export async function disableAllProxies(): Promise<boolean> {
try {
const configs = await getAllProxyConfigs();
for (const config of configs) {
if (config.enabled) {
const updated = { ...config, enabled: false };
await saveProxyConfig(updated);
}
}
return true;
} catch (error) {
console.error('Error disabling all proxies:', error);
return false;
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
srcDir: 'src',
modules: ['@wxt-dev/module-react'],
manifest: {
name: 'Yaklang 代理管理',
description: '一个用于快速切换浏览器代理设置的扩展',
version: '0.1.0',
permissions: [
'proxy',
'storage',
'tabs'
],
host_permissions: [
'<all_urls>'
],
web_accessible_resources: [
{
resources: ['yak.svg'],
matches: ['<all_urls>']
}
],
icons: {
"16": "icon/icon16.png",
"48": "icon/icon48.png",
"128": "icon/icon128.png"
},
}
});