Compare commits
205 Commits
v2.0.3
...
feature/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cdc2a5cf | ||
|
|
7522ba3e03 | ||
|
|
3ac3f122eb | ||
|
|
67db492330 | ||
|
|
358d6e001e | ||
|
|
8a26cb51d8 | ||
|
|
9f8c745f8c | ||
|
|
3a9a8036d2 | ||
|
|
04e81ebbe3 | ||
|
|
c6e4f3599e | ||
|
|
60eb9ce2a4 | ||
|
|
50244f0055 | ||
|
|
eca14db58c | ||
|
|
463e430a3d | ||
|
|
32e66e054b | ||
|
|
2a9f093210 | ||
|
|
9bf216b102 | ||
|
|
b69d7f7979 | ||
|
|
efff780eea | ||
|
|
19dcc84c83 | ||
|
|
4e9e63f524 | ||
|
|
1d1440f52f | ||
|
|
36b78d1b4b | ||
|
|
2b59a5d51b | ||
|
|
15c12c8e65 | ||
|
|
3256b2f842 | ||
|
|
7374b934c7 | ||
|
|
d9d7c5c342 | ||
|
|
f4f7e10953 | ||
|
|
6ad7e04a95 | ||
|
|
7122e10646 | ||
|
|
bb685be43d | ||
|
|
c5b3b4027f | ||
|
|
daba6b094b | ||
|
|
711ad843ce | ||
|
|
189a70280f | ||
|
|
7ccef5f385 | ||
|
|
85ba24f1c3 | ||
|
|
0d2dedbb6d | ||
|
|
d76c675feb | ||
|
|
9372ecd3c6 | ||
|
|
d0b654f63e | ||
|
|
f035796654 | ||
|
|
160da2729e | ||
|
|
14db6b8a8f | ||
|
|
d91bbb122c | ||
|
|
6df5dfc123 | ||
|
|
c8327f7632 | ||
|
|
4a0e63d0b7 | ||
|
|
e63b4e069b | ||
|
|
687c7de111 | ||
|
|
876605e983 | ||
|
|
442b05507c | ||
|
|
eca9c02147 | ||
|
|
9fbce5d0cf | ||
|
|
c597b9b122 | ||
|
|
54b88d9c89 | ||
|
|
319e5fa61a | ||
|
|
310086d5c9 | ||
|
|
4297703ebe | ||
|
|
ca7ce99702 | ||
|
|
af8b9289fe | ||
|
|
bf7e13d4e9 | ||
|
|
b015af173a | ||
|
|
4a4779a7e7 | ||
|
|
92a39a1a34 | ||
|
|
ea56794a37 | ||
|
|
fd4864115c | ||
|
|
74d4b42936 | ||
|
|
a95f974787 | ||
|
|
29057c1fe0 | ||
|
|
63285acba8 | ||
|
|
f99b614888 | ||
|
|
41f3aa7d76 | ||
|
|
f23898a5c9 | ||
|
|
664391568c | ||
|
|
081aabe10f | ||
|
|
036069a5c1 | ||
|
|
9b7091ba88 | ||
|
|
2357d976dc | ||
|
|
df43692bb9 | ||
|
|
6ed9cf47df | ||
|
|
a3582f54e9 | ||
|
|
9ff7516c51 | ||
|
|
a1a16be2aa | ||
|
|
df7d818514 | ||
|
|
c0d9d0296d | ||
|
|
77a65aaad8 | ||
|
|
3ce847d2e0 | ||
|
|
1482dc9e66 | ||
|
|
fa2b11fcc2 | ||
|
|
02bfc97ee6 | ||
|
|
77bdeb02fb | ||
|
|
48bd37a74b | ||
|
|
7346fcde2c | ||
|
|
5af476d376 | ||
|
|
07b870488d | ||
|
|
74ab14f572 | ||
|
|
a14f7ef9b2 | ||
|
|
07dd70570e | ||
|
|
37d4c9b48d | ||
|
|
da4f7b5fe4 | ||
|
|
e119d1cb31 | ||
|
|
54003d69e2 | ||
|
|
ab6be1d510 | ||
|
|
a1dfdf4e68 | ||
|
|
464ca70d7b | ||
|
|
2dca85c881 | ||
|
|
837435223a | ||
|
|
79ad0b9368 | ||
|
|
5624a2d11a | ||
|
|
29367ff576 | ||
|
|
64c94804ee | ||
|
|
1d9fb7bf26 | ||
|
|
30a441d9ec | ||
|
|
33753c72cd | ||
|
|
02d7eca2ad | ||
|
|
2c6fe6c31a | ||
|
|
ab71b11532 | ||
|
|
a858596fa2 | ||
|
|
5176134c28 | ||
|
|
79370dd8a1 | ||
|
|
3c32f12152 | ||
|
|
64f7e47b20 | ||
|
|
25c112856d | ||
|
|
3665a79e50 | ||
|
|
4dce31aff7 | ||
|
|
451ca949ec | ||
|
|
a9ff8ce01c | ||
|
|
7848248df7 | ||
|
|
b00e8de26f | ||
|
|
47b06b7773 | ||
|
|
4e66f0c105 | ||
|
|
84c7726940 | ||
|
|
b8f59a4740 | ||
|
|
06a19519c5 | ||
|
|
b4ebb7c9e5 | ||
|
|
5edc3e07a4 | ||
|
|
417dcc1d37 | ||
|
|
72f6068e86 | ||
|
|
97e7f34260 | ||
|
|
74babf9730 | ||
|
|
30fe800ebe | ||
|
|
c98a724935 | ||
|
|
0cb89c8f67 | ||
|
|
7b5d5c6ce1 | ||
|
|
eea5e4123b | ||
|
|
c10ace7a84 | ||
|
|
0e803b53d8 | ||
|
|
49d8787ab9 | ||
|
|
a05fefb54c | ||
|
|
3574fa07cb | ||
|
|
b64b86f3ca | ||
|
|
2cf116280f | ||
|
|
73cf337c42 | ||
|
|
fa2d64b692 | ||
|
|
9c17be1b59 | ||
|
|
fe1574a026 | ||
|
|
9254c5d291 | ||
|
|
642e7a3817 | ||
|
|
5e2e80b00d | ||
|
|
2a43f1f54d | ||
|
|
7e6ce83158 | ||
|
|
6932e89ea8 | ||
|
|
d144d5c2fc | ||
|
|
adee37ab66 | ||
|
|
dcf49cc094 | ||
|
|
f8e39594fa | ||
|
|
374649750b | ||
|
|
6d26115368 | ||
|
|
606ee67778 | ||
|
|
57d21fabcf | ||
|
|
001664c67d | ||
|
|
616e230218 | ||
|
|
70f9a68e5c | ||
|
|
78bc0a1a31 | ||
|
|
dac8ebe03b | ||
|
|
9f370bf429 | ||
|
|
bac2c3db36 | ||
|
|
326e975748 | ||
|
|
b5696b4511 | ||
|
|
ef7e9d2f73 | ||
|
|
d78013562c | ||
|
|
d3adfc480d | ||
|
|
731cfc47be | ||
|
|
95b3746e49 | ||
|
|
c8670aede6 | ||
|
|
95549473bd | ||
|
|
f3f484a04b | ||
|
|
1458f1e45d | ||
|
|
0301d1aee7 | ||
|
|
224d7a8be0 | ||
|
|
c4791ff523 | ||
|
|
55c62a3753 | ||
|
|
12fa80e002 | ||
|
|
29581b85d9 | ||
|
|
88e69e844a | ||
|
|
2a658af5b9 | ||
|
|
1402fd0cc5 | ||
|
|
8a3133be43 | ||
|
|
f64320fbd6 | ||
|
|
3479780639 | ||
|
|
1b0ab269fb | ||
|
|
6706889387 | ||
|
|
093e54f23c |
38
.gitattributes
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.txt text eol=lf
|
||||
|
||||
# TypeScript/JavaScript files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
|
||||
# HTML/CSS files
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
|
||||
# Shell scripts
|
||||
*.sh text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
412
.github/workflows/release.yml
vendored
@@ -8,100 +8,382 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win32
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
|
||||
- os: windows-2022
|
||||
- os: ubuntu-22.04
|
||||
- os: macos-14
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add macOS targets
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
- name: Install Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sudo apt-get update
|
||||
# Core build tools and pkg-config
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
patchelf \
|
||||
libssl-dev
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libayatana-appindicator3-dev
|
||||
# WebKit2GTK (version differs across Ubuntu images; try 4.1 then 4.0)
|
||||
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
|
||||
# libsoup also changed major version; prefer 3.0 with fallback to 2.4
|
||||
sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
pnpm run build
|
||||
pnpm run dist
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
|
||||
- name: List build files (debug)
|
||||
|
||||
- name: Prepare Tauri signing key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing release directory:"
|
||||
ls -la release/ || echo "No release directory found"
|
||||
find . -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" || echo "No build files found"
|
||||
|
||||
# 调试:检查 Secret 是否存在
|
||||
if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then
|
||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2
|
||||
echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
|
||||
# 目标:提供正确的私钥“文件路径”给 Tauri CLI,避免内容解码歧义
|
||||
KEY_PATH="$RUNNER_TEMP/tauri_signing.key"
|
||||
# 情况 1:原始两行文本(第一行以 "untrusted comment:" 开头)
|
||||
if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then
|
||||
printf '%s\n' "$RAW" > "$KEY_PATH"
|
||||
echo "✅ 使用原始两行密钥文件格式"
|
||||
else
|
||||
# 情况 2:整体被 base64 包裹(解包后应当是两行)
|
||||
if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \
|
||||
&& echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then
|
||||
printf '%s\n' "$DECODED" > "$KEY_PATH"
|
||||
echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件"
|
||||
else
|
||||
# 情况 3:已是第二行(纯 Base64 一行)→ 构造两行文件
|
||||
if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then
|
||||
ONE=$(printf '%s' "$RAW" | tr -d '\r\n')
|
||||
printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH"
|
||||
echo "✅ 使用一行 Base64 私钥,已构造两行文件"
|
||||
else
|
||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64,亦非一行 base64" >&2
|
||||
echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 将“完整两行内容”作为环境变量注入(Tauri 支持传入完整私钥文本或文件路径)
|
||||
# 使用多行写入语法,保持换行以便解析
|
||||
# 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量
|
||||
if command -v base64 >/dev/null 2>&1; then
|
||||
KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n')
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
KEY_B64=$(openssl base64 -A -in "$KEY_PATH")
|
||||
else
|
||||
KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))")
|
||||
fi
|
||||
if [ -z "$KEY_B64" ]; then
|
||||
echo "❌ 无法生成私钥 base64 内容" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
|
||||
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "✅ Tauri signing key prepared"
|
||||
|
||||
- name: Build Tauri App (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||
TAR_GZ=""; APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
if [ -z "$TAR_GZ" ]; then
|
||||
echo "No macOS .tar.gz updater artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$TAR_GZ" release-assets/
|
||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
|
||||
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
|
||||
if [ -n "$APP_PATH" ]; then
|
||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready: CC-Switch-macOS.zip"
|
||||
else
|
||||
echo "No .app found to zip (optional)" >&2
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $msi) {
|
||||
# 兜底:全局搜索 .msi
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
if ($null -ne $msi) {
|
||||
$dest = 'CC-Switch-Setup.msi'
|
||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
$sigPath = "$($msi.FullName).sig"
|
||||
if (Test-Path $sigPath) {
|
||||
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
||||
Write-Host "Signature copied: $dest.sig"
|
||||
} else {
|
||||
Write-Warning "Signature not found for $($msi.Name)"
|
||||
}
|
||||
} else {
|
||||
Write-Warning 'No Windows MSI installer found'
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
||||
$exeCandidates = @(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
)
|
||||
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($null -ne $exePath) {
|
||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath $portableDir
|
||||
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
||||
Remove-Item -Recurse -Force $portableDir
|
||||
Write-Host 'Windows portable zip created'
|
||||
} else {
|
||||
Write-Warning 'Portable exe not found'
|
||||
}
|
||||
|
||||
- name: Prepare Linux Assets
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
# Updater artifact: AppImage(含对应 .sig)
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||
if [ -n "$APPIMAGE" ]; then
|
||||
cp "$APPIMAGE" release-assets/
|
||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
|
||||
echo "AppImage copied"
|
||||
else
|
||||
echo "No AppImage found under target/release/bundle" >&2
|
||||
fi
|
||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||
if [ -n "$DEB" ]; then
|
||||
cp "$DEB" release-assets/
|
||||
echo "Deb package copied"
|
||||
else
|
||||
echo "No .deb found (optional)"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
run: |
|
||||
ls -la release-assets || true
|
||||
|
||||
- name: Collect Signatures
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Collected signatures (if any alongside artifacts):"
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
name: "CC Switch ${{ github.ref_name }}"
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: CC Switch ${{ github.ref_name }}
|
||||
prerelease: true
|
||||
body: |
|
||||
## CC Switch ${{ github.ref_name }}
|
||||
|
||||
|
||||
Claude Code 供应商切换工具
|
||||
|
||||
|
||||
### 下载
|
||||
|
||||
#### Windows 用户
|
||||
- **安装版 (推荐)**: `CC Switch Setup ${{ github.ref_name }}.exe`
|
||||
- **便携版**: `CC Switch ${{ github.ref_name }}.exe`
|
||||
|
||||
#### macOS 用户(推荐使用通用版本)
|
||||
- **通用版本**: `CC Switch-${{ github.ref_name }}-mac.zip` - 兼容所有Mac(Intel + M系列)
|
||||
|
||||
#### Linux 用户
|
||||
- **AppImage**: `CC Switch-${{ github.ref_name }}.AppImage`
|
||||
|
||||
### macOS 安装说明
|
||||
1. 下载 ZIP 文件后解压
|
||||
2. 首次打开可能出现"未知开发者"警告
|
||||
3. 前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
||||
4. 或者使用命令: `xattr -cr "/path/to/CC Switch.app"`
|
||||
|
||||
### 注意事项
|
||||
- macOS 版本使用 Intel 架构,通过 Rosetta 2 在 M 系列芯片上运行
|
||||
- 兼容性和稳定性最佳,性能损失minimal
|
||||
|
||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
||||
- Windows: `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||
|
||||
---
|
||||
提示:macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
files: release-assets/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
assemble-latest-json:
|
||||
name: Assemble latest.json
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Prepare GH
|
||||
run: |
|
||||
gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download all release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
mkdir -p dl
|
||||
gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY"
|
||||
ls -la dl || true
|
||||
- name: Generate latest.json
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${TAG#v}"
|
||||
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
base_url="https://github.com/$REPO/releases/download/$TAG"
|
||||
# 初始化空平台映射
|
||||
mac_url=""; mac_sig=""
|
||||
win_url=""; win_sig=""
|
||||
linux_url=""; linux_sig=""
|
||||
shopt -s nullglob
|
||||
for sig in dl/*.sig; do
|
||||
base=${sig%.sig}
|
||||
fname=$(basename "$base")
|
||||
url="$base_url/$fname"
|
||||
sig_content=$(cat "$sig")
|
||||
case "$fname" in
|
||||
*.tar.gz)
|
||||
# 视为 macOS updater artifact
|
||||
mac_url="$url"; mac_sig="$sig_content";;
|
||||
*.AppImage|*.appimage)
|
||||
linux_url="$url"; linux_sig="$sig_content";;
|
||||
*.msi|*.exe)
|
||||
win_url="$url"; win_sig="$sig_content";;
|
||||
esac
|
||||
done
|
||||
# 构造 JSON(仅包含存在的目标)
|
||||
tmp_json=$(mktemp)
|
||||
{
|
||||
echo '{'
|
||||
echo " \"version\": \"$VERSION\",";
|
||||
echo " \"notes\": \"Release $TAG\",";
|
||||
echo " \"pub_date\": \"$PUB_DATE\",";
|
||||
echo ' "platforms": {'
|
||||
first=1
|
||||
if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then
|
||||
# 为兼容 arm64 / x64,重复写入两个键,指向同一 universal 包
|
||||
for key in darwin-aarch64 darwin-x86_64; do
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}"
|
||||
first=0
|
||||
done
|
||||
fi
|
||||
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
||||
first=0
|
||||
fi
|
||||
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
|
||||
first=0
|
||||
fi
|
||||
echo ' }'
|
||||
echo '}'
|
||||
} > "$tmp_json"
|
||||
echo "Generated latest.json:" && cat "$tmp_json"
|
||||
mv "$tmp_json" latest.json
|
||||
- name: Upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
5
.gitignore
vendored
@@ -6,4 +6,7 @@ release/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
docs/
|
||||
|
||||
137
CHANGELOG.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CC Switch will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.2.0] - 2025-09-13
|
||||
|
||||
### ✨ New Features
|
||||
- System tray provider switching with dynamic menu for Claude/Codex
|
||||
- Frontend receives `provider-switched` events and refreshes active app
|
||||
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
|
||||
|
||||
### 🔧 Improvements
|
||||
- Single source of truth for provider configs; no duplicate copy files
|
||||
- One-time migration imports existing copies into `config.json` and archives originals
|
||||
- Duplicate provider de-duplication by name + API key at startup
|
||||
- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure
|
||||
- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints
|
||||
- Tailwind v4 integration and refined dark mode handling
|
||||
|
||||
### 🐛 Fixes
|
||||
- Remove/minimize debug console logs in production builds
|
||||
- Fix CSS minifier warnings for scrollbar pseudo-elements
|
||||
- Prettier formatting across codebase for consistent style
|
||||
|
||||
### 📦 Dependencies
|
||||
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
|
||||
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
|
||||
|
||||
### 🔄 Notes
|
||||
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
|
||||
|
||||
## [3.1.1] - 2025-09-03
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed the default codex config.toml to match the latest modifications
|
||||
- Improved provider configuration UX with custom option
|
||||
|
||||
### 📝 Documentation
|
||||
- Updated README with latest information
|
||||
|
||||
## [3.1.0] - 2025-09-01
|
||||
|
||||
### ✨ New Features
|
||||
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
||||
- Manage auth.json and config.toml for Codex
|
||||
- Support for backup and restore operations
|
||||
- Preset providers for Codex (Official, PackyCode)
|
||||
- API Key auto-write to auth.json when using presets
|
||||
- **New UI components**
|
||||
- App switcher with segmented control design
|
||||
- Dual editor form for Codex configuration
|
||||
- Pills-style app switcher with consistent button widths
|
||||
- **Enhanced configuration management**
|
||||
- Multi-app config v2 structure (claude/codex)
|
||||
- Automatic v1→v2 migration with backup
|
||||
- OPENAI_API_KEY validation for non-official presets
|
||||
- TOML syntax validation for config.toml
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Unified Tauri command API with app_type parameter
|
||||
- Backward compatibility for app/appType parameters
|
||||
- Added get_config_status/open_config_folder/open_external commands
|
||||
- Improved error handling for empty config.toml
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed config path reporting and folder opening for Codex
|
||||
- Corrected default import behavior when main config is missing
|
||||
- Fixed non_snake_case warnings in commands.rs
|
||||
|
||||
## [3.0.0] - 2025-08-27
|
||||
|
||||
### 🚀 Major Changes
|
||||
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
||||
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
||||
- **Significantly improved startup performance**
|
||||
- **Native system integration** without Chromium overhead
|
||||
- **Enhanced security** with Rust backend
|
||||
|
||||
### ✨ New Features
|
||||
- **Native window controls** with transparent title bar on macOS
|
||||
- **Improved file system operations** using Rust for better performance
|
||||
- **Enhanced security model** with explicit permission declarations
|
||||
- **Better platform detection** using Tauri's native APIs
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Migrated from Electron IPC to Tauri command system
|
||||
- Replaced Node.js file operations with Rust implementations
|
||||
- Implemented proper CSP (Content Security Policy) for enhanced security
|
||||
- Added TypeScript strict mode for better type safety
|
||||
- Integrated Rust cargo fmt and clippy for code quality
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
||||
- Resolved platform detection issues
|
||||
- Improved error handling in configuration management
|
||||
|
||||
### 📦 Dependencies
|
||||
- **Tauri**: 2.8.2
|
||||
- **React**: 18.2.0
|
||||
- **TypeScript**: 5.3.0
|
||||
- **Vite**: 5.0.0
|
||||
|
||||
### 🔄 Migration Notes
|
||||
For users upgrading from v2.x (Electron version):
|
||||
- Configuration files remain compatible - no action required
|
||||
- The app will automatically migrate your existing provider configurations
|
||||
- Window position and size preferences have been reset to defaults
|
||||
|
||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
||||
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
|
||||
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
|
||||
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||
|
||||
### 🛠️ Development
|
||||
- Added `pnpm typecheck` command for TypeScript validation
|
||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||
- Rust code now uses cargo fmt for consistent formatting
|
||||
|
||||
## [2.0.0] - Previous Electron Release
|
||||
|
||||
### Features
|
||||
- Multi-provider configuration management
|
||||
- Quick provider switching
|
||||
- Import/export configurations
|
||||
- Preset provider templates
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - Initial Release
|
||||
|
||||
### Features
|
||||
- Basic provider management
|
||||
- Claude Code integration
|
||||
- Configuration file handling
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
233
README.md
@@ -1,14 +1,28 @@
|
||||
# Claude Code 供应商切换器
|
||||
# Claude Code & Codex 供应商切换器
|
||||
|
||||
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
## 功能特性
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
- 一键切换不同供应商
|
||||
- 智谱 GLM、Qwen coder、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- 支持添加自定义供应商
|
||||
- 简洁美观的图形界面
|
||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||
> v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)。
|
||||
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
|
||||
## 功能特性(v3.2.0)
|
||||
|
||||
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
|
||||
- **系统托盘(菜单栏)快速切换**:按应用分组(Claude / Codex),勾选态展示当前供应商
|
||||
- **内置更新器**:集成 Tauri Updater,支持检测/下载/安装与一键重启
|
||||
- **单一事实源(SSOT)**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
|
||||
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
|
||||
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
|
||||
- **深色模式优化**:Tailwind v4 适配与选择器修正
|
||||
- **丰富预设与自定义**:Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
|
||||
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -22,109 +36,170 @@
|
||||
|
||||
## 下载安装
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Windows**: Windows 10 及以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
|
||||
### Windows 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载:
|
||||
|
||||
- **安装版 (推荐)**: `CC-Switch-Setup-x.x.x.exe`
|
||||
- 完整系统集成,正确显示应用图标
|
||||
- 自动创建桌面快捷方式和开始菜单项
|
||||
- **便携版**: `CC-Switch-Portable-x.x.x.exe`
|
||||
- 无需安装,直接运行
|
||||
- 适合需要绿色软件的用户
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
||||
|
||||
### macOS 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载:
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
||||
|
||||
- **通用版本(推荐)**: `CC Switch-x.x.x-mac.zip` - Intel 版本,兼容所有 Mac(包括 M 系列芯片)
|
||||
|
||||
#### macOS 安装说明
|
||||
|
||||
**推荐使用通用版本**,它通过 Rosetta 2 在 M 系列 Mac 上运行良好,兼容性最佳。
|
||||
|
||||
由于作者没有苹果开发者账号,应用使用 ad-hoc 签名(未经苹果官方认证),首次打开时可能出现"未知开发者"警告。这是正常的安全提示,处理方法:
|
||||
|
||||
**方法 1 - 系统设置**:
|
||||
|
||||
1. 双击应用时选择"取消"
|
||||
2. 打开"系统设置" → "隐私与安全性"
|
||||
3. 在底部找到被阻止的应用,点击"仍要打开"
|
||||
4. 确认后即可正常使用
|
||||
|
||||
**方法 2 - 自行编译**:
|
||||
|
||||
1. Clone 代码到本地:`git clone https://github.com/farion1231/cc-switch.git`
|
||||
2. 安装依赖:`pnpm install`
|
||||
3. 编译代码:`pnpm run build`
|
||||
4. 打包应用:`pnpm run dist`
|
||||
5. 在项目 release 目录找到编译好的应用包
|
||||
|
||||
**安全保障**:
|
||||
|
||||
- 应用已通过 ad-hoc 代码签名,确保文件完整性
|
||||
- 源代码完全开源,可在 GitHub 审查
|
||||
- 本地存储配置,无网络传输风险
|
||||
|
||||
**技术说明**:
|
||||
|
||||
- 使用 Intel x64 架构,通过 Rosetta 2 在 M 系列芯片上运行
|
||||
- 兼容性和稳定性最佳,性能损失 minimal
|
||||
- 避免了 ARM64 原生版本的签名复杂性问题
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Linux 用户
|
||||
|
||||
- **AppImage**: `CC Switch-x.x.x.AppImage`
|
||||
|
||||
下载后添加执行权限:
|
||||
|
||||
```bash
|
||||
chmod +x CC-Switch-x.x.x.AppImage
|
||||
```
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 选择要使用的供应商,点击单选按钮切换
|
||||
3. 配置会自动保存到 Claude Code 的配置文件中
|
||||
4. 重启或者新打开 Claude Code 终端以生效
|
||||
2. 切换方式:
|
||||
- 在主界面选择供应商后点击切换
|
||||
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||
4. 重启或新开终端以确保生效
|
||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||
|
||||
### 检查更新
|
||||
|
||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||
|
||||
### Codex 说明(v3.2.0 SSOT)
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||
|
||||
### Claude Code 说明(v3.2.0 SSOT)
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||
|
||||
### 迁移与归档(v3.2.0)
|
||||
|
||||
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
|
||||
- Claude:`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`)
|
||||
- Codex:`~/.codex/auth-*.json` 与 `config-*.toml`(按名称成对合并)
|
||||
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重;若当前为空,将 live 合并项设为当前
|
||||
- 归档与清理:
|
||||
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- 归档成功后删除原副本;失败则保留原文件(保守策略)
|
||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||
|
||||
## 开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 8+
|
||||
- Rust 1.75+
|
||||
- Tauri CLI 2.0+
|
||||
|
||||
### 开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
# 或
|
||||
npm install
|
||||
|
||||
# 开发模式
|
||||
pnpm run dev
|
||||
# 开发模式(热重载)
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
|
||||
# 代码格式化
|
||||
pnpm format
|
||||
|
||||
# 检查代码格式
|
||||
pnpm format:check
|
||||
|
||||
# 构建应用
|
||||
pnpm run build
|
||||
pnpm build
|
||||
|
||||
# 打包发布
|
||||
pnpm run dist
|
||||
# 构建调试版本
|
||||
pnpm tauri build --debug
|
||||
```
|
||||
|
||||
### Rust 后端开发
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
|
||||
# 格式化 Rust 代码
|
||||
cargo fmt
|
||||
|
||||
# 运行 clippy 检查
|
||||
cargo clippy
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon)
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── main/ # 主进程代码
|
||||
│ ├── renderer/ # 渲染进程代码
|
||||
│ └── shared/ # 共享类型和工具
|
||||
├── build/ # 应用图标资源
|
||||
└── dist/ # 构建输出目录
|
||||
├── src/ # 前端代码 (React + TypeScript)
|
||||
│ ├── components/ # React 组件
|
||||
│ ├── config/ # 预设供应商配置
|
||||
│ ├── lib/ # Tauri API 封装
|
||||
│ └── utils/ # 工具函数
|
||||
├── src-tauri/ # 后端代码 (Rust)
|
||||
│ ├── src/ # Rust 源代码
|
||||
│ │ ├── commands.rs # Tauri 命令定义
|
||||
│ │ ├── config.rs # 配置文件管理
|
||||
│ │ ├── provider.rs # 供应商管理逻辑
|
||||
│ │ └── store.rs # 状态管理
|
||||
│ ├── capabilities/ # 权限配置
|
||||
│ └── icons/ # 应用图标资源
|
||||
└── screenshots/ # 界面截图
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||
|
||||
## Electron 旧版
|
||||
|
||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||
|
||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT © Jason Young
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 161 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 312 KiB |
193
docs/encrypted-config-plan.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# CC Switch 加密配置与切换重构方案(V1)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源(SSOT),改为“加密落盘”;切换时从解密后的内存配置写入目标应用主配置(Claude/Codex)。
|
||||
- 范围:
|
||||
- 后端(Rust/Tauri)新增加密模块与读写改造。
|
||||
- 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。
|
||||
- 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。
|
||||
- 兼容旧明文配置(v1/v2),首次保存迁移为加密文件。
|
||||
|
||||
## 2. 背景现状(简述)
|
||||
|
||||
- 当前:
|
||||
- 全局配置:`~/.cc-switch/config.json`(v2:`MultiAppConfig`,含多个 `ProviderManager`)。
|
||||
- 切换:依赖“供应商副本文件”(Claude:`~/.claude/settings-<name>.json`;Codex:`~/.codex/auth-<name>.json`、`config-<name>.toml`)→ 恢复到主配置。
|
||||
- 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。
|
||||
- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。
|
||||
|
||||
## 3. 总体方案
|
||||
|
||||
- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。
|
||||
- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。
|
||||
- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。
|
||||
- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`(v1/v2)与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive/<ts>/`,生成 `manifest.json` 以便恢复;默认不做静默清理。
|
||||
|
||||
## 4. 密钥管理
|
||||
|
||||
- 存储:系统级凭据管家(keyring crate)。
|
||||
- Service:`cc-switch`;Account:`config-key-v1`;内容:Base64 编码的 32 字节随机密钥(AES-256)。
|
||||
- 首次运行:生成随机密钥,写入 Keychain。
|
||||
- 进程内缓存:启动加载后缓存密钥,避免重复 IO。
|
||||
- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。
|
||||
- 回退策略:Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。
|
||||
|
||||
## 5. 加密封装格式
|
||||
|
||||
- 文件:`~/.cc-switch/config.enc.json`
|
||||
- 结构(JSON 封装,便于演进):
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"alg": "AES-256-GCM",
|
||||
"nonce": "<base64-nonce>",
|
||||
"ct": "<base64-ciphertext>"
|
||||
}
|
||||
```
|
||||
- 明文:`serde_json::to_vec(MultiAppConfig)`;加密:AES-GCM(12 字节随机 nonce);每次保存生成新 nonce。
|
||||
|
||||
## 6. 模块与改造点
|
||||
|
||||
- 新增 `src-tauri/src/secure_store.rs`:
|
||||
- `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。
|
||||
- `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)`;`decrypt_bytes(key, nonce, ciphertext)`。
|
||||
- `read_encrypted_config() -> Result<MultiAppConfig, String>`:读取 `config.enc.json`、解析封装、解密、反序列化。
|
||||
- `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。
|
||||
- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档):
|
||||
- `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`(v1/v2)与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`),返回分组清单、大小、mtime;永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。
|
||||
- `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive/<ts>/` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`(记录原路径、归档路径、大小、mtime、sha256、类别);同分区 `rename`,跨分区“copy + fsync + remove”。
|
||||
- `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。
|
||||
- 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。
|
||||
- 安全护栏:操作前后做 mtime/hash 复核(CAS);发生变化中止并提示“外部已修改”。
|
||||
- 调整 `src-tauri/src/app_config.rs`:
|
||||
- `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文:
|
||||
- 若检测到 v1(`ProviderManager`)→ 迁移到 v2(原有逻辑保留)。
|
||||
- `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup.<ts>.json`(或保留为只读,视实现选择)。
|
||||
- 调整 `src-tauri/src/commands.rs::switch_provider`:
|
||||
- Claude:
|
||||
1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON,写回 `manager.providers[manager.current].settings_config`。
|
||||
2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。
|
||||
- Codex:
|
||||
1. 回填:读取 `~/.codex/auth.json`(JSON)与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。
|
||||
2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML)。
|
||||
- 更新 `manager.current = id`,`state.save()` → 触发加密保存。
|
||||
- 保留/清理:
|
||||
- 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。
|
||||
- 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。
|
||||
|
||||
## 7. 数据流与时序
|
||||
|
||||
- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。
|
||||
- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。
|
||||
- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。
|
||||
- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。
|
||||
|
||||
## 8. 迁移策略
|
||||
|
||||
- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。
|
||||
- 旧版支持:
|
||||
- v1 明文(单 `ProviderManager`)→ 自动迁移为 v2(已有逻辑)。
|
||||
- v2 明文 → 直接加载。
|
||||
- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup.<ts>.json`(或保留为只读)。
|
||||
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
|
||||
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
|
||||
|
||||
## 9. 回滚策略
|
||||
|
||||
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
|
||||
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
|
||||
|
||||
## 10. 安全与性能
|
||||
|
||||
- 算法:AES-256-GCM(AEAD);随机 12 字节 nonce;每次保存新 nonce。
|
||||
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 1–20ms,可缓存。
|
||||
- 可靠性:原子写入(临时文件 + rename);写入失败不破坏现有文件。
|
||||
- 可选增强:`zeroize` 清理密钥与明文;Claude 配置 JSON Schema 校验。
|
||||
- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。
|
||||
|
||||
## 11. API 与 UX 影响
|
||||
|
||||
- 前端 API:现有行为不变;新增清理相关命令(Tauri)供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive`(`purge_archived` 可选)。
|
||||
- UI 提示:在“配置文件位置”旁提示“已加密存储”。
|
||||
- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。
|
||||
- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。
|
||||
|
||||
## 12. 开发任务拆解(阶段一为本次交付)
|
||||
|
||||
- 阶段一(核心改造 + 清理能力最小闭环)
|
||||
- 新增模块 `secure_store.rs`:Keychain 与加解密工具函数。
|
||||
- 改造 `app_config.rs`:`load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。
|
||||
- 改造 `commands.rs::switch_provider`:
|
||||
- 回填 live 配置 → 写入目标主配置(Claude/Codex)。
|
||||
- 去除对“副本恢复”的依赖(保留函数以便回退)。
|
||||
- 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。
|
||||
- 保持 `import_default_config`、`get_config_status` 行为不变。
|
||||
- 阶段二(清理与增强)
|
||||
- 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。
|
||||
- Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。
|
||||
- 阶段三(安全升级)
|
||||
- 密钥轮换;可选 passphrase(KDF: Argon2id + salt)。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
- 功能:
|
||||
- 无加密明文文件也能启动并正确读写;
|
||||
- 切换成功将内存配置写入主配置;
|
||||
- 外部手改在下一次切换前被回填保存;
|
||||
- 旧配置自动迁移并生成加密文件;
|
||||
- Keychain/解密异常时不损坏已有文件,给出可理解错误。
|
||||
- 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。
|
||||
- 质量:
|
||||
- 关键路径加错误处理与日志;
|
||||
- 写入采用原子替换;
|
||||
- 代码变更集中、最小侵入,与现有风格一致。
|
||||
- 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。
|
||||
|
||||
## 15. 风险与对策
|
||||
|
||||
- Keychain 不可用或权限受限:
|
||||
- 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。
|
||||
- 加密文件损坏:
|
||||
- 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。
|
||||
- 与“副本文件”并存导致混淆:
|
||||
- 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。
|
||||
- 清理误删或不可逆:
|
||||
- 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。
|
||||
|
||||
## 16. 发布与回退
|
||||
|
||||
- 发布:随 Tauri 应用正常发布,无需前端变更。
|
||||
- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。
|
||||
|
||||
## 17. 旧文件清理与归档(新增)
|
||||
|
||||
- 归档对象:
|
||||
- `~/.cc-switch/config.json`(v1/v2,迁移成功后)
|
||||
- `~/.claude/settings-*.json`(保留 `settings.json`)
|
||||
- `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`)
|
||||
- 归档位置与结构:`~/.cc-switch/archive/<timestamp>/{cc-switch,claude,codex}/...`
|
||||
- `manifest.json`:记录原路径、归档路径、大小、mtime、sha256、类别(v1/v2/claude/codex);用于恢复与可视化。
|
||||
- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。
|
||||
- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验;跨分区采用“copy+fsync+remove”;失败即时回滚并提示。
|
||||
|
||||
## 18. 变更点清单(代码)
|
||||
|
||||
- 新增:`src-tauri/src/secure_store.rs`
|
||||
- 修改:
|
||||
- `src-tauri/src/app_config.rs`(load/save 加密化、迁移与原子写入)
|
||||
- `src-tauri/src/commands.rs`(switch_provider 改为内存 → 主配置,并回填 live)
|
||||
- `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件)
|
||||
- 保持:
|
||||
- `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动)
|
||||
- 前端 `src/lib/tauri-api.ts` 与 UI 逻辑
|
||||
|
||||
## 19. 开放问题(待确认)
|
||||
|
||||
- Keychain 失败时是否提供“本地明文密钥文件(600 权限)”的应急模式(当前建议:不提供,保持只读)。
|
||||
- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。
|
||||
- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30)。
|
||||
|
||||
---
|
||||
|
||||
以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需,我可以继续补充任务看板(Issue 列表)与实施顺序的 PR 规划。
|
||||
91
docs/updater-plan.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 更新功能开发计划(Tauri v2 Updater)
|
||||
|
||||
> 目标:基于 Tauri v2 官方 Updater,完成“检查更新 → 下载 → 安装 → 重启”的完整闭环;提供清晰的前后端接口、配置与测试/发布流程。
|
||||
|
||||
## 范围与目标
|
||||
- 能力:静态 JSON 与动态接口两种更新源;可选稳定/测试通道;进度反馈与错误处理。
|
||||
- 平台:macOS `.app` 优先;Windows 使用安装器(NSIS/MSI)。
|
||||
- 安全:启用 Ed25519 更新签名校验;上线前建议平台代码签名与公证。
|
||||
|
||||
## 架构与依赖
|
||||
- 插件:`tauri-plugin-updater`(更新)、`@tauri-apps/plugin-updater`(JS);`tauri-plugin-process` 与 `@tauri-apps/plugin-process`(重启)。
|
||||
- 签名与构建:`tauri signer generate` 生成密钥;CI/本机注入 `TAURI_SIGNING_PRIVATE_KEY`;`bundle.createUpdaterArtifacts: true` 生成签名制品。
|
||||
- 权限:在 `src-tauri/capabilities/default.json` 启用 `updater:default` 与 `process:allow-restart`。
|
||||
- 配置(`src-tauri/tauri.conf.json`):
|
||||
- `plugins.updater.pubkey: "<PUBLICKEY.PEM>"`
|
||||
- `plugins.updater.endpoints: ["<更新源 URL 列表>"]`
|
||||
- Windows(可选):`plugins.updater.windows.installMode: "passive|basicUi|quiet"`
|
||||
|
||||
## 前端接口设计(TypeScript)
|
||||
- 类型
|
||||
- `type UpdateChannel = 'stable' | 'beta'`
|
||||
- `type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'restarting' | 'upToDate' | 'error'`
|
||||
- `type UpdateInfo = { currentVersion: string; availableVersion: string; notes?: string; pubDate?: string }`
|
||||
- `type UpdateProgressEvent = { event: 'Started' | 'Progress' | 'Finished'; total?: number; downloaded?: number }`
|
||||
- `type UpdateError = { code: string; message: string; cause?: unknown }`
|
||||
- `type CheckOptions = { timeout?: number; channel?: UpdateChannel }`
|
||||
- API(`src/lib/updater.ts`)
|
||||
- `getCurrentVersion(): Promise<string>` 读取当前版本。
|
||||
- `checkForUpdate(opts?: CheckOptions)` → `up-to-date` 或 `{ status: 'available', info, update }`。
|
||||
- `downloadAndInstall(update, onProgress?)` 下载并安装,进度回调映射 Started/Progress/Finished。
|
||||
- `relaunchApp()` 调用 `@tauri-apps/plugin-process.relaunch()`。
|
||||
- `runUpdateFlow(opts?)` 编排:检查 → 下载安装 → 重启;错误统一抛出 `UpdateError`。
|
||||
- `setUpdateChannel(channel)` 前端记录偏好;实际端点切换见“端点动态化”。
|
||||
- Hook(可选 `useUpdater()`)
|
||||
- 返回 `{ phase, info?, progress?, error?, actions: { check, startUpdate, relaunch } }`。
|
||||
- UI(组件建议)
|
||||
- `UpdateBanner`:发现新版本时展示;`UpdaterDialog`:显示说明、进度与错误/重试。
|
||||
|
||||
## Rust 集成与权限
|
||||
- 插件注册(`src-tauri/src/main.rs`):
|
||||
- `app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;`
|
||||
- `.plugin(tauri_plugin_process::init())` 用于重启。
|
||||
- Windows 清理钩子(可选):`UpdaterExt::on_before_exit(app.cleanup_before_exit)`,避免安装器启动前文件占用。
|
||||
- 端点动态化(可选):在 `setup` 根据配置/环境切换 `endpoints`、超时、代理或 headers。
|
||||
|
||||
## 更新源与格式
|
||||
- 静态 JSON(latest.json):字段 `version`、`platforms[target].url`、`platforms[target].signature`(`.sig` 内容);可选 `notes`、`pub_date`。
|
||||
- 动态接口:
|
||||
- 无更新:HTTP 204
|
||||
- 有更新:HTTP 200 → `{ version, url, signature, notes?, pub_date? }`
|
||||
- 通道组织:`/stable/latest.json`、`/beta/latest.json`;CDN 缓存需可控,回滚可强制刷新。
|
||||
|
||||
## 用户流程与 UX
|
||||
- 流程:检查 → 展示版本/日志 → 下载进度(累计/百分比)→ 安装 → 提示并重启。
|
||||
- 错误:网络异常(超时/断网/证书)、签名不匹配、权限/文件占用(Win)。提供“重试/稍后更新”。
|
||||
- 平台提示:
|
||||
- macOS:建议安装在 `~/Applications`,避免 `/Applications` 提权导致失败。
|
||||
- Windows:优先安装器分发,并选择合适 `installMode`。
|
||||
|
||||
## 测试计划
|
||||
- 功能:有更新/无更新(204)/下载中断/重试/安装后重启成功与版本号提升。
|
||||
- 安全:签名不匹配必须拒绝更新;端点不可用/被劫持有清晰提示。
|
||||
- 网络:超时/断网/代理场景提示与恢复。
|
||||
- 平台:
|
||||
- macOS:`/Applications` 与 `~/Applications` 的权限差异。
|
||||
- Windows:`passive|basicUi|quiet` 行为差异与成功率。
|
||||
- 本地自测:以 v1.0.0 运行,构建 v1.0.1 制品+`.sig`,本地 HTTP 托管 `latest.json`,验证全链路。
|
||||
|
||||
## 发布与回滚
|
||||
- 发布(CI 推荐):注入 `TAURI_SIGNING_PRIVATE_KEY` → 构建生成各平台制品+签名 → 上传产物与 `latest.json` 至 Releases/CDN。
|
||||
- 回滚:撤下问题版本或将 `latest.json` 指回上一个稳定版本;如需降级,Rust 侧可定制版本比较策略(可选)。
|
||||
|
||||
## 里程碑与验收
|
||||
- D1:密钥与基础集成(插件/配置/权限)。
|
||||
- D2:前端入口与进度 UI,静态 JSON 自测通过。
|
||||
- D3:Releases/CDN 端到端验证,平台专项测试。
|
||||
- D4:文档完善、回滚与异常流程演练。
|
||||
- 验收:两平台完成“发现→下载→安装→重启→版本提升”;签名校验生效;异常有明确提示与可行恢复。
|
||||
|
||||
## 待确认
|
||||
- 更新源托管(GitHub Releases 还是自有 CDN)。
|
||||
- 是否需要 beta 通道与运行时切换。
|
||||
- Windows 是否仅支持安装器分发;便携版兼容策略是否需要明确说明。
|
||||
- UI 文案与样式偏好。
|
||||
|
||||
## 落地步骤(实施顺序)
|
||||
1) 生成 Ed25519 密钥,将公钥写入 `plugins.updater.pubkey`,在构建环境配置 `TAURI_SIGNING_PRIVATE_KEY`。
|
||||
2) `src-tauri` 注册 `tauri-plugin-updater` 与 `tauri-plugin-process`,补齐 `capabilities/default.json` 与 `tauri.conf.json`。
|
||||
3) 前端新增 `src/lib/updater.ts` 封装与 `UpdateBanner`/`UpdaterDialog` 组件,接入入口按钮。
|
||||
4) 本地静态 `latest.json` 自测全链路;完善错误与进度提示。
|
||||
5) 配置 CI 发布产物与 `latest.json`;编写发布/回滚操作手册。
|
||||
93
package.json
@@ -1,91 +1,44 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "2.0.3",
|
||||
"description": "Claude Code 供应商切换工具",
|
||||
"main": "dist/main/index.js",
|
||||
"version": "3.2.0",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron:watch\"",
|
||||
"dev:electron": "tsc -p tsconfig.main.json && electron .",
|
||||
"dev:electron:watch": "tsc -p tsconfig.main.json && concurrently -k \"tsc -w -p tsconfig.main.json\" \"npm:electron\"",
|
||||
"electron": "electron .",
|
||||
"dev": "pnpm tauri dev",
|
||||
"build": "pnpm tauri build",
|
||||
"tauri": "tauri",
|
||||
"dev:renderer": "vite",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"start": "electron .",
|
||||
"dist": "electron-builder"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jason Young",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.8.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"electron": "^32.3.3",
|
||||
"electron-builder": "^24.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ccswitch.app",
|
||||
"productName": "CC Switch",
|
||||
"compression": "maximum",
|
||||
"publish": null,
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"icon": "build/icon.ico",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "build/icon.icns",
|
||||
"identity": "-",
|
||||
"hardenedRuntime": false,
|
||||
"entitlements": null,
|
||||
"entitlementsInherit": null,
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "build/icon.png"
|
||||
}
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^4.1.13"
|
||||
}
|
||||
}
|
||||
|
||||
2803
pnpm-lock.yaml
generated
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 162 KiB |
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
6134
src-tauri/Cargo.lock
generated
Normal file
42
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.2.0"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/farion1231/cc-switch"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "cc_switch_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
|
||||
# Optimize release binary size to help reduce AppImage footprint
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
14
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 109 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/tray/macos/statusTemplate.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
src-tauri/icons/tray/macos/statusTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
139
src-tauri/src/app_config.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
use crate::provider::ProviderManager;
|
||||
|
||||
/// 应用类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppType {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"codex" => AppType::Codex,
|
||||
_ => AppType::Claude, // 默认为 Claude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
#[serde(flatten)]
|
||||
pub apps: HashMap<String, ProviderManager>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
impl Default for MultiAppConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
Self { version: 2, apps }
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiAppConfig {
|
||||
/// 从文件加载配置(处理v1到v2的迁移)
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let config_path = get_app_config_path();
|
||||
|
||||
if !config_path.exists() {
|
||||
log::info!("配置文件不存在,创建新的多应用配置");
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取配置文件失败: {}", e))?;
|
||||
|
||||
// 检查是否是旧版本格式(v1)
|
||||
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
||||
log::info!("检测到v1配置,自动迁移到v2");
|
||||
|
||||
// 迁移到新格式
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), v1_config);
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
let config = Self { version: 2, apps };
|
||||
|
||||
// 迁移前备份旧版(v1)配置文件
|
||||
let backup_dir = get_app_config_dir();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
|
||||
|
||||
match copy_file(&config_path, &backup_path) {
|
||||
Ok(()) => log::info!(
|
||||
"已备份旧版配置文件: {} -> {}",
|
||||
config_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
|
||||
}
|
||||
|
||||
// 保存迁移后的配置
|
||||
config.save()?;
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 尝试读取v2格式
|
||||
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config_path = get_app_config_path();
|
||||
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
||||
if config_path.exists() {
|
||||
let backup_path = get_app_config_dir().join("config.json.bak");
|
||||
if let Err(e) = copy_file(&config_path, &backup_path) {
|
||||
log::warn!("备份 config.json 到 .bak 失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
write_json_file(&config_path, self)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器
|
||||
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
|
||||
self.apps.get(app.as_str())
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器(可变引用)
|
||||
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
|
||||
self.apps.get_mut(app.as_str())
|
||||
}
|
||||
|
||||
/// 确保应用存在
|
||||
pub fn ensure_app(&mut self, app: &AppType) {
|
||||
if !self.apps.contains_key(app.as_str()) {
|
||||
self.apps
|
||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src-tauri/src/codex_config.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
// unused imports removed
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{
|
||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
pub fn get_codex_auth_path() -> PathBuf {
|
||||
get_codex_config_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
pub fn get_codex_config_path() -> PathBuf {
|
||||
get_codex_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// 获取 Codex 供应商配置文件路径
|
||||
pub fn get_codex_provider_paths(
|
||||
provider_id: &str,
|
||||
provider_name: Option<&str>,
|
||||
) -> (PathBuf, PathBuf) {
|
||||
let base_name = provider_name
|
||||
.map(|name| sanitize_provider_name(name))
|
||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||
|
||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
||||
|
||||
(auth_path, config_path)
|
||||
}
|
||||
|
||||
/// 删除 Codex 供应商配置文件
|
||||
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
|
||||
delete_file(&auth_path).ok();
|
||||
delete_file(&config_path).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
|
||||
|
||||
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
||||
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
|
||||
if let Some(parent) = auth_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 读取旧内容用于回滚
|
||||
let old_auth = if auth_path.exists() {
|
||||
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _old_config = if config_path.exists() {
|
||||
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 准备写入内容
|
||||
let cfg_text = match config_text_opt {
|
||||
Some(s) => s.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
if !cfg_text.trim().is_empty() {
|
||||
toml::from_str::<toml::Table>(&cfg_text)
|
||||
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
||||
}
|
||||
|
||||
// 第一步:写 auth.json
|
||||
write_json_file(&auth_path, auth)?;
|
||||
|
||||
// 第二步:写 config.toml(失败则回滚 auth.json)
|
||||
if let Err(e) = write_text_file(&config_path, &cfg_text) {
|
||||
// 回滚 auth.json
|
||||
if let Some(bytes) = old_auth {
|
||||
let _ = atomic_write(&auth_path, &bytes);
|
||||
} else {
|
||||
let _ = delete_file(&auth_path);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
||||
pub fn read_codex_config_text() -> Result<String, String> {
|
||||
let path = get_codex_config_path();
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
|
||||
pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 对非空的 TOML 文本进行语法校验
|
||||
pub fn validate_config_toml(text: &str) -> Result<(), String> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
toml::from_str::<toml::Table>(text)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("config.toml 语法错误: {}", e))
|
||||
}
|
||||
|
||||
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
|
||||
pub fn read_and_validate_codex_config_text() -> Result<String, String> {
|
||||
let s = read_codex_config_text()?;
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// 从指定路径读取并校验 config.toml,返回文本(可能为空)
|
||||
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, String> {
|
||||
let s = read_config_text_from_path(path)?;
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
670
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,670 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{get_claude_settings_path, ConfigStatus};
|
||||
use crate::vscode;
|
||||
use crate::config;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err("Claude 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||
if !auth.is_object() {
|
||||
return Err("Codex auth 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
if let Some(config_value) = settings.get("config") {
|
||||
if !(config_value.is_string() || config_value.is_null()) {
|
||||
return Err("Codex config 字段必须是字符串".to_string());
|
||||
}
|
||||
if let Some(cfg_text) = config_value.as_str() {
|
||||
codex_config::validate_config_toml(cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub async fn get_providers(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<HashMap<String, Provider>, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.get_all_providers().clone())
|
||||
}
|
||||
|
||||
/// 获取当前供应商ID
|
||||
#[tauri::command]
|
||||
pub async fn get_current_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.current.clone())
|
||||
}
|
||||
|
||||
/// 添加供应商
|
||||
#[tauri::command]
|
||||
pub async fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取当前是否是激活供应商(短锁)
|
||||
let is_current = {
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager.current == provider.id
|
||||
};
|
||||
|
||||
// 若目标为当前供应商,则先写 live,成功后再落盘配置
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内存并保存配置
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager
|
||||
.providers
|
||||
.insert(provider.id.clone(), provider.clone());
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 更新供应商
|
||||
#[tauri::command]
|
||||
pub async fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取校验 & 是否当前(短锁)
|
||||
let (exists, is_current) = {
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
(
|
||||
manager.providers.contains_key(&provider.id),
|
||||
manager.current == provider.id,
|
||||
)
|
||||
};
|
||||
if !exists {
|
||||
return Err(format!("供应商不存在: {}", provider.id));
|
||||
}
|
||||
|
||||
// 若更新的是当前供应商,先写 live 成功再保存
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内存并保存
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager
|
||||
.providers
|
||||
.insert(provider.id.clone(), provider.clone());
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除供应商
|
||||
#[tauri::command]
|
||||
pub async fn delete_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查是否为当前供应商
|
||||
if manager.current == id {
|
||||
return Err("不能删除当前正在使用的供应商".to_string());
|
||||
}
|
||||
|
||||
// 获取供应商信息
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// 删除配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::delete_codex_provider_config(&id, &provider.name)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{delete_file, get_provider_config_path};
|
||||
// 兼容历史两种命名:settings-{name}.json 与 settings-{id}.json
|
||||
let by_name = get_provider_config_path(&id, Some(&provider.name));
|
||||
let by_id = get_provider_config_path(&id, None);
|
||||
delete_file(&by_name)?;
|
||||
delete_file(&by_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 从管理器删除
|
||||
manager.providers.remove(&id);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
#[tauri::command]
|
||||
pub async fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查供应商是否存在
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
use serde_json::Value;
|
||||
|
||||
// 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config
|
||||
if !manager.current.is_empty() {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
let config_path = codex_config::get_codex_config_path();
|
||||
if auth_path.exists() {
|
||||
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
||||
let config_str = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let live = serde_json::json!({
|
||||
"auth": auth,
|
||||
"config": config_str,
|
||||
});
|
||||
|
||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
||||
cur.settings_config = live;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换:从目标供应商 settings_config 写入主配置(Codex 双文件原子+回滚)
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{read_json_file, write_json_file};
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
|
||||
// 回填:读取 live settings.json 写回当前供应商 settings_config
|
||||
if settings_path.exists() && !manager.current.is_empty() {
|
||||
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
|
||||
if let Some(cur) = manager.providers.get_mut(&manager.current) {
|
||||
cur.settings_config = live;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换:从目标供应商 settings_config 写入主配置
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 不做归档,直接写入
|
||||
write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前供应商
|
||||
manager.current = id;
|
||||
|
||||
log::info!("成功切换到供应商: {}", provider.name);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 导入当前配置为默认供应商
|
||||
#[tauri::command]
|
||||
pub async fn import_default_config(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
// 仅当 providers 为空时才从 live 导入一条默认项
|
||||
{
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
if let Some(manager) = config.get_manager(&app_type) {
|
||||
if !manager.get_all_providers().is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据应用类型导入配置
|
||||
// 读取当前主配置为默认供应商(不再写入副本文件)
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err("Codex 配置文件不存在".to_string());
|
||||
}
|
||||
let auth: serde_json::Value =
|
||||
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
|
||||
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
serde_json::json!({ "auth": auth, "config": config_str })
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
return Err("Claude Code 配置文件不存在".to_string());
|
||||
}
|
||||
crate::config::read_json_file::<serde_json::Value>(&settings_path)?
|
||||
}
|
||||
};
|
||||
|
||||
// 创建默认供应商(仅首次初始化)
|
||||
let provider = Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
// 添加到管理器
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
// 设置当前供应商为默认项
|
||||
manager.current = "default".to_string();
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
Ok(crate::config::get_claude_config_status())
|
||||
}
|
||||
|
||||
/// 获取应用配置状态(通用)
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
let app = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
match app {
|
||||
AppType::Claude => Ok(crate::config::get_claude_config_status()),
|
||||
AppType::Codex => {
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_dir};
|
||||
let auth_path = get_codex_auth_path();
|
||||
|
||||
// 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空
|
||||
let exists = auth_path.exists();
|
||||
let path = get_codex_config_dir().to_string_lossy().to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置文件路径
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 打开配置文件夹
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn open_config_folder(
|
||||
handle: tauri::AppHandle,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config_dir = match app_type {
|
||||
AppType::Claude => crate::config::get_claude_config_dir(),
|
||||
AppType::Codex => crate::codex_config::get_codex_config_dir(),
|
||||
};
|
||||
|
||||
// 确保目录存在
|
||||
if !config_dir.exists() {
|
||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 使用 opener 插件打开文件夹
|
||||
handle
|
||||
.opener()
|
||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 打开外部链接
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
|
||||
// 规范化 URL,缺少协议时默认加 https://
|
||||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url
|
||||
} else {
|
||||
format!("https://{}", url)
|
||||
};
|
||||
|
||||
// 使用 opener 插件打开链接
|
||||
app.opener()
|
||||
.open_url(&url, None::<String>)
|
||||
.map_err(|e| format!("打开链接失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
#[tauri::command]
|
||||
pub async fn get_app_config_path() -> Result<String, String> {
|
||||
use crate::config::get_app_config_path;
|
||||
|
||||
let config_path = get_app_config_path();
|
||||
Ok(config_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 打开应用配置文件夹
|
||||
#[tauri::command]
|
||||
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
use crate::config::get_app_config_dir;
|
||||
|
||||
let config_dir = get_app_config_dir();
|
||||
|
||||
// 确保目录存在
|
||||
if !config_dir.exists() {
|
||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 使用 opener 插件打开文件夹
|
||||
handle
|
||||
.opener()
|
||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
|
||||
Ok(serde_json::json!({
|
||||
"showInTray": true
|
||||
}))
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
_state: State<'_, AppState>,
|
||||
settings: serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
|
||||
log::info!("保存设置: {:?}", settings);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 检查更新
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
// 打开 GitHub releases 页面
|
||||
handle
|
||||
.opener()
|
||||
.open_url(
|
||||
"https://github.com/farion1231/cc-switch/releases",
|
||||
None::<String>,
|
||||
)
|
||||
.map_err(|e| format!("打开更新页面失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// VS Code: 获取用户 settings.json 状态
|
||||
#[tauri::command]
|
||||
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
Ok(ConfigStatus { exists: true, path: p.to_string_lossy().to_string() })
|
||||
} else {
|
||||
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
|
||||
let preferred = vscode::candidate_settings_paths().into_iter().next();
|
||||
Ok(ConfigStatus { exists: false, path: preferred.unwrap_or_default().to_string_lossy().to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
/// VS Code: 读取 settings.json 文本(仅当文件存在)
|
||||
#[tauri::command]
|
||||
pub async fn read_vscode_settings() -> Result<String, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
|
||||
} else {
|
||||
Err("未找到 VS Code 用户设置文件".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
|
||||
#[tauri::command]
|
||||
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
|
||||
if let Some(p) = vscode::find_existing_settings() {
|
||||
config::write_text_file(&p, &content)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("未找到 VS Code 用户设置文件".to_string())
|
||||
}
|
||||
}
|
||||
224
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
// unused import removed
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 主配置文件路径
|
||||
pub fn get_claude_settings_path() -> PathBuf {
|
||||
let dir = get_claude_config_dir();
|
||||
let settings = dir.join("settings.json");
|
||||
if settings.exists() {
|
||||
return settings;
|
||||
}
|
||||
// 兼容旧版命名:若存在旧文件则继续使用
|
||||
let legacy = dir.join("claude.json");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
// 默认新建:回落到标准文件名 settings.json(不再生成 claude.json)
|
||||
settings
|
||||
}
|
||||
|
||||
/// 获取应用配置目录路径 (~/.cc-switch)
|
||||
pub fn get_app_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
pub fn get_app_config_path() -> PathBuf {
|
||||
get_app_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
/// 归档根目录 ~/.cc-switch/archive
|
||||
pub fn get_archive_root() -> PathBuf {
|
||||
get_app_config_dir().join("archive")
|
||||
}
|
||||
|
||||
fn ensure_unique_path(dest: PathBuf) -> PathBuf {
|
||||
if !dest.exists() {
|
||||
return dest;
|
||||
}
|
||||
let file_name = dest
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let ext = dest
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default();
|
||||
for i in 2..1000 {
|
||||
let mut candidate = parent.clone();
|
||||
candidate.push(format!("{}-{}{}", file_name, i, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
dest
|
||||
}
|
||||
|
||||
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
|
||||
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, String> {
|
||||
if !src.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut dest_dir = get_archive_root();
|
||||
dest_dir.push(ts.to_string());
|
||||
dest_dir.push(category);
|
||||
fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?;
|
||||
|
||||
let file_name = src
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let mut dest = dest_dir.join(file_name);
|
||||
dest = ensure_unique_path(dest);
|
||||
|
||||
copy_file(src, &dest)?;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
|
||||
/// 清理供应商名称,确保文件名安全
|
||||
pub fn sanitize_provider_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
|
||||
_ => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// 获取供应商配置文件路径
|
||||
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||
let base_name = provider_name
|
||||
.map(|name| sanitize_provider_name(name))
|
||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||
|
||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("文件不存在: {}", path.display()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件
|
||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
}
|
||||
|
||||
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
atomic_write(path, data.as_bytes())
|
||||
}
|
||||
|
||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
|
||||
let mut tmp = parent.to_path_buf();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| "无效的文件名".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
||||
|
||||
{
|
||||
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
|
||||
f.write_all(data)
|
||||
.map_err(|e| format!("写入临时文件失败: {}", e))?;
|
||||
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let perm = meta.permissions().mode();
|
||||
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 复制文件
|
||||
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
|
||||
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除文件
|
||||
pub fn delete_file(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 Claude Code 配置状态
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ConfigStatus {
|
||||
pub exists: bool,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
pub fn get_claude_config_status() -> ConfigStatus {
|
||||
let path = get_claude_settings_path();
|
||||
ConfigStatus {
|
||||
exists: path.exists(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
//(移除未使用的备份/导入函数,避免 dead_code 告警)
|
||||
390
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
mod app_config;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod vscode;
|
||||
mod migration;
|
||||
mod provider;
|
||||
mod store;
|
||||
|
||||
use store::AppState;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::RunEvent;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
fn create_tray_menu(
|
||||
app: &tauri::AppHandle,
|
||||
app_state: &AppState,
|
||||
) -> Result<Menu<tauri::Wry>, String> {
|
||||
let config = app_state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
|
||||
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||
let claude_header =
|
||||
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
||||
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&claude_header);
|
||||
|
||||
if !claude_manager.providers.is_empty() {
|
||||
for (id, provider) in &claude_manager.providers {
|
||||
let is_current = claude_manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("claude_{}", id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("创建菜单项失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
} else {
|
||||
// 没有供应商时显示提示
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
"claude_empty",
|
||||
" (无供应商,请在主界面添加)",
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&empty_hint);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
|
||||
// 添加Codex标题(禁用状态,仅作为分组标识)
|
||||
let codex_header =
|
||||
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
||||
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&codex_header);
|
||||
|
||||
if !codex_manager.providers.is_empty() {
|
||||
for (id, provider) in &codex_manager.providers {
|
||||
let is_current = codex_manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("codex_{}", id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("创建菜单项失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
} else {
|
||||
// 没有供应商时显示提示
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
"codex_empty",
|
||||
" (无供应商,请在主界面添加)",
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&empty_hint);
|
||||
}
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
|
||||
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
|
||||
|
||||
menu_builder = menu_builder.separator().item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
.build()
|
||||
.map_err(|e| format!("构建菜单失败: {}", e))
|
||||
}
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
log::info!("处理托盘菜单事件: {}", event_id);
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
id if id.starts_with("claude_") => {
|
||||
let provider_id = id.strip_prefix("claude_").unwrap();
|
||||
log::info!("切换到Claude供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = switch_provider_internal(
|
||||
&app_handle,
|
||||
crate::app_config::AppType::Claude,
|
||||
provider_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("切换Claude供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
id if id.starts_with("codex_") => {
|
||||
let provider_id = id.strip_prefix("codex_").unwrap();
|
||||
log::info!("切换到Codex供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = switch_provider_internal(
|
||||
&app_handle,
|
||||
crate::app_config::AppType::Codex,
|
||||
provider_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("切换Codex供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未处理的菜单事件: {}", event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// 内部切换供应商函数
|
||||
async fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
app_type: crate::app_config::AppType,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
// 在使用前先保存需要的值
|
||||
let app_type_str = app_type.as_str().to_string();
|
||||
let provider_id_clone = provider_id.clone();
|
||||
|
||||
crate::commands::switch_provider(
|
||||
app_state.clone().into(),
|
||||
Some(app_type),
|
||||
None,
|
||||
None,
|
||||
provider_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 切换成功后重新创建托盘菜单
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("更新托盘菜单失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发射事件到前端,通知供应商已切换
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("发射供应商切换事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新托盘菜单的Tauri命令
|
||||
#[tauri::command]
|
||||
async fn update_tray_menu(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
tray.set_menu(Some(new_menu))
|
||||
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
if let Err(e) = app
|
||||
.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
{
|
||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_app_kit::NSColor;
|
||||
|
||||
let ns_window_ptr = window.ns_window().unwrap();
|
||||
let ns_window: Retained<AnyObject> =
|
||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
|
||||
|
||||
// 使用与主界面 banner 相同的蓝色 #3498db
|
||||
// #3498db = RGB(52, 152, 219)
|
||||
let bg_color = unsafe {
|
||||
NSColor::colorWithRed_green_blue_alpha(
|
||||
52.0 / 255.0, // R: 52
|
||||
152.0 / 255.0, // G: 152
|
||||
219.0 / 255.0, // B: 219
|
||||
1.0, // Alpha: 1.0
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
use objc2::msg_send;
|
||||
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||
let app_state = AppState::new();
|
||||
|
||||
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
||||
{
|
||||
let mut config_guard = app_state.config.lock().unwrap();
|
||||
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
|
||||
if migrated {
|
||||
log::info!("已将副本文件导入到 config.json,并完成归档");
|
||||
}
|
||||
// 确保两个 App 条目存在
|
||||
config_guard.ensure_app(&app_config::AppType::Claude);
|
||||
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
let _ = app_state.save();
|
||||
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(&app.handle(), &app_state)?;
|
||||
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
.on_tray_icon_event(|_tray, event| match event {
|
||||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||
TrayIconEvent::Click { .. } => {}
|
||||
_ => log::debug!("unhandled event {event:?}"),
|
||||
})
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, &event.id.0);
|
||||
})
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
|
||||
|
||||
let _tray = tray_builder.build(app)?;
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_providers,
|
||||
commands::get_current_provider,
|
||||
commands::add_provider,
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
commands::open_config_folder,
|
||||
commands::open_external,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::check_for_updates,
|
||||
commands::get_vscode_settings_status,
|
||||
commands::read_vscode_settings,
|
||||
commands::write_vscode_settings,
|
||||
update_tray_menu,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||
match event {
|
||||
RunEvent::Reopen { .. } => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app_handle, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cc_switch_lib::run();
|
||||
}
|
||||
437
src-tauri/src/migration.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use crate::app_config::{AppType, MultiAppConfig};
|
||||
use crate::config::{
|
||||
archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn get_marker_path() -> PathBuf {
|
||||
get_app_config_dir().join("migrated.copies.v1")
|
||||
}
|
||||
|
||||
fn sanitized_id(base: &str) -> String {
|
||||
crate::config::sanitize_provider_name(base)
|
||||
}
|
||||
|
||||
fn next_unique_id(existing: &HashSet<String>, base: &str) -> String {
|
||||
let base = sanitized_id(base);
|
||||
if !existing.contains(&base) {
|
||||
return base;
|
||||
}
|
||||
for i in 2..1000 {
|
||||
let candidate = format!("{}-{}", base, i);
|
||||
if !existing.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
format!("{}-dup", base)
|
||||
}
|
||||
|
||||
fn extract_claude_api_key(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_codex_api_key(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("auth")
|
||||
.and_then(|auth| {
|
||||
auth.get("OPENAI_API_KEY")
|
||||
.or_else(|| auth.get("openai_api_key"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn norm_name(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
|
||||
// 去重策略:name + 原始 key 直接比较(不做哈希)
|
||||
|
||||
fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
|
||||
let mut items = Vec::new();
|
||||
let dir = get_claude_config_dir();
|
||||
if !dir.exists() {
|
||||
return items;
|
||||
}
|
||||
if let Ok(rd) = fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let fname = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if fname == "settings.json" || fname == "claude.json" {
|
||||
continue;
|
||||
}
|
||||
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let name = fname
|
||||
.trim_start_matches("settings-")
|
||||
.trim_end_matches(".json");
|
||||
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
|
||||
items.push((name.to_string(), p, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)> {
|
||||
let mut by_name: HashMap<String, (Option<PathBuf>, Option<PathBuf>)> = HashMap::new();
|
||||
let dir = crate::codex_config::get_codex_config_dir();
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Ok(rd) = fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let fname = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if fname.starts_with("auth-") && fname.ends_with(".json") {
|
||||
let name = fname.trim_start_matches("auth-").trim_end_matches(".json");
|
||||
let entry = by_name.entry(name.to_string()).or_default();
|
||||
entry.0 = Some(p);
|
||||
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
|
||||
let name = fname
|
||||
.trim_start_matches("config-")
|
||||
.trim_end_matches(".toml");
|
||||
let entry = by_name.entry(name.to_string()).or_default();
|
||||
entry.1 = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (name, (auth_path, config_path)) in by_name {
|
||||
if let Some(authp) = auth_path {
|
||||
if let Ok(auth) = crate::config::read_json_file::<Value>(&authp) {
|
||||
let config_str = if let Some(cfgp) = &config_path {
|
||||
match crate::codex_config::read_and_validate_config_from_path(cfgp) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("跳过无效 Codex config-{}.toml: {}", name, e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let settings = serde_json::json!({
|
||||
"auth": auth,
|
||||
"config": config_str,
|
||||
});
|
||||
items.push((name, Some(authp), config_path, settings));
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||
let marker = get_marker_path();
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
||||
}
|
||||
if marker.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let claude_items = scan_claude_copies();
|
||||
let codex_items = scan_codex_copies();
|
||||
if claude_items.is_empty() && codex_items.is_empty() {
|
||||
// 即便没有可迁移项,也写入标记避免每次扫描
|
||||
fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 备份旧的 config.json
|
||||
let ts = now_ts();
|
||||
let app_cfg_path = get_app_config_path();
|
||||
if app_cfg_path.exists() {
|
||||
let _ = archive_file(ts, "cc-switch", &app_cfg_path);
|
||||
}
|
||||
|
||||
// 读取 live:Claude(settings.json / claude.json)
|
||||
let live_claude: Option<(String, Value)> = {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
if settings_path.exists() {
|
||||
match crate::config::read_json_file::<Value>(&settings_path) {
|
||||
Ok(val) => Some(("default".to_string(), val)),
|
||||
Err(e) => {
|
||||
log::warn!("读取 Claude live 配置失败: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 合并:Claude(优先 live,然后副本) - 去重键: name + apiKey(直接比较)
|
||||
config.ensure_app(&AppType::Claude);
|
||||
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
|
||||
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
|
||||
let mut live_claude_id: Option<String> = None;
|
||||
|
||||
if let Some((name, value)) = &live_claude {
|
||||
let cand_key = extract_claude_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
|
||||
prov.settings_config = value.clone();
|
||||
live_claude_id = Some(exist_id);
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
live_claude_id = Some(id);
|
||||
}
|
||||
}
|
||||
for (name, path, value) in claude_items.iter() {
|
||||
let cand_key = extract_claude_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!(
|
||||
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
|
||||
name,
|
||||
path.display()
|
||||
);
|
||||
prov.settings_config = value.clone();
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 live:Codex(auth.json 必需,config.toml 可空)
|
||||
let live_codex: Option<(String, Value)> = {
|
||||
let auth_path = crate::codex_config::get_codex_auth_path();
|
||||
if auth_path.exists() {
|
||||
match crate::config::read_json_file::<Value>(&auth_path) {
|
||||
Ok(auth) => {
|
||||
let cfg = match crate::codex_config::read_and_validate_codex_config_text() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("读取/校验 Codex live config.toml 失败: {}", e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
Some((
|
||||
"default".to_string(),
|
||||
serde_json::json!({"auth": auth, "config": cfg}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("读取 Codex live auth.json 失败: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 合并:Codex(优先 live,然后副本) - 去重键: name + OPENAI_API_KEY(直接比较)
|
||||
config.ensure_app(&AppType::Codex);
|
||||
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
|
||||
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
|
||||
let mut live_codex_id: Option<String> = None;
|
||||
|
||||
if let Some((name, value)) = &live_codex {
|
||||
let cand_key = extract_codex_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
|
||||
prov.settings_config = value.clone();
|
||||
live_codex_id = Some(exist_id);
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
live_codex_id = Some(id);
|
||||
}
|
||||
}
|
||||
for (name, authp, cfgp, value) in codex_items.iter() {
|
||||
let cand_key = extract_codex_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!(
|
||||
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
|
||||
name,
|
||||
authp,
|
||||
cfgp
|
||||
);
|
||||
prov.settings_config = value.clone();
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 若当前为空,将 live 导入项设为当前
|
||||
{
|
||||
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
|
||||
if manager.current.is_empty() {
|
||||
if let Some(id) = live_claude_id {
|
||||
manager.current = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
|
||||
if manager.current.is_empty() {
|
||||
if let Some(id) = live_codex_id {
|
||||
manager.current = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 归档副本文件
|
||||
for (_, p, _) in claude_items.into_iter() {
|
||||
match archive_file(ts, "claude", &p) {
|
||||
Ok(Some(_)) => {
|
||||
let _ = delete_file(&p);
|
||||
}
|
||||
_ => {
|
||||
// 归档失败则不要删除原文件,保守处理
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, ap, cp, _) in codex_items.into_iter() {
|
||||
if let Some(ap) = ap {
|
||||
match archive_file(ts, "codex", &ap) {
|
||||
Ok(Some(_)) => {
|
||||
let _ = delete_file(&ap);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(cp) = cp {
|
||||
match archive_file(ts, "codex", &cp) {
|
||||
Ok(Some(_)) => {
|
||||
let _ = delete_file(&cp);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记完成
|
||||
// 仅在迁移阶段执行一次全量去重(忽略大小写的名称 + API Key)
|
||||
let removed = dedupe_config(config);
|
||||
if removed > 0 {
|
||||
log::info!("迁移阶段已去重重复供应商 {} 个", removed);
|
||||
}
|
||||
|
||||
fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 启动时对现有配置做一次去重:按名称(忽略大小写)+API Key
|
||||
pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
|
||||
use std::collections::HashMap as Map;
|
||||
|
||||
fn dedupe_one(
|
||||
mgr: &mut crate::provider::ProviderManager,
|
||||
extract_key: &dyn Fn(&Value) -> Option<String>,
|
||||
) -> usize {
|
||||
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
|
||||
let mut remove: Vec<String> = Vec::new();
|
||||
for (id, p) in mgr.providers.iter() {
|
||||
let k = format!(
|
||||
"{}|{}",
|
||||
norm_name(&p.name),
|
||||
extract_key(&p.settings_config).unwrap_or_default()
|
||||
);
|
||||
if let Some(exist_id) = keep.get(&k) {
|
||||
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
|
||||
if *id == mgr.current {
|
||||
// 替换:把原先的标记为删除,改保留为当前
|
||||
remove.push(exist_id.clone());
|
||||
keep.insert(k, id.clone());
|
||||
} else {
|
||||
remove.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
keep.insert(k, id.clone());
|
||||
}
|
||||
}
|
||||
for id in remove.iter() {
|
||||
mgr.providers.remove(id);
|
||||
}
|
||||
remove.len()
|
||||
}
|
||||
|
||||
let mut removed = 0;
|
||||
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Claude) {
|
||||
removed += dedupe_one(mgr, &extract_claude_api_key);
|
||||
}
|
||||
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Codex) {
|
||||
removed += dedupe_one(mgr, &extract_codex_api_key);
|
||||
}
|
||||
removed
|
||||
}
|
||||
60
src-tauri/src/provider.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// SSOT 模式:不再写供应商副本文件
|
||||
|
||||
/// 供应商结构体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "settingsConfig")]
|
||||
pub settings_config: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// 从现有ID创建供应商
|
||||
pub fn with_id(
|
||||
id: String,
|
||||
name: String,
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商管理器
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderManager {
|
||||
pub providers: HashMap<String, Provider>,
|
||||
pub current: String,
|
||||
}
|
||||
|
||||
impl Default for ProviderManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
providers: HashMap::new(),
|
||||
current: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// 获取所有供应商
|
||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
31
src-tauri/src/store.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 全局应用状态
|
||||
pub struct AppState {
|
||||
pub config: Mutex<MultiAppConfig>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new() -> Self {
|
||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
||||
MultiAppConfig::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
config: Mutex::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config = self
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
config.save()
|
||||
}
|
||||
}
|
||||
61
src-tauri/src/vscode.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::path::{PathBuf};
|
||||
|
||||
/// 枚举可能的 VS Code 发行版配置目录名称
|
||||
fn vscode_product_dirs() -> Vec<&'static str> {
|
||||
vec![
|
||||
"Code", // VS Code Stable
|
||||
"Code - Insiders", // VS Code Insiders
|
||||
"VSCodium", // VSCodium
|
||||
"Code - OSS", // OSS 发行版
|
||||
]
|
||||
}
|
||||
|
||||
/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序)
|
||||
pub fn candidate_settings_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(
|
||||
home.join("Library").join("Application Support").join(prod).join("User").join("settings.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\Code\User\settings.json
|
||||
if let Some(roaming) = dirs::config_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(roaming.join(prod).join("User").join("settings.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Linux: ~/.config/Code/User/settings.json
|
||||
if let Some(config) = dirs::config_dir() {
|
||||
for prod in vscode_product_dirs() {
|
||||
paths.push(config.join(prod).join("User").join("settings.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// 返回第一个存在的 settings.json 路径
|
||||
pub fn find_existing_settings() -> Option<PathBuf> {
|
||||
for p in candidate_settings_paths() {
|
||||
if let Ok(meta) = std::fs::metadata(&p) {
|
||||
if meta.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
51
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.2.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "pnpm run dev:renderer",
|
||||
"beforeBuildCommand": "pnpm run build:renderer"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "",
|
||||
"width": 900,
|
||||
"height": 650,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Transparent"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
,
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||
"endpoints": [
|
||||
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
397
src/App.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Provider } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
import ProviderList from "./components/ProviderList";
|
||||
import AddProviderModal from "./components/AddProviderModal";
|
||||
import EditProviderModal from "./components/EditProviderModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import SettingsModal from "./components/SettingsModal";
|
||||
import { UpdateBadge } from "./components/UpdateBadge";
|
||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||
import { buttonStyles } from "./lib/styles";
|
||||
import { useDarkMode } from "./hooks/useDarkMode";
|
||||
import { extractErrorMessage } from "./utils/errorUtils";
|
||||
import { applyProviderToVSCode } from "./utils/vscodeSettings";
|
||||
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
|
||||
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
|
||||
|
||||
function App() {
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const { isAutoSyncEnabled } = useVSCodeAutoSync();
|
||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [notification, setNotification] = useState<{
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
} | null>(null);
|
||||
const [isNotificationVisible, setIsNotificationVisible] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 设置通知的辅助函数
|
||||
const showNotification = (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration = 3000,
|
||||
) => {
|
||||
// 清除之前的定时器
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// 立即显示通知
|
||||
setNotification({ message, type });
|
||||
setIsNotificationVisible(true);
|
||||
|
||||
// 设置淡出定时器
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsNotificationVisible(false);
|
||||
// 等待淡出动画完成后清除通知
|
||||
setTimeout(() => {
|
||||
setNotification(null);
|
||||
timeoutRef.current = null;
|
||||
}, 300); // 与CSS动画时间匹配
|
||||
}, duration);
|
||||
};
|
||||
|
||||
// 加载供应商列表
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, [activeApp]); // 当切换应用时重新加载
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听托盘切换事件(包括菜单切换)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("收到供应商切换事件:", data);
|
||||
}
|
||||
|
||||
// 如果当前应用类型匹配,则重新加载数据
|
||||
if (data.appType === activeApp) {
|
||||
await loadProviders();
|
||||
}
|
||||
|
||||
// 若为 Codex 且开启自动同步,则静默同步到 VS Code(覆盖)
|
||||
if (data.appType === "codex" && isAutoSyncEnabled) {
|
||||
await syncCodexToVSCode(data.providerId, true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("设置供应商切换监听器失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
// 清理监听器
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, [activeApp, isAutoSyncEnabled]);
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.api.getProviders(activeApp);
|
||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
||||
setProviders(loadedProviders);
|
||||
setCurrentProviderId(currentId);
|
||||
|
||||
// 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
|
||||
if (Object.keys(loadedProviders).length === 0) {
|
||||
await handleAutoImportDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
const handleAddProvider = async (provider: Omit<Provider, "id">) => {
|
||||
const newProvider: Provider = {
|
||||
...provider,
|
||||
id: generateId(),
|
||||
createdAt: Date.now(), // 添加创建时间戳
|
||||
};
|
||||
await window.api.addProvider(newProvider, activeApp);
|
||||
await loadProviders();
|
||||
setIsAddModalOpen(false);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
};
|
||||
|
||||
const handleEditProvider = async (provider: Provider) => {
|
||||
try {
|
||||
await window.api.updateProvider(provider, activeApp);
|
||||
await loadProviders();
|
||||
setEditingProviderId(null);
|
||||
// 显示编辑成功提示
|
||||
showNotification("供应商配置已保存", "success", 2000);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
setEditingProviderId(null);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const message = errorMessage
|
||||
? `保存失败:${errorMessage}`
|
||||
: "保存失败,请重试";
|
||||
showNotification(message, "error", errorMessage ? 6000 : 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
const provider = providers[id];
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: "删除供应商",
|
||||
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
|
||||
onConfirm: async () => {
|
||||
await window.api.deleteProvider(id, activeApp);
|
||||
await loadProviders();
|
||||
setConfirmDialog(null);
|
||||
showNotification("供应商删除成功", "success");
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 同步Codex供应商到VS Code设置(静默覆盖)
|
||||
const syncCodexToVSCode = async (providerId: string, silent = false) => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
if (!silent) {
|
||||
showNotification(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
const provider = providers[providerId];
|
||||
const isOfficial = provider?.category === "official";
|
||||
|
||||
// 非官方供应商需要解析 base_url(使用公共工具函数)
|
||||
let baseUrl: string | undefined = undefined;
|
||||
if (!isOfficial) {
|
||||
const parsed = getCodexBaseUrl(provider);
|
||||
if (!parsed) {
|
||||
if (!silent) {
|
||||
showNotification(
|
||||
"当前配置缺少 base_url,无法写入 VS Code",
|
||||
"error",
|
||||
4000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
baseUrl = parsed;
|
||||
}
|
||||
|
||||
const updatedSettings = applyProviderToVSCode(raw, {
|
||||
baseUrl,
|
||||
isOfficial,
|
||||
});
|
||||
if (updatedSettings !== raw) {
|
||||
await window.api.writeVSCodeSettings(updatedSettings);
|
||||
if (!silent) {
|
||||
showNotification("已同步到 VS Code", "success", 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发providers重新加载,以更新VS Code按钮状态
|
||||
await loadProviders();
|
||||
} catch (error: any) {
|
||||
console.error("同步到VS Code失败:", error);
|
||||
if (!silent) {
|
||||
const errorMessage = error?.message || "同步 VS Code 失败";
|
||||
showNotification(errorMessage, "error", 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.api.switchProvider(id, activeApp);
|
||||
if (success) {
|
||||
setCurrentProviderId(id);
|
||||
// 显示重启提示
|
||||
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
|
||||
showNotification(
|
||||
`切换成功!请重启 ${appName} 终端以生效`,
|
||||
"success",
|
||||
2000,
|
||||
);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
|
||||
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
|
||||
if (activeApp === "codex" && isAutoSyncEnabled) {
|
||||
await syncCodexToVSCode(id, true); // silent模式,不显示通知
|
||||
}
|
||||
} else {
|
||||
showNotification("切换失败,请检查配置", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 自动从 live 导入一条默认供应商(仅首次初始化时)
|
||||
const handleAutoImportDefault = async () => {
|
||||
try {
|
||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
||||
|
||||
if (result.success) {
|
||||
await loadProviders();
|
||||
showNotification("已从现有配置创建默认供应商", "success", 3000);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
}
|
||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
||||
} catch (error) {
|
||||
console.error("自动导入默认配置失败:", error);
|
||||
// 静默处理,不影响用户体验
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
{/* 顶部导航区域 - 固定高度 */}
|
||||
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||
title="在 GitHub 上查看"
|
||||
>
|
||||
CC Switch
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className={buttonStyles.icon}
|
||||
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
|
||||
>
|
||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className={buttonStyles.icon}
|
||||
title="设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加供应商
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 - 独立滚动 */}
|
||||
<main className="flex-1 overflow-y-scroll">
|
||||
<div className="pt-3 px-6 pb-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 通知组件 - 相对于视窗定位 */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
notification.type === "error"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onEdit={setEditingProviderId}
|
||||
appType={activeApp}
|
||||
onNotify={showNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isAddModalOpen && (
|
||||
<AddProviderModal
|
||||
appType={activeApp}
|
||||
onAdd={handleAddProvider}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingProviderId && providers[editingProviderId] && (
|
||||
<EditProviderModal
|
||||
appType={activeApp}
|
||||
provider={providers[editingProviderId]}
|
||||
onSave={handleEditProvider}
|
||||
onClose={() => setEditingProviderId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSettingsOpen && (
|
||||
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/icons/chatgpt.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1757750114641" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M202.112 678.656l200.64-112.64 3.392-9.792-3.392-5.44h-9.792l-33.6-2.048-114.624-3.072-99.456-4.224-96.384-5.12-24.192-5.12-22.72-29.952 2.304-14.976 20.48-13.696 29.12 2.56 64.576 4.416 96.832 6.72 70.208 4.096 104.064 10.88h16.576l2.304-6.72-5.696-4.16-4.352-4.096-100.224-67.968-108.48-71.744-56.768-41.344-30.72-20.928-15.488-19.584-6.72-42.88 27.84-30.72 37.504 2.56 9.536 2.56 37.952 29.184 81.088 62.784 105.856 77.952 15.488 12.928 6.208-4.352 0.768-3.136L395.264 360l-57.6-104.064-61.44-105.92-27.392-43.904-7.168-26.304c-2.56-10.88-4.48-19.904-4.48-30.976l31.808-43.136L286.592 0l42.304 5.696 17.856 15.488 26.304 60.16 42.624 94.72 66.112 128.896 19.392 38.208 10.24 35.392 3.904 10.88h6.72v-6.208l5.44-72.576 10.048-89.088 9.856-114.688 3.328-32.256 16-38.72 31.808-20.928 24.768 11.904 20.416 29.184-2.88 18.816-12.16 78.72-23.68 123.52-15.552 82.56h9.088l10.304-10.24 41.856-55.552 70.208-87.808 30.976-34.88 36.16-38.464 23.232-18.368h43.904l32.32 48.064-14.464 49.6-45.184 57.28-37.44 48.576-53.76 72.32-33.536 57.856 3.072 4.608 8-0.768 121.408-25.792 65.6-11.904 78.208-13.44 35.392 16.512 3.84 16.832-13.952 34.304-83.648 20.672-98.112 19.648-146.176 34.56-1.792 1.28 2.048 2.56 65.92 6.272 28.096 1.536h68.928l128.384 9.6 33.536 22.144 20.16 27.136-3.392 20.672-51.648 26.304-69.696-16.512-162.688-38.72-55.744-13.952h-7.744v4.672l46.464 45.44 85.184 76.928 106.688 99.2 5.376 24.512-13.632 19.328-14.464-2.048-93.76-70.464-36.16-31.808-81.856-68.928h-5.44v7.232l18.88 27.648 99.648 149.76 5.184 45.952-7.232 14.976-25.856 9.024-28.352-5.12L673.408 856l-60.16-92.16-48.576-82.624-5.952 3.392-28.672 308.544-13.44 15.744-30.976 11.904-25.792-19.648-13.696-31.744 13.696-62.72 16.512-81.92 13.44-65.024 12.16-80.832 7.232-26.88-0.512-1.792-5.952 0.768-60.928 83.648-92.736 125.248-73.344 78.528-17.536 6.976-30.464-15.808 2.816-28.16 17.024-24.96 101.504-129.152 61.184-80 39.552-46.272-0.256-6.72h-2.368L177.6 789.44l-48 6.144-20.736-19.328 2.56-31.744 9.856-10.368 81.088-55.744-0.256 0.256z" p-id="1476" fill="#bfbfbf"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,18 +1,22 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../../shared/types";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
appType: AppType;
|
||||
onAdd: (provider: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
||||
appType,
|
||||
onAdd,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="添加新供应商"
|
||||
submitText="添加"
|
||||
showPresets={true}
|
||||
51
src/components/AppSwitcher.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppType;
|
||||
onSwitch: (app: AppType) => void;
|
||||
}
|
||||
|
||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppType) => {
|
||||
if (app === activeApp) return;
|
||||
onSwitch(app);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("claude")}
|
||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "claude"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<ClaudeIcon
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "claude"
|
||||
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
||||
}
|
||||
/>
|
||||
<span>Claude</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("codex")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "codex"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<CodexIcon size={16} />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/BrandIcons.tsx
Normal file
79
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = "确定",
|
||||
cancelText = "取消",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle size={20} className="text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
39
src/components/EditProviderModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface EditProviderModalProps {
|
||||
appType: AppType;
|
||||
provider: Provider;
|
||||
onSave: (provider: Provider) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
appType,
|
||||
provider,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const handleSubmit = (data: Omit<Provider, "id">) => {
|
||||
onSave({
|
||||
...provider,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="编辑供应商"
|
||||
submitText="保存"
|
||||
initialData={provider}
|
||||
showPresets={false}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProviderModal;
|
||||
140
src/components/JsonEditor.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
import { linter, Diagnostic } from "@codemirror/lint";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
darkMode?: boolean;
|
||||
rows?: number;
|
||||
showValidation?: boolean;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder: placeholderText = "",
|
||||
darkMode = false,
|
||||
rows = 12,
|
||||
showValidation = true,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// JSON linter 函数
|
||||
const jsonLinter = useMemo(
|
||||
() =>
|
||||
linter((view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (!showValidation) return diagnostics;
|
||||
|
||||
const doc = view.state.doc.toString();
|
||||
if (!doc.trim()) return diagnostics;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(doc);
|
||||
// 检查是否是JSON对象
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// 格式正确
|
||||
} else {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message: "配置必须是JSON对象,不能是数组或其他类型",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 简单处理JSON解析错误
|
||||
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}),
|
||||
[showValidation],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// 创建编辑器扩展
|
||||
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
|
||||
const sizingTheme = EditorView.theme({
|
||||
"&": { minHeight: `${minHeightPx}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": {
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
fontSize: "14px",
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
json(),
|
||||
placeholder(placeholderText || ""),
|
||||
sizingTheme,
|
||||
jsonLinter,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
onChange(newValue);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// 如果启用深色模式,添加深色主题
|
||||
if (darkMode) {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
// 创建初始状态
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions,
|
||||
});
|
||||
|
||||
// 创建编辑器视图
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: editorRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
|
||||
const transaction = viewRef.current.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: viewRef.current.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
viewRef.current.dispatch(transaction);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={editorRef} style={{ width: "100%" }} />;
|
||||
};
|
||||
|
||||
export default JsonEditor;
|
||||
1290
src/components/ProviderForm.tsx
Normal file
70
src/components/ProviderForm/ApiKeyInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "请输入API Key",
|
||||
disabled = false,
|
||||
required = false,
|
||||
label = "API Key",
|
||||
id = "apiKey",
|
||||
}) => {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
const toggleShowKey = () => {
|
||||
setShowKey(!showKey);
|
||||
};
|
||||
|
||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
{!disabled && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleShowKey}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyInput;
|
||||
198
src/components/ProviderForm/ClaudeConfigEditor.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import JsonEditor from "../JsonEditor";
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
interface ClaudeConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
configError: string;
|
||||
}
|
||||
|
||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测暗色模式
|
||||
const checkDarkMode = () => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// 监听暗色模式变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
checkDarkMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="settingsConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
/>
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</p>
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑通用配置片段
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时合并到 settings.json 中
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeConfigEditor;
|
||||
244
src/components/ProviderForm/CodexConfigEditor.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
configValue: string;
|
||||
onAuthChange: (value: string) => void;
|
||||
onConfigChange: (value: string) => void;
|
||||
onAuthBlur?: () => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
authError: string;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
authValue,
|
||||
configValue,
|
||||
onAuthChange,
|
||||
onConfigChange,
|
||||
onAuthBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
authError,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
|
||||
const handleAuthChange = (value: string) => {
|
||||
onAuthChange(value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
onConfigChange(value);
|
||||
};
|
||||
|
||||
const handleCommonConfigSnippetChange = (value: string) => {
|
||||
onCommonConfigSnippetChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexAuth"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
auth.json (JSON) *
|
||||
</label>
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={authValue}
|
||||
onChange={(e) => handleAuthChange(e.target.value)}
|
||||
onBlur={onAuthBlur}
|
||||
placeholder={`{
|
||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||
}`}
|
||||
rows={6}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex auth.json 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
config.toml (TOML)
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={configValue}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder=""
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex config.toml 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑 Codex 通用配置片段
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时追加到 config.toml 末尾
|
||||
</p>
|
||||
<textarea
|
||||
value={commonConfigSnippet}
|
||||
onChange={(e) =>
|
||||
handleCommonConfigSnippetChange(e.target.value)
|
||||
}
|
||||
placeholder={`# Common Codex config
|
||||
# Add your common TOML configuration here`}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexConfigEditor;
|
||||
185
src/components/ProviderForm/KimiModelSelector.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
|
||||
|
||||
interface KimiModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface KimiModelSelectorProps {
|
||||
apiKey: string;
|
||||
anthropicModel: string;
|
||||
anthropicSmallFastModel: string;
|
||||
onModelChange: (
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
apiKey,
|
||||
anthropicModel,
|
||||
anthropicSmallFastModel,
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [models, setModels] = useState<KimiModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [debouncedKey, setDebouncedKey] = useState("");
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModelsWithKey = async (key: string) => {
|
||||
if (!key) {
|
||||
setError("请先填写 API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.moonshot.cn/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
setModels(data.data);
|
||||
} else {
|
||||
throw new Error("返回数据格式错误");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取模型列表失败:", err);
|
||||
setError(err instanceof Error ? err.message : "获取模型列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 500ms 防抖 API Key
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKey(apiKey.trim());
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [apiKey]);
|
||||
|
||||
// 当防抖后的 Key 改变时自动获取模型列表
|
||||
useEffect(() => {
|
||||
if (debouncedKey) {
|
||||
fetchModelsWithKey(debouncedKey);
|
||||
} else {
|
||||
setModels([]);
|
||||
setError("");
|
||||
}
|
||||
}, [debouncedKey]);
|
||||
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
const ModelSelect: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading || models.length === 0}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">
|
||||
{loading
|
||||
? "加载中..."
|
||||
: models.length === 0
|
||||
? "暂无模型"
|
||||
: "请选择模型"}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
模型配置
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||
disabled={disabled || loading || !debouncedKey}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
刷新模型列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
label="主模型"
|
||||
value={anthropicModel}
|
||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||
/>
|
||||
<ModelSelect
|
||||
label="快速模型"
|
||||
value={anthropicSmallFastModel}
|
||||
onChange={(value) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!apiKey.trim() && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
💡 填写 API Key 后将自动获取可用模型列表
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KimiModelSelector;
|
||||
110
src/components/ProviderForm/PresetSelector.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ProviderCategory } from "../../types";
|
||||
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
||||
|
||||
interface Preset {
|
||||
name: string;
|
||||
isOfficial?: boolean;
|
||||
category?: ProviderCategory;
|
||||
}
|
||||
|
||||
interface PresetSelectorProps {
|
||||
title?: string;
|
||||
presets: Preset[];
|
||||
selectedIndex: number | null;
|
||||
onSelectPreset: (index: number) => void;
|
||||
onCustomClick: () => void;
|
||||
customLabel?: string;
|
||||
}
|
||||
|
||||
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
title = "选择配置类型",
|
||||
presets,
|
||||
selectedIndex,
|
||||
onSelectPreset,
|
||||
onCustomClick,
|
||||
customLabel = "自定义",
|
||||
}) => {
|
||||
const getButtonClass = (index: number, preset?: Preset) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const baseClass =
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
if (preset?.isOfficial || preset?.category === "official") {
|
||||
// Codex 官方使用黑色背景
|
||||
if (preset?.name.includes("Codex")) {
|
||||
return `${baseClass} bg-gray-900 text-white`;
|
||||
}
|
||||
// Claude 官方使用品牌色背景
|
||||
return `${baseClass} bg-[#D97757] text-white`;
|
||||
}
|
||||
return `${baseClass} bg-blue-500 text-white`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (selectedIndex === -1) {
|
||||
return "手动配置供应商,需要填写完整的配置信息";
|
||||
}
|
||||
|
||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||
const preset = presets[selectedIndex];
|
||||
return preset?.isOfficial || preset?.category === "official"
|
||||
? "官方登录,不需要填写 API Key"
|
||||
: "使用预设配置,只需填写 API Key";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{title}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||
onClick={onCustomClick}
|
||||
>
|
||||
{customLabel}
|
||||
</button>
|
||||
{presets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={getButtonClass(index, preset)}
|
||||
onClick={() => onSelectPreset(index)}
|
||||
>
|
||||
{(preset.isOfficial || preset.category === "official") && (
|
||||
<>
|
||||
{preset.name.includes("Claude") ? (
|
||||
<ClaudeIcon size={14} />
|
||||
) : preset.name.includes("Codex") ? (
|
||||
<CodexIcon size={14} />
|
||||
) : (
|
||||
<Zap size={14} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{getDescription() && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{getDescription()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetSelector;
|
||||
350
src/components/ProviderList.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Provider } from "../types";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import {
|
||||
applyProviderToVSCode,
|
||||
detectApplied,
|
||||
normalizeBaseUrl,
|
||||
} from "../utils/vscodeSettings";
|
||||
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
|
||||
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
|
||||
// 不再在列表中显示分类徽章,避免造成困惑
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
currentProviderId: string;
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
appType?: AppType;
|
||||
onNotify?: (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration?: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
providers,
|
||||
currentProviderId,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit,
|
||||
appType,
|
||||
onNotify,
|
||||
}) => {
|
||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
const cfg = provider.settingsConfig;
|
||||
// Claude/Anthropic: 从 env 中读取
|
||||
if (cfg?.env?.ANTHROPIC_BASE_URL) {
|
||||
return cfg.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
// Codex: 从 TOML 配置中解析 base_url
|
||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||
// 支持单/双引号
|
||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
if (match && match[2]) return match[2];
|
||||
}
|
||||
return "未配置官网地址";
|
||||
} catch {
|
||||
return "配置错误";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlClick = async (url: string) => {
|
||||
try {
|
||||
await window.api.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error("打开链接失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 Codex 配置中的 base_url(已提取到公共工具)
|
||||
|
||||
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
|
||||
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
|
||||
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
|
||||
|
||||
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
if (appType !== "codex" || !currentProviderId) {
|
||||
setVscodeAppliedFor(null);
|
||||
return;
|
||||
}
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
setVscodeAppliedFor(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await window.api.readVSCodeSettings();
|
||||
const detected = detectApplied(content);
|
||||
// 认为“已应用”的条件(非官方供应商):VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
|
||||
const current = providers[currentProviderId];
|
||||
let applied = false;
|
||||
if (current && current.category !== "official") {
|
||||
const base = getCodexBaseUrl(current);
|
||||
if (detected.apiBase && base) {
|
||||
applied =
|
||||
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
|
||||
}
|
||||
}
|
||||
setVscodeAppliedFor(applied ? currentProviderId : null);
|
||||
} catch {
|
||||
setVscodeAppliedFor(null);
|
||||
}
|
||||
};
|
||||
check();
|
||||
}, [appType, currentProviderId, providers]);
|
||||
|
||||
const handleApplyToVSCode = async (provider: Provider) => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
onNotify?.(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
|
||||
const isOfficial = provider.category === "official";
|
||||
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
|
||||
if (!isOfficial) {
|
||||
const parsed = getCodexBaseUrl(provider);
|
||||
if (!parsed) {
|
||||
onNotify?.("当前配置缺少 base_url,无法写入 VS Code", "error", 4000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
|
||||
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
|
||||
|
||||
if (next === raw) {
|
||||
// 幂等:没有变化也提示成功
|
||||
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(provider.id);
|
||||
// 用户手动应用时,启用自动同步
|
||||
enableAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.api.writeVSCodeSettings(next);
|
||||
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(provider.id);
|
||||
// 用户手动应用时,启用自动同步
|
||||
enableAutoSync();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromVSCode = async () => {
|
||||
try {
|
||||
const status = await window.api.getVSCodeSettingsStatus();
|
||||
if (!status.exists) {
|
||||
onNotify?.(
|
||||
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||
"error",
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const raw = await window.api.readVSCodeSettings();
|
||||
const next = applyProviderToVSCode(raw, {
|
||||
baseUrl: undefined,
|
||||
isOfficial: true,
|
||||
});
|
||||
if (next === raw) {
|
||||
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(null);
|
||||
// 用户手动移除时,禁用自动同步
|
||||
disableAutoSync();
|
||||
return;
|
||||
}
|
||||
await window.api.writeVSCodeSettings(next);
|
||||
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||
setVscodeAppliedFor(null);
|
||||
// 用户手动移除时,禁用自动同步
|
||||
disableAutoSync();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const msg = e && e.message ? e.message : "移除失败";
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// 对供应商列表进行排序
|
||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||
// 按添加时间排序
|
||||
// 没有时间戳的视为最早添加的(排在最前面)
|
||||
// 有时间戳的按时间升序排列
|
||||
const timeA = a.createdAt || 0;
|
||||
const timeB = b.createdAt || 0;
|
||||
|
||||
// 如果都没有时间戳,按名称排序
|
||||
if (timeA === 0 && timeB === 0) {
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
|
||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||
if (timeA === 0) return -1;
|
||||
if (timeB === 0) return 1;
|
||||
|
||||
// 都有时间戳,按时间升序
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedProviders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
还没有添加任何供应商
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
点击右上角的"添加供应商"按钮开始配置您的第一个API供应商
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProviders.map((provider) => {
|
||||
const isCurrent = provider.id === currentProviderId;
|
||||
const apiUrl = getApiUrl(provider);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={cn(
|
||||
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{/* 分类徽章已移除 */}
|
||||
<div
|
||||
className={cn(
|
||||
badgeStyles.success,
|
||||
!isCurrent && "invisible",
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
当前使用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{provider.websiteUrl ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleUrlClick(provider.websiteUrl!);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
|
||||
title={`访问 ${provider.websiteUrl}`}
|
||||
>
|
||||
{provider.websiteUrl}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
title={apiUrl}
|
||||
>
|
||||
{apiUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{appType === "codex" &&
|
||||
provider.category !== "official" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
vscodeAppliedFor === provider.id
|
||||
? handleRemoveFromVSCode()
|
||||
: handleApplyToVSCode(provider)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] justify-center",
|
||||
!isCurrent && "invisible",
|
||||
vscodeAppliedFor === provider.id
|
||||
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||
)}
|
||||
title={
|
||||
vscodeAppliedFor === provider.id
|
||||
? "从 VS Code 移除我们写入的配置"
|
||||
: "将当前供应商应用到 VS Code"
|
||||
}
|
||||
>
|
||||
{vscodeAppliedFor === provider.id
|
||||
? "从 VS Code 移除"
|
||||
: "应用到 VS Code"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSwitch(provider.id)}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||
isCurrent
|
||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
||||
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
)}
|
||||
>
|
||||
<Play size={14} />
|
||||
{isCurrent ? "使用中" : "启用"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onEdit(provider.id)}
|
||||
className={buttonStyles.icon}
|
||||
title="编辑供应商"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(provider.id)}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
buttonStyles.icon,
|
||||
isCurrent
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
|
||||
)}
|
||||
title="删除供应商"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderList;
|
||||