Compare commits
509 Commits
v3.1.2
...
fix/third-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a6c08673 | ||
|
|
74969ae968 | ||
|
|
1f3627add3 | ||
|
|
14ee122b27 | ||
|
|
7aecba14fe | ||
|
|
99b5f881e8 | ||
|
|
286bafbd67 | ||
|
|
6046cf8767 | ||
|
|
c88afa365f | ||
|
|
93fa5fe29a | ||
|
|
3d31ad64af | ||
|
|
bb0951552d | ||
|
|
00e3e6fa70 | ||
|
|
1ce007622e | ||
|
|
436f0e8e42 | ||
|
|
3d69da5b66 | ||
|
|
0ae9ed5a17 | ||
|
|
5ff689af82 | ||
|
|
b9412ece0b | ||
|
|
ec303544ca | ||
|
|
023726c59d | ||
|
|
8d2c067814 | ||
|
|
a00eb764f7 | ||
|
|
67bd8f5c11 | ||
|
|
3051743bd3 | ||
|
|
883cf0346b | ||
|
|
1805ed586e | ||
|
|
98a1305684 | ||
|
|
f79efb86cd | ||
|
|
ed59420a83 | ||
|
|
bfc27349b3 | ||
|
|
4fc7413ffa | ||
|
|
12112e9d7d | ||
|
|
6a6980c82c | ||
|
|
031ea3a58f | ||
|
|
9d431cc7ae | ||
|
|
685a1138e4 | ||
|
|
154ff4c819 | ||
|
|
2540f6ba08 | ||
|
|
d32ceb9b80 | ||
|
|
e11c7d84cd | ||
|
|
ea8f2095e2 | ||
|
|
09f80d82bc | ||
|
|
2f18d6ec00 | ||
|
|
fafca841cb | ||
|
|
f4b8aed29a | ||
|
|
9663b4251e | ||
|
|
9e8abf5f26 | ||
|
|
32a6de074c | ||
|
|
ac09551563 | ||
|
|
7ae2a9f556 | ||
|
|
c985db8f3d | ||
|
|
c7b235bb98 | ||
|
|
1616c63c0b | ||
|
|
146b42fb68 | ||
|
|
0ea434a485 | ||
|
|
21fd7cc9fd | ||
|
|
434c64f38d | ||
|
|
6d8e822f8d | ||
|
|
2fae8c9275 | ||
|
|
30c763ffe3 | ||
|
|
e4d7999294 | ||
|
|
34f7139fda | ||
|
|
a85f24f616 | ||
|
|
b9743a463d | ||
|
|
2f02514a14 | ||
|
|
75866044bd | ||
|
|
155532ea8c | ||
|
|
346f916048 | ||
|
|
8a05e7bd3d | ||
|
|
32a2ba5ef6 | ||
|
|
4502b2f973 | ||
|
|
6cb930b4ec | ||
|
|
9a5c8c0e57 | ||
|
|
b1abdf95aa | ||
|
|
be155c857e | ||
|
|
9d6f101006 | ||
|
|
2a56a0d889 | ||
|
|
7096957b40 | ||
|
|
23d06515ad | ||
|
|
3210202132 | ||
|
|
7b52c44a9d | ||
|
|
772081312e | ||
|
|
cfcd7b892a | ||
|
|
3da787b9af | ||
|
|
9370054911 | ||
|
|
5b3b211c9a | ||
|
|
fb02881684 | ||
|
|
34b8aa1008 | ||
|
|
52a7f9d313 | ||
|
|
b617879035 | ||
|
|
8d77866160 | ||
|
|
7305b1124b | ||
|
|
dc79c31148 | ||
|
|
03e15916dd | ||
|
|
69c0a09604 | ||
|
|
4e23250755 | ||
|
|
5f78e58ffc | ||
|
|
e4416c9da8 | ||
|
|
6f05a91226 | ||
|
|
db80e96786 | ||
|
|
d6fa0060fb | ||
|
|
4f4c1e4ed7 | ||
|
|
ce24b37b39 | ||
|
|
a428e618d2 | ||
|
|
21d29b9c2d | ||
|
|
254896e5eb | ||
|
|
dbf220d85f | ||
|
|
b498f0fe91 | ||
|
|
f92dd4cc5a | ||
|
|
720c4d9774 | ||
|
|
bafddb8e52 | ||
|
|
05e58e9e14 | ||
|
|
802c3bffd9 | ||
|
|
7be74806e8 | ||
|
|
a6b6c199b4 | ||
|
|
0778347f84 | ||
|
|
49c2855b10 | ||
|
|
ccb011fba1 | ||
|
|
0f62829599 | ||
|
|
cc5d59ce56 | ||
|
|
4afa68eac6 | ||
|
|
36fd61b2a2 | ||
|
|
85334d8dce | ||
|
|
ab2833e626 | ||
|
|
b4f10d8316 | ||
|
|
50eb4538ca | ||
|
|
c56866f48c | ||
|
|
972650377d | ||
|
|
faeca6b6ce | ||
|
|
cb83089866 | ||
|
|
ebb7106102 | ||
|
|
4811aa2dcd | ||
|
|
2ebe34810c | ||
|
|
87f408c163 | ||
|
|
b1f7840e45 | ||
|
|
c168873c1e | ||
|
|
fa9f4997af | ||
|
|
717be12991 | ||
|
|
ef85b015d3 | ||
|
|
def4095e4e | ||
|
|
b3e14b3985 | ||
|
|
64f2220ad9 | ||
|
|
55223bdd46 | ||
|
|
8e4a0a1bbb | ||
|
|
80dd6e9381 | ||
|
|
931ef7d3dd | ||
|
|
2aec407a2f | ||
|
|
ead65d82ad | ||
|
|
08f480ec94 | ||
|
|
590be4e136 | ||
|
|
7d56aed543 | ||
|
|
1841f8b462 | ||
|
|
5c3aca18eb | ||
|
|
88a952023f | ||
|
|
9e72e786e3 | ||
|
|
7b1a68ee4e | ||
|
|
7e27f88154 | ||
|
|
c2e8855a0f | ||
|
|
8e980e6974 | ||
|
|
10abdfa096 | ||
|
|
6a9aa7aeb5 | ||
|
|
9f5c2b427f | ||
|
|
4aa9512e36 | ||
|
|
1cc0e4bc8d | ||
|
|
c01e495eea | ||
|
|
bfab1d0ccb | ||
|
|
d064cb8555 | ||
|
|
76a8d1760b | ||
|
|
885dd94803 | ||
|
|
c3f712bc18 | ||
|
|
c8c4656e0e | ||
|
|
521c69db92 | ||
|
|
d65621a556 | ||
|
|
0b40e200f5 | ||
|
|
b3c333c908 | ||
|
|
001ac14c85 | ||
|
|
96a8712f2d | ||
|
|
2c7dcb023a | ||
|
|
019ad351a1 | ||
|
|
c2031c9b5c | ||
|
|
89aef39c74 | ||
|
|
bbf830a1da | ||
|
|
7325edff35 | ||
|
|
28900b8920 | ||
|
|
c2517571f5 | ||
|
|
d296471b3b | ||
|
|
36767045ce | ||
|
|
fb0dc5b186 | ||
|
|
07787a2ee1 | ||
|
|
495e66e3b6 | ||
|
|
6cc75d5c24 | ||
|
|
e38ff843e7 | ||
|
|
ae6d16ccae | ||
|
|
3504fae4cb | ||
|
|
bc185602ca | ||
|
|
7a694fbcb0 | ||
|
|
cbd1903b90 | ||
|
|
3626880663 | ||
|
|
13acc5323c | ||
|
|
39981f8075 | ||
|
|
9144014803 | ||
|
|
0de818b8b1 | ||
|
|
505fa47feb | ||
|
|
ef53439f83 | ||
|
|
491bbff11d | ||
|
|
43ed1c7533 | ||
|
|
a0cb29d3b2 | ||
|
|
57661817d3 | ||
|
|
eb6948a562 | ||
|
|
bae6a1cf55 | ||
|
|
b036a94281 | ||
|
|
a5fff93732 | ||
|
|
5253e7ec37 | ||
|
|
9fc5555ecf | ||
|
|
9d6ccb6d15 | ||
|
|
08eed46919 | ||
|
|
57552b3159 | ||
|
|
404297cd30 | ||
|
|
320bf3eeac | ||
|
|
5ebe23abc8 | ||
|
|
e6b66f425a | ||
|
|
8e82ded158 | ||
|
|
c04f636bbe | ||
|
|
f5c6363dee | ||
|
|
2d3d717826 | ||
|
|
bcaebc1bcb | ||
|
|
e02175e68d | ||
|
|
9fb000b8fe | ||
|
|
0cff882a3f | ||
|
|
d9c56511b1 | ||
|
|
b8a435a7f6 | ||
|
|
edfb61186d | ||
|
|
f963d58e6a | ||
|
|
8d6ab63648 | ||
|
|
d3f2c3c901 | ||
|
|
c1f5ddf763 | ||
|
|
54b0b3b139 | ||
|
|
51c68ef192 | ||
|
|
41dd487471 | ||
|
|
17f350f2d3 | ||
|
|
8a724b79ec | ||
|
|
9d75a646ee | ||
|
|
0868a71576 | ||
|
|
8ce574bdd2 | ||
|
|
856beb3b70 | ||
|
|
74afca7b58 | ||
|
|
e4f85f4f65 | ||
|
|
6541c14421 | ||
|
|
fe4b3e9957 | ||
|
|
918e519b05 | ||
|
|
a32aeaf73e | ||
|
|
577998fef2 | ||
|
|
3b22bcc134 | ||
|
|
98c35c7c62 | ||
|
|
2c1346a23d | ||
|
|
31f56f7c86 | ||
|
|
cfefe6b52a | ||
|
|
92528e6a9f | ||
|
|
5f2bede5c4 | ||
|
|
bb48f4f6af | ||
|
|
f3e7412a14 | ||
|
|
2b45af118f | ||
|
|
b88eb88608 | ||
|
|
cc0b7053aa | ||
|
|
95e2d84655 | ||
|
|
9eb991d087 | ||
|
|
3b6048b1e8 | ||
|
|
3e4df2c96a | ||
|
|
59644b29e6 | ||
|
|
5427ae04e4 | ||
|
|
a2aa5f8434 | ||
|
|
06010ff78e | ||
|
|
e77eab2116 | ||
|
|
ed9dd7bbc3 | ||
|
|
3d20245a80 | ||
|
|
f55c6d3d91 | ||
|
|
ec83e2ca44 | ||
|
|
60e8351f60 | ||
|
|
4a9eb64f76 | ||
|
|
66bbf63300 | ||
|
|
6e2c80531d | ||
|
|
2ec0a10a2c | ||
|
|
e92d99b758 | ||
|
|
036d41b774 | ||
|
|
3bd70b9508 | ||
|
|
664efc7456 | ||
|
|
1415ef4d78 | ||
|
|
fb137c4a78 | ||
|
|
668ab710c6 | ||
|
|
ea7080a42e | ||
|
|
c2b27a4949 | ||
|
|
a6ee3ba35f | ||
|
|
2a60d20841 | ||
|
|
42329d4dce | ||
|
|
5013d3b4c9 | ||
|
|
9ba9cddf18 | ||
|
|
81356cacee | ||
|
|
3b142155c3 | ||
|
|
4543664ba2 | ||
|
|
e88562be98 | ||
|
|
bfdf7d4ad5 | ||
|
|
c350e64687 | ||
|
|
70d8d2cc43 | ||
|
|
56b2681a6f | ||
|
|
6cf7dacd0e | ||
|
|
428369cae0 | ||
|
|
7f1131dfae | ||
|
|
7493f3f9dd | ||
|
|
eb8d9352c8 | ||
|
|
29b8d5edde | ||
|
|
97d81c13b7 | ||
|
|
511980e3ea | ||
|
|
f6bf8611cd | ||
|
|
0be596afb5 | ||
|
|
2bb847cb3d | ||
|
|
9471cb0d19 | ||
|
|
d0fe9d7533 | ||
|
|
59c13c3366 | ||
|
|
96a4b4fe95 | ||
|
|
e0e84ca58a | ||
|
|
c6a062f64a | ||
|
|
94192a3720 | ||
|
|
e7a584c5ba | ||
|
|
e9833e9a57 | ||
|
|
6afc436946 | ||
|
|
c89bf0c6f0 | ||
|
|
a6d461282d | ||
|
|
75ce5a0723 | ||
|
|
3f3905fda0 | ||
|
|
01da9a1eac | ||
|
|
420a4234de | ||
|
|
ca488cf076 | ||
|
|
3ad11acdb2 | ||
|
|
f8c40d591f | ||
|
|
ce593248fc | ||
|
|
e0908701b4 | ||
|
|
d86994eb7e | ||
|
|
94e93137a2 | ||
|
|
db832a9654 | ||
|
|
45a639e73f | ||
|
|
f74d641f86 | ||
|
|
fcfa9574e8 | ||
|
|
d739bb36e5 | ||
|
|
0bcc04adce | ||
|
|
fee0762e3e | ||
|
|
1a8ae85e55 | ||
|
|
c5aa244d65 | ||
|
|
0bedbb2663 | ||
|
|
5f3caa1484 | ||
|
|
fd0e83ebd5 | ||
|
|
e969bdbd73 | ||
|
|
7435a34c66 | ||
|
|
5d2d15690c | ||
|
|
11ee8bddf7 | ||
|
|
186c361a79 | ||
|
|
cc1caea36d | ||
|
|
9ede0ad27d | ||
|
|
20f0dd7e1c | ||
|
|
4dd07dfd85 | ||
|
|
8c01be42fa | ||
|
|
aaf1af0743 | ||
|
|
aeb0007957 | ||
|
|
077d491720 | ||
|
|
7e9930fe50 | ||
|
|
b17d915086 | ||
|
|
3e834e2c38 | ||
|
|
cae625dab1 | ||
|
|
122d7f1ad6 | ||
|
|
7eaf284400 | ||
|
|
86ef7afbdf | ||
|
|
615c431875 | ||
|
|
d041ea7a56 | ||
|
|
c4c1747563 | ||
|
|
c284fe8348 | ||
|
|
8f932b7358 | ||
|
|
d9e940e7a7 | ||
|
|
2147db6707 | ||
|
|
8c826b3073 | ||
|
|
54f1357bcc | ||
|
|
b8d2daccde | ||
|
|
21205272a5 | ||
|
|
ef067a6968 | ||
|
|
84204889f0 | ||
|
|
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 |
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
|
||||||
278
.github/workflows/release.yml
vendored
@@ -8,15 +8,19 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- os: windows-2022
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-22.04
|
||||||
- os: macos-latest
|
- os: macos-14
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -74,7 +78,7 @@ jobs:
|
|||||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
@@ -83,6 +87,62 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Prepare Tauri signing key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# 调试:检查 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)
|
- name: Build Tauri App (macOS)
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
run: pnpm tauri build --target universal-apple-darwin
|
run: pnpm tauri build --target universal-apple-darwin
|
||||||
@@ -101,29 +161,38 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
echo "Looking for .app bundle..."
|
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||||
APP_PATH=""
|
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||||
|
TAR_GZ=""; APP_PATH=""
|
||||||
for path in \
|
for path in \
|
||||||
"src-tauri/target/release/bundle/macos" \
|
|
||||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
|
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||||
|
"src-tauri/target/release/bundle/macos"; do
|
||||||
if [ -d "$path" ]; then
|
if [ -d "$path" ]; then
|
||||||
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||||
[ -n "$APP_PATH" ] && break
|
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$APP_PATH" ]; then
|
if [ -z "$TAR_GZ" ]; then
|
||||||
echo "No .app found" >&2
|
echo "No macOS .tar.gz updater artifact found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
APP_DIR=$(dirname "$APP_PATH")
|
# 重命名 tar.gz 为统一格式
|
||||||
APP_NAME=$(basename "$APP_PATH")
|
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
|
||||||
cd "$APP_DIR"
|
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
|
||||||
# 使用 ditto 打包更兼容资源分叉
|
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
|
||||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
echo "macOS updater artifact copied: $NEW_TAR_GZ"
|
||||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
if [ -n "$APP_PATH" ]; then
|
||||||
echo "macOS zip ready"
|
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||||
|
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
|
||||||
|
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
|
||||||
|
echo "macOS zip ready: $NEW_ZIP"
|
||||||
|
else
|
||||||
|
echo "No .app found to zip (optional)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Prepare Windows Assets
|
- name: Prepare Windows Assets
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -131,18 +200,28 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||||
# 安装器(优先 NSIS,其次 MSI)
|
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
||||||
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||||
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
Select-Object -First 1
|
if ($null -eq $msi) {
|
||||||
if ($null -ne $installer) {
|
# 兜底:全局搜索 .msi
|
||||||
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
Copy-Item $installer.FullName (Join-Path release-assets $dest)
|
|
||||||
Write-Host "Installer copied: $dest"
|
|
||||||
} else {
|
|
||||||
Write-Warning 'No Windows installer found'
|
|
||||||
}
|
}
|
||||||
# 绿色版(portable):仅可执行文件
|
if ($null -ne $msi) {
|
||||||
|
$dest = "CC-Switch-$VERSION-Windows.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 = @(
|
$exeCandidates = @(
|
||||||
'src-tauri/target/release/cc-switch.exe',
|
'src-tauri/target/release/cc-switch.exe',
|
||||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||||
@@ -152,9 +231,16 @@ jobs:
|
|||||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||||
Copy-Item $exePath $portableDir
|
Copy-Item $exePath $portableDir
|
||||||
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
||||||
|
$portableContent = @(
|
||||||
|
'# CC Switch portable build marker',
|
||||||
|
'portable=true'
|
||||||
|
)
|
||||||
|
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
||||||
|
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
|
||||||
|
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
||||||
Remove-Item -Recurse -Force $portableDir
|
Remove-Item -Recurse -Force $portableDir
|
||||||
Write-Host 'Windows portable zip created'
|
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'Portable exe not found'
|
Write-Warning 'Portable exe not found'
|
||||||
}
|
}
|
||||||
@@ -165,14 +251,25 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
# 仅上传安装包(deb)
|
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||||
|
# Updater artifact: AppImage(含对应 .sig)
|
||||||
|
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||||
|
if [ -n "$APPIMAGE" ]; then
|
||||||
|
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
|
||||||
|
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
|
||||||
|
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
|
||||||
|
echo "AppImage copied: $NEW_APPIMAGE"
|
||||||
|
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)
|
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||||
if [ -n "$DEB" ]; then
|
if [ -n "$DEB" ]; then
|
||||||
cp "$DEB" release-assets/
|
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
|
||||||
echo "Deb package copied"
|
cp "$DEB" "release-assets/$NEW_DEB"
|
||||||
|
echo "Deb package copied: $NEW_DEB"
|
||||||
else
|
else
|
||||||
echo "No .deb found" >&2
|
echo "No .deb found (optional)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: List prepared assets
|
- name: List prepared assets
|
||||||
@@ -180,11 +277,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ls -la release-assets || true
|
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
|
- name: Upload Release Assets
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: CC Switch ${{ github.ref_name }}
|
name: CC Switch ${{ github.ref_name }}
|
||||||
|
prerelease: true
|
||||||
body: |
|
body: |
|
||||||
## CC Switch ${{ github.ref_name }}
|
## CC Switch ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -192,12 +297,12 @@ jobs:
|
|||||||
|
|
||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||||
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)
|
||||||
|
|
||||||
---
|
---
|
||||||
提示:macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -208,3 +313,92 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Listing bundles in src-tauri/target..."
|
echo "Listing bundles in src-tauri/target..."
|
||||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
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"
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -9,3 +9,11 @@ release/
|
|||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
|
/.claude
|
||||||
|
/.codex
|
||||||
|
/.gemini
|
||||||
|
/.cc-switch
|
||||||
|
/.idea
|
||||||
|
/.vscode
|
||||||
|
vitest-report.json
|
||||||
|
|||||||
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v22.4.1
|
||||||
511
CHANGELOG.md
@@ -5,18 +5,479 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.7.0] - 2025-11-19
|
||||||
|
|
||||||
|
### Major Features
|
||||||
|
|
||||||
|
#### Gemini CLI Integration
|
||||||
|
|
||||||
|
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
|
||||||
|
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
|
||||||
|
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- **MCP management** - Full MCP configuration capabilities for Gemini
|
||||||
|
- **Provider presets**
|
||||||
|
- Google Official (OAuth authentication)
|
||||||
|
- PackyCode (partner integration)
|
||||||
|
- Custom endpoint support
|
||||||
|
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
|
||||||
|
- **System tray integration** - Quick-switch Gemini providers from tray menu
|
||||||
|
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
|
||||||
|
|
||||||
|
#### MCP v3.7.0 Unified Architecture
|
||||||
|
|
||||||
|
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
|
||||||
|
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
|
||||||
|
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
|
||||||
|
- **Extended field support** - Preserve custom fields in Codex TOML conversion
|
||||||
|
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
|
||||||
|
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
|
||||||
|
- **UX improvements**
|
||||||
|
- Default app selection in forms
|
||||||
|
- JSON formatter for config validation
|
||||||
|
- Improved layout and visual hierarchy
|
||||||
|
- Better validation error messages
|
||||||
|
|
||||||
|
#### Claude Skills Management System
|
||||||
|
|
||||||
|
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
|
||||||
|
- **Pre-configured repositories**
|
||||||
|
- `ComposioHQ/awesome-claude-skills` (curated collection)
|
||||||
|
- `anthropics/skills` (official Anthropic skills)
|
||||||
|
- `cexll/myclaude` (community, with subdirectory scanning)
|
||||||
|
- **Lifecycle management**
|
||||||
|
- One-click install to `~/.claude/skills/`
|
||||||
|
- Safe uninstall with state tracking
|
||||||
|
- Update checking (infrastructure ready)
|
||||||
|
- **Custom repository support** - Add any GitHub repo as a skill source
|
||||||
|
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
|
||||||
|
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
|
||||||
|
- **Frontend interface**
|
||||||
|
- SkillsPage: Browse and manage skills
|
||||||
|
- SkillCard: Visual skill presentation
|
||||||
|
- RepoManager: Repository management dialog
|
||||||
|
- **State persistence** - Installation state stored in `skills.json`
|
||||||
|
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
|
||||||
|
|
||||||
|
#### Prompts (System Prompts) Management
|
||||||
|
|
||||||
|
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
|
||||||
|
- **Cross-app support**
|
||||||
|
- Claude: `~/.claude/CLAUDE.md`
|
||||||
|
- Codex: `~/.codex/AGENTS.md`
|
||||||
|
- Gemini: `~/.gemini/GEMINI.md`
|
||||||
|
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
|
||||||
|
- **Smart synchronization**
|
||||||
|
- Auto-write to live files on enable
|
||||||
|
- Content backfill protection (save current before switching)
|
||||||
|
- First-launch auto-import from live files
|
||||||
|
- **Single-active enforcement** - Only one prompt can be active at a time
|
||||||
|
- **Delete protection** - Cannot delete active prompts
|
||||||
|
- **Backend service** - `PromptService` (213 lines) with CRUD operations
|
||||||
|
- **Frontend components**
|
||||||
|
- PromptPanel: Main management interface (177 lines)
|
||||||
|
- PromptFormModal: Edit dialog with validation (160 lines)
|
||||||
|
- MarkdownEditor: CodeMirror integration (159 lines)
|
||||||
|
- usePromptActions: Business logic hook (152 lines)
|
||||||
|
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
|
||||||
|
|
||||||
|
#### Deep Link Protocol (ccswitch://)
|
||||||
|
|
||||||
|
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
|
||||||
|
- **Provider import** - Import provider configurations from URLs or shared links
|
||||||
|
- **Lifecycle integration** - Deep link handling integrated into app startup
|
||||||
|
- **Cross-platform support** - Works on Windows, macOS, and Linux
|
||||||
|
|
||||||
|
#### Environment Variable Conflict Detection
|
||||||
|
|
||||||
|
- **Claude & Codex detection** - Identify conflicting environment variables
|
||||||
|
- **Gemini auto-detection** - Automatic environment variable discovery
|
||||||
|
- **Conflict management** - UI for resolving configuration conflicts
|
||||||
|
- **Prevention system** - Warn before overwriting existing configurations
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### Provider Management
|
||||||
|
|
||||||
|
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
|
||||||
|
- **Kimi For Coding** - Moonshot AI coding assistant
|
||||||
|
- **BaiLing preset** - BaiLing AI integration
|
||||||
|
- **Removed AnyRouter preset** - Discontinued provider
|
||||||
|
- **Model configuration** - Support for custom model names in Codex and Gemini
|
||||||
|
- **Provider notes field** - Add custom notes to providers for better organization
|
||||||
|
|
||||||
|
#### Configuration Management
|
||||||
|
|
||||||
|
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
|
||||||
|
- **Unified persistence** - Common config snippets now shared across all apps
|
||||||
|
- **Auto-import on first launch** - Automatically import configs from live files on first run
|
||||||
|
- **Backfill priority fix** - Correct priority handling when enabling prompts
|
||||||
|
|
||||||
|
#### UI/UX Improvements
|
||||||
|
|
||||||
|
- **macOS native design** - Migrated color scheme to macOS native design system
|
||||||
|
- **Window centering** - Default window position centered on screen
|
||||||
|
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
|
||||||
|
- **URL overflow prevention** - Fixed overflow in provider cards
|
||||||
|
- **Error notification enhancement** - Copy-to-clipboard for error messages
|
||||||
|
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
|
||||||
|
- **Unified structure** - Default initialization with v3.7.0 unified structure
|
||||||
|
- **Backward compatibility** - Compilation fixes for older configs
|
||||||
|
- **Code formatting** - Applied consistent formatting across backend and frontend
|
||||||
|
|
||||||
|
#### Platform Compatibility
|
||||||
|
|
||||||
|
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
|
||||||
|
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
|
||||||
|
- **Gemini form sync** - Fixed form fields syncing with environment editor
|
||||||
|
- **Gemini config reading** - Read from both `.env` and `settings.json`
|
||||||
|
- **Validation improvements** - Enhanced input validation and boundary checks
|
||||||
|
|
||||||
|
#### Internationalization
|
||||||
|
|
||||||
|
- **JSON syntax fixes** - Resolved syntax errors in locale files
|
||||||
|
- **App name i18n** - Added internationalization support for app names
|
||||||
|
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
|
||||||
|
- **Gemini MCP title** - Added missing Gemini MCP panel title
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
#### Critical Fixes
|
||||||
|
|
||||||
|
- **Usage script validation** - Added input validation and boundary checks
|
||||||
|
- **Gemini validation** - Relaxed validation when adding providers
|
||||||
|
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
|
||||||
|
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
|
||||||
|
- **Password input** - Fixed white screen crash (FormLabel → Label)
|
||||||
|
|
||||||
|
#### Stability
|
||||||
|
|
||||||
|
- **Tray menu safety** - Replaced unwrap with safe pattern matching
|
||||||
|
- **Error isolation** - Tray menu update failures don't block main operations
|
||||||
|
- **Import classification** - Set category to custom for imported default configs
|
||||||
|
|
||||||
|
#### UI Fixes
|
||||||
|
|
||||||
|
- **Model placeholders** - Removed misleading model input placeholders
|
||||||
|
- **Base URL population** - Auto-fill base URL for non-official providers
|
||||||
|
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
|
||||||
|
- **Type safety** - Complete TypeScript type coverage across codebase
|
||||||
|
- **Test improvements** - Simplified boolean assertions in tests
|
||||||
|
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
|
||||||
|
- **Code refactoring** - Extracted templates, optimized logic flows
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
|
||||||
|
- **Tauri** - Updated to 2.8.x series
|
||||||
|
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
|
||||||
|
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
|
||||||
|
- **winreg** - Updated to v0.52 (Windows compatibility)
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
- **Startup optimization** - Removed legacy migration scanning
|
||||||
|
- **Lock management** - Improved RwLock usage to prevent deadlocks
|
||||||
|
- **Background query** - Enabled background mode for usage polling
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
|
||||||
|
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
|
||||||
|
- **New modules**:
|
||||||
|
- Skills: 2,034 lines (21 files)
|
||||||
|
- Prompts: 1,302 lines (20 files)
|
||||||
|
- Gemini: ~1,000 lines (multiple files)
|
||||||
|
- MCP refactor: ~3,000 lines (refactored)
|
||||||
|
|
||||||
|
### Strategic Positioning
|
||||||
|
|
||||||
|
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
|
||||||
|
|
||||||
|
1. **Capability Extension** - Skills provide external ability integration
|
||||||
|
2. **Behavior Customization** - Prompts enable AI personality presets
|
||||||
|
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
|
||||||
|
4. **Ecosystem Openness** - Deep links enable community sharing
|
||||||
|
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
|
||||||
|
6. **Intelligent Detection** - Auto-discovery of environment conflicts
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
|
||||||
|
- Skills and Prompts management are new features requiring no migration
|
||||||
|
- Gemini CLI support requires Gemini CLI to be installed separately
|
||||||
|
- MCP v3.7.0 unified structure is backward compatible with previous configs
|
||||||
|
|
||||||
|
## [3.6.0] - 2025-11-07
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation
|
||||||
|
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
||||||
|
- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers
|
||||||
|
- **Usage Query Enhancements**
|
||||||
|
- Auto-refresh interval: Support periodic automatic usage query
|
||||||
|
- Test Script API: Validate JavaScript scripts before execution
|
||||||
|
- Template system expansion: Custom blank template, support for access token and user ID parameters
|
||||||
|
- **Configuration Editor Improvements**
|
||||||
|
- Add JSON format button
|
||||||
|
- Real-time TOML syntax validation for Codex configuration
|
||||||
|
- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation
|
||||||
|
- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
|
||||||
|
- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
||||||
|
- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- **Configuration Directory Switching**
|
||||||
|
- Introduced unified post-change sync utility (`postChangeSync.ts`)
|
||||||
|
- Auto-sync current providers to new directory when changing Claude/Codex config directories
|
||||||
|
- Perfect support for WSL environment switching
|
||||||
|
- Auto-sync after config import to ensure immediate effectiveness
|
||||||
|
- Use Result pattern for graceful error handling without blocking main flow
|
||||||
|
- Distinguish "fully successful" and "partially successful" states for precise user feedback
|
||||||
|
- **UI/UX Enhancements**
|
||||||
|
- Provider cards: Unique icons and color identification
|
||||||
|
- Unified border design system across all components
|
||||||
|
- Drag interaction optimization: Push effect animation, improved handle icons
|
||||||
|
- Enhanced current provider visual feedback
|
||||||
|
- Dialog size standardization and layout consistency
|
||||||
|
- Form experience: Optimized model placeholders, simplified provider hints, category-specific hints
|
||||||
|
- **Complete Internationalization Coverage**
|
||||||
|
- Error messages internationalization
|
||||||
|
- Tray menu internationalization
|
||||||
|
- All UI components internationalization
|
||||||
|
- **Usage Display Moved Inline** - Usage display moved next to enable button
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **Configuration Sync**
|
||||||
|
- Fixed `apiKeyUrl` priority issue
|
||||||
|
- Fixed MCP sync-to-other-side functionality failure
|
||||||
|
- Fixed sync issues after config import
|
||||||
|
- Prevent silent fallback and data loss on config error
|
||||||
|
- **Usage Query**
|
||||||
|
- Fixed auto-query interval timing issue
|
||||||
|
- Ensure refresh button shows loading animation on click
|
||||||
|
- **UI Issues**
|
||||||
|
- Fixed name collision error (`get_init_error` command)
|
||||||
|
- Fixed language setting rollback after successful save
|
||||||
|
- Fixed language switch state reset (dependency cycle)
|
||||||
|
- Fixed edit mode button alignment
|
||||||
|
- **Configuration Management**
|
||||||
|
- Fixed Codex API Key auto-sync
|
||||||
|
- Fixed endpoint speed test functionality
|
||||||
|
- Fixed provider duplicate insertion position (next to original provider)
|
||||||
|
- Fixed custom endpoint preservation in edit mode
|
||||||
|
- **Startup Issues**
|
||||||
|
- Force exit on config error (no silent fallback)
|
||||||
|
- Eliminate code duplication causing initialization errors
|
||||||
|
|
||||||
|
### 🏗️ Technical Improvements (For Developers)
|
||||||
|
|
||||||
|
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
|
||||||
|
|
||||||
|
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||||
|
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
|
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||||
|
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
||||||
|
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||||
|
|
||||||
|
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
|
||||||
|
|
||||||
|
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
||||||
|
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||||
|
- **Stage 3**: Component splitting and business logic extraction
|
||||||
|
- **Stage 4**: Code cleanup and formatting unification
|
||||||
|
|
||||||
|
**Testing System**:
|
||||||
|
|
||||||
|
- Hooks unit tests 100% coverage
|
||||||
|
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
|
||||||
|
- MSW mocking backend API to ensure test independence
|
||||||
|
|
||||||
|
**Code Quality**:
|
||||||
|
|
||||||
|
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
|
||||||
|
- `AppType` renamed to `AppId`: Semantically clearer
|
||||||
|
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
|
||||||
|
- Eliminate code duplication: DRY violations cleanup
|
||||||
|
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
|
||||||
|
|
||||||
|
**Internal Optimizations**:
|
||||||
|
|
||||||
|
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
||||||
|
- ✅ **Impact**: Improved startup performance, cleaner code
|
||||||
|
- ✅ **Compatibility**: v2 format configs fully compatible, no action required
|
||||||
|
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
|
||||||
|
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
|
||||||
|
- ✅ **Impact**: More standardized code, friendlier error prompts
|
||||||
|
- ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
||||||
|
|
||||||
|
### 📦 Dependencies
|
||||||
|
|
||||||
|
- Updated to Tauri 2.8.x
|
||||||
|
- Updated to TailwindCSS 4.x
|
||||||
|
- Updated to TanStack Query v5.90.x
|
||||||
|
- Maintained React 18.2.x and TypeScript 5.3.x
|
||||||
|
|
||||||
|
## [3.5.0] - 2025-01-15
|
||||||
|
|
||||||
|
### ⚠ Breaking Changes
|
||||||
|
|
||||||
|
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
||||||
|
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
|
||||||
|
- Add, edit, delete, and toggle MCP servers in `~/.claude.json`
|
||||||
|
- Support for stdio and http server types with command validation
|
||||||
|
- Built-in templates for popular MCP servers (mcp-fetch, etc.)
|
||||||
|
- Real-time enable/disable toggle for MCP servers
|
||||||
|
- Atomic file writing to prevent configuration corruption
|
||||||
|
- **Configuration Import/Export** - Backup and restore your provider configurations
|
||||||
|
- Export all configurations to JSON file with one click
|
||||||
|
- Import configurations with validation and automatic backup
|
||||||
|
- Automatic backup rotation (keeps 10 most recent backups)
|
||||||
|
- Progress modal with detailed status feedback
|
||||||
|
- **Endpoint Speed Testing** - Test API endpoint response times
|
||||||
|
- Measure latency to different provider endpoints
|
||||||
|
- Visual indicators for connection quality
|
||||||
|
- Help users choose the fastest provider
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- Complete internationalization (i18n) coverage for all UI components
|
||||||
|
- Enhanced error handling and user feedback throughout the application
|
||||||
|
- Improved configuration file management with better validation
|
||||||
|
- Added new provider presets: Longcat, kat-coder
|
||||||
|
- Updated GLM provider configurations with latest models
|
||||||
|
- Refined UI/UX with better spacing, icons, and visual feedback
|
||||||
|
- Enhanced tray menu functionality and responsiveness
|
||||||
|
- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:
|
||||||
|
- macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`
|
||||||
|
- Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`
|
||||||
|
- Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Fixed layout shifts during provider switching
|
||||||
|
- Improved config file path handling across different platforms
|
||||||
|
- Better error messages for configuration validation failures
|
||||||
|
- Fixed various edge cases in configuration import/export
|
||||||
|
|
||||||
|
### 📦 Technical Details
|
||||||
|
|
||||||
|
- Enhanced `import_export.rs` module with backup management
|
||||||
|
- New `claude_mcp.rs` module for MCP configuration handling
|
||||||
|
- Improved state management and lock handling in Rust backend
|
||||||
|
- Better TypeScript type safety across the codebase
|
||||||
|
|
||||||
|
## [3.4.0] - 2025-10-01
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
|
||||||
|
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
|
||||||
|
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
|
||||||
|
- Support portable mode launches and enforce a single running instance to avoid conflicts
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
|
||||||
|
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
|
||||||
|
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
|
||||||
|
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
|
||||||
|
- Fix layout shifts while switching app types with Claude plugin sync enabled
|
||||||
|
- Align Enable/In Use button states to avoid visual jank across app views
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-09-22
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants _(Removed in 3.4.x)_
|
||||||
|
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_
|
||||||
|
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
|
||||||
|
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
|
||||||
|
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
|
||||||
|
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
|
||||||
|
- Add a `created_at` timestamp to provider records for future sorting and analytics
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
|
||||||
|
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
|
||||||
|
- Bundle `@codemirror/lint` to reinstate live linting in config editors
|
||||||
|
|
||||||
|
## [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
|
## [3.1.1] - 2025-09-03
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed the default codex config.toml to match the latest modifications
|
- Fixed the default codex config.toml to match the latest modifications
|
||||||
- Improved provider configuration UX with custom option
|
- Improved provider configuration UX with custom option
|
||||||
|
|
||||||
### 📝 Documentation
|
### 📝 Documentation
|
||||||
|
|
||||||
- Updated README with latest information
|
- Updated README with latest information
|
||||||
|
|
||||||
## [3.1.0] - 2025-09-01
|
## [3.1.0] - 2025-09-01
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
|
||||||
- Manage auth.json and config.toml for Codex
|
- Manage auth.json and config.toml for Codex
|
||||||
- Support for backup and restore operations
|
- Support for backup and restore operations
|
||||||
@@ -33,12 +494,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- TOML syntax validation for config.toml
|
- TOML syntax validation for config.toml
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Unified Tauri command API with app_type parameter
|
- Unified Tauri command API with app_type parameter
|
||||||
- Backward compatibility for app/appType parameters
|
- Backward compatibility for app/appType parameters
|
||||||
- Added get_config_status/open_config_folder/open_external commands
|
- Added get_config_status/open_config_folder/open_external commands
|
||||||
- Improved error handling for empty config.toml
|
- Improved error handling for empty config.toml
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed config path reporting and folder opening for Codex
|
- Fixed config path reporting and folder opening for Codex
|
||||||
- Corrected default import behavior when main config is missing
|
- Corrected default import behavior when main config is missing
|
||||||
- Fixed non_snake_case warnings in commands.rs
|
- Fixed non_snake_case warnings in commands.rs
|
||||||
@@ -46,6 +509,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [3.0.0] - 2025-08-27
|
## [3.0.0] - 2025-08-27
|
||||||
|
|
||||||
### 🚀 Major Changes
|
### 🚀 Major Changes
|
||||||
|
|
||||||
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
- **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)
|
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
||||||
- **Significantly improved startup performance**
|
- **Significantly improved startup performance**
|
||||||
@@ -53,12 +517,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Enhanced security** with Rust backend
|
- **Enhanced security** with Rust backend
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- **Native window controls** with transparent title bar on macOS
|
- **Native window controls** with transparent title bar on macOS
|
||||||
- **Improved file system operations** using Rust for better performance
|
- **Improved file system operations** using Rust for better performance
|
||||||
- **Enhanced security model** with explicit permission declarations
|
- **Enhanced security model** with explicit permission declarations
|
||||||
- **Better platform detection** using Tauri's native APIs
|
- **Better platform detection** using Tauri's native APIs
|
||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- Migrated from Electron IPC to Tauri command system
|
- Migrated from Electron IPC to Tauri command system
|
||||||
- Replaced Node.js file operations with Rust implementations
|
- Replaced Node.js file operations with Rust implementations
|
||||||
- Implemented proper CSP (Content Security Policy) for enhanced security
|
- Implemented proper CSP (Content Security Policy) for enhanced security
|
||||||
@@ -66,28 +532,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Integrated Rust cargo fmt and clippy for code quality
|
- Integrated Rust cargo fmt and clippy for code quality
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
||||||
- Resolved platform detection issues
|
- Resolved platform detection issues
|
||||||
- Improved error handling in configuration management
|
- Improved error handling in configuration management
|
||||||
|
|
||||||
### 📦 Dependencies
|
### 📦 Dependencies
|
||||||
|
|
||||||
- **Tauri**: 2.8.2
|
- **Tauri**: 2.8.2
|
||||||
- **React**: 18.2.0
|
- **React**: 18.2.0
|
||||||
- **TypeScript**: 5.3.0
|
- **TypeScript**: 5.3.0
|
||||||
- **Vite**: 5.0.0
|
- **Vite**: 5.0.0
|
||||||
|
|
||||||
### 🔄 Migration Notes
|
### 🔄 Migration Notes
|
||||||
|
|
||||||
For users upgrading from v2.x (Electron version):
|
For users upgrading from v2.x (Electron version):
|
||||||
|
|
||||||
- Configuration files remain compatible - no action required
|
- Configuration files remain compatible - no action required
|
||||||
- The app will automatically migrate your existing provider configurations
|
- The app will automatically migrate your existing provider configurations
|
||||||
- Window position and size preferences have been reset to defaults
|
- Window position and size preferences have been reset to defaults
|
||||||
|
|
||||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
#### 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.
|
- 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`
|
- 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.
|
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||||
|
|
||||||
### 🛠️ Development
|
### 🛠️ Development
|
||||||
|
|
||||||
- Added `pnpm typecheck` command for TypeScript validation
|
- Added `pnpm typecheck` command for TypeScript validation
|
||||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||||
- Rust code now uses cargo fmt for consistent formatting
|
- Rust code now uses cargo fmt for consistent formatting
|
||||||
@@ -95,6 +567,7 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [2.0.0] - Previous Electron Release
|
## [2.0.0] - Previous Electron Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Multi-provider configuration management
|
- Multi-provider configuration management
|
||||||
- Quick provider switching
|
- Quick provider switching
|
||||||
- Import/export configurations
|
- Import/export configurations
|
||||||
@@ -105,6 +578,44 @@ For users upgrading from v2.x (Electron version):
|
|||||||
## [1.0.0] - Initial Release
|
## [1.0.0] - Initial Release
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- Configuration file handling
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ⚠️ Breaking Changes
|
||||||
|
|
||||||
|
- **Runtime auto-migration from v1 to v2 config format has been removed**
|
||||||
|
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
|
||||||
|
- When a v1 config is detected, the app now returns a clear error with migration instructions
|
||||||
|
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
|
||||||
|
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
|
||||||
|
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
|
||||||
|
|
||||||
|
- **Legacy v1 copy file migration logic has been removed**
|
||||||
|
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
|
||||||
|
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
|
||||||
|
- No longer archives copy files or performs automatic deduplication
|
||||||
|
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
|
||||||
|
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
|
||||||
|
|
||||||
|
- **Tauri commands now only accept `app` parameter**
|
||||||
|
- Removed legacy `app_type`/`appType` compatibility paths
|
||||||
|
- Explicit error with available values when unknown `app` is provided
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
|
||||||
|
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
|
||||||
|
- Simplified startup logic: Only ensures config structure exists, no migration overhead
|
||||||
|
|
||||||
|
### 🧪 Tests
|
||||||
|
|
||||||
|
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
|
||||||
|
- Added comprehensive config loading tests:
|
||||||
|
- `load_v1_config_returns_error_and_does_not_write`
|
||||||
|
- `load_v1_with_extra_version_still_treated_as_v1`
|
||||||
|
- `load_invalid_json_returns_parse_error_and_does_not_write`
|
||||||
|
- `load_valid_v2_config_succeeds`
|
||||||
|
|||||||
395
README.md
@@ -1,181 +1,368 @@
|
|||||||
# Claude Code & Codex 供应商切换器
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
|
||||||
[](https://tauri.app/)
|
|
||||||
|
|
||||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://github.com/trending/typescript)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
## 功能特性
|
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
|
||||||
|
|
||||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
</div>
|
||||||
- 一键切换不同供应商
|
|
||||||
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
|
||||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
|
||||||
- 支持添加自定义供应商
|
|
||||||
- 随时切换官方登录
|
|
||||||
- 简洁美观的图形界面
|
|
||||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
|
||||||
- 超小体积 - 仅 ~5MB 安装包
|
|
||||||
|
|
||||||
## 界面预览
|
## ❤️Sponsor
|
||||||
|
|
||||||
### 主界面
|

|
||||||
|
|
||||||

|
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
||||||
|
|
||||||
### 添加供应商
|
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
||||||
|
|
||||||

|
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
||||||
|
|
||||||
## 下载安装
|
---
|
||||||
|
|
||||||
### 系统要求
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||||
|
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
- **Windows**: Windows 10 及以上
|
## Screenshots
|
||||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
|
||||||
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
|
||||||
|
|
||||||
### Windows 用户
|
| Main Interface | Add Provider |
|
||||||
|
| :-----------------------------------------------: | :--------------------------------------------: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
## Features
|
||||||
|
|
||||||
### macOS 用户
|
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
**Core Capabilities**
|
||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||||
|
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
||||||
|
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||||
|
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||||
|
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
||||||
|
- **Claude Plugin Sync**: One-click apply/restore Claude plugin configurations
|
||||||
|
|
||||||
### Linux 用户
|
**v3.6 Highlights**
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
- Provider duplication & drag-and-drop sorting
|
||||||
|
- Multi-endpoint management & custom config directory (cloud sync ready)
|
||||||
|
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
|
||||||
|
- WSL environment support with auto-sync on directory change
|
||||||
|
- 100% hooks test coverage & complete architecture refactoring
|
||||||
|
- New presets: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
||||||
|
|
||||||
## 使用说明
|
**System Features**
|
||||||
|
|
||||||
1. 点击"添加供应商"添加你的 API 配置
|
- System tray with quick switching
|
||||||
2. 选择要使用的供应商,点击单选按钮切换
|
- Single instance daemon
|
||||||
3. 配置会自动保存到对应应用的配置文件中
|
- Built-in auto-updater
|
||||||
4. 重启或者新打开终端以生效
|
- Atomic writes with rollback protection
|
||||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
|
||||||
|
|
||||||
### Codex 说明
|
## Download & Installation
|
||||||
|
|
||||||
- 配置目录:`~/.codex/`
|
### System Requirements
|
||||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
|
||||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
|
||||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
|
||||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
|
||||||
- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。
|
|
||||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
|
||||||
|
|
||||||
### Claude Code 说明
|
- **Windows**: Windows 10 and above
|
||||||
|
- **macOS**: macOS 10.15 (Catalina) and above
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
|
||||||
|
|
||||||
- 配置目录:`~/.claude/`
|
### Windows Users
|
||||||
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
|
||||||
- 供应商副本:`settings-<name>.json`
|
|
||||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
|
||||||
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
|
||||||
- 导入默认:若 `~/.claude/settings.json` 或 `~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本。
|
|
||||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
|
||||||
|
|
||||||
### 迁移与备份
|
Download the latest `CC-Switch-v{version}-Windows.msi` installer or `CC-Switch-v{version}-Windows-Portable.zip` portable version from the [Releases](../../releases) page.
|
||||||
|
|
||||||
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
### macOS Users
|
||||||
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
|
||||||
|
|
||||||
## 开发
|
**Method 1: Install via Homebrew (Recommended)**
|
||||||
|
|
||||||
### 环境要求
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method 2: Manual Download**
|
||||||
|
|
||||||
|
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
|
||||||
|
|
||||||
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||||
|
|
||||||
|
### ArchLinux 用户
|
||||||
|
|
||||||
|
**Install via paru (Recommended)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux Users
|
||||||
|
|
||||||
|
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
1. **Add Provider**: Click "Add Provider" → Choose preset or create custom configuration
|
||||||
|
2. **Switch Provider**:
|
||||||
|
- Main UI: Select provider → Click "Enable"
|
||||||
|
- System Tray: Click provider name directly (instant effect)
|
||||||
|
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
||||||
|
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
||||||
|
|
||||||
|
### MCP Management
|
||||||
|
|
||||||
|
- **Location**: Click "MCP" button in top-right corner
|
||||||
|
- **Add Server**: Use built-in templates (mcp-fetch, mcp-filesystem) or custom config
|
||||||
|
- **Enable/Disable**: Toggle switches to control which servers sync to live config
|
||||||
|
- **Sync**: Enabled servers auto-sync to `~/.claude.json` (Claude) or `~/.codex/config.toml` (Codex)
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
**Claude Code**
|
||||||
|
|
||||||
|
- Live config: `~/.claude/settings.json` (or `claude.json`)
|
||||||
|
- API key field: `env.ANTHROPIC_AUTH_TOKEN` or `env.ANTHROPIC_API_KEY`
|
||||||
|
- MCP servers: `~/.claude.json` → `mcpServers`
|
||||||
|
|
||||||
|
**Codex**
|
||||||
|
|
||||||
|
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
||||||
|
- API key field: `OPENAI_API_KEY` in `auth.json`
|
||||||
|
- MCP servers: `~/.codex/config.toml` → `[mcp_servers]` tables
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
|
||||||
|
- API key field: `GEMINI_API_KEY` inside `.env`
|
||||||
|
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
|
||||||
|
|
||||||
|
**CC Switch Storage**
|
||||||
|
|
||||||
|
- Main config (SSOT): `~/.cc-switch/config.json`
|
||||||
|
- Settings: `~/.cc-switch/settings.json`
|
||||||
|
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
||||||
|
|
||||||
|
### Cloud Sync Setup
|
||||||
|
|
||||||
|
1. Go to Settings → "Custom Configuration Directory"
|
||||||
|
2. Choose your cloud sync folder (Dropbox, OneDrive, iCloud, etc.)
|
||||||
|
3. Restart app to apply
|
||||||
|
4. Repeat on other devices to enable cross-device sync
|
||||||
|
|
||||||
|
> **Note**: First launch auto-imports existing Claude/Codex configs as default provider.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (React + TS) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
|
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ Tauri IPC
|
||||||
|
┌────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ Backend (Tauri + Rust) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
|
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Core Design Patterns**
|
||||||
|
|
||||||
|
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
|
||||||
|
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
|
||||||
|
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
|
||||||
|
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
|
||||||
|
- **Layered Architecture**: Clear separation (Commands → Services → Models)
|
||||||
|
|
||||||
|
**Key Components**
|
||||||
|
|
||||||
|
- **ProviderService**: Provider CRUD, switching, backfill, sorting
|
||||||
|
- **McpService**: MCP server management, import/export, live file sync
|
||||||
|
- **ConfigService**: Config import/export, backup rotation
|
||||||
|
- **SpeedtestService**: API endpoint latency measurement
|
||||||
|
|
||||||
|
**v3.6 Refactoring**
|
||||||
|
|
||||||
|
- Backend: 5-phase refactoring (error handling → command split → tests → services → concurrency)
|
||||||
|
- Frontend: 4-stage refactoring (test infra → hooks → components → cleanup)
|
||||||
|
- Testing: 100% hooks coverage + integration tests (vitest + MSW)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Environment Requirements
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- pnpm 8+
|
- pnpm 8+
|
||||||
- Rust 1.75+
|
- Rust 1.85+
|
||||||
- Tauri CLI 2.0+
|
- Tauri CLI 2.8+
|
||||||
|
|
||||||
### 开发命令
|
### Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
# Install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 开发模式(热重载)
|
# Dev mode (hot reload)
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
# 类型检查
|
# Type check
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
|
||||||
# 代码格式化
|
# Format code
|
||||||
pnpm format
|
pnpm format
|
||||||
|
|
||||||
# 检查代码格式
|
# Check code format
|
||||||
pnpm format:check
|
pnpm format:check
|
||||||
|
|
||||||
# 构建应用
|
# Run frontend unit tests
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# Run tests in watch mode (recommended for development)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# Build application
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# 构建调试版本
|
# Build debug version
|
||||||
pnpm tauri build --debug
|
pnpm tauri build --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rust 后端开发
|
### Rust Backend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
|
|
||||||
# 格式化 Rust 代码
|
# Format Rust code
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
# 运行 clippy 检查
|
# Run clippy checks
|
||||||
cargo clippy
|
cargo clippy
|
||||||
|
|
||||||
# 运行测试
|
# Run backend tests
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
|
# Run specific tests
|
||||||
|
cargo test test_name
|
||||||
|
|
||||||
|
# Run tests with test-hooks feature
|
||||||
|
cargo test --features test-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
### Testing Guide (v3.6 New)
|
||||||
|
|
||||||
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
|
**Frontend Testing**:
|
||||||
- **[React 18](https://react.dev/)** - 用户界面库
|
|
||||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
|
||||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
|
||||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
|
||||||
|
|
||||||
## 项目结构
|
- Uses **vitest** as test framework
|
||||||
|
- Uses **MSW (Mock Service Worker)** to mock Tauri API calls
|
||||||
|
- Uses **@testing-library/react** for component testing
|
||||||
|
|
||||||
```
|
**Test Coverage**:
|
||||||
├── src/ # 前端代码 (React + TypeScript)
|
|
||||||
│ ├── components/ # React 组件
|
- Hooks unit tests (100% coverage)
|
||||||
│ ├── config/ # 预设供应商配置
|
- `useProviderActions` - Provider operations
|
||||||
│ ├── lib/ # Tauri API 封装
|
- `useMcpActions` - MCP management
|
||||||
│ └── utils/ # 工具函数
|
- `useSettings` series - Settings management
|
||||||
├── src-tauri/ # 后端代码 (Rust)
|
- `useImportExport` - Import/export
|
||||||
│ ├── src/ # Rust 源代码
|
- Integration tests
|
||||||
│ │ ├── commands.rs # Tauri 命令定义
|
- App main application flow
|
||||||
│ │ ├── config.rs # 配置文件管理
|
- SettingsDialog complete interaction
|
||||||
│ │ ├── provider.rs # 供应商管理逻辑
|
- MCP panel functionality
|
||||||
│ │ └── store.rs # 状态管理
|
|
||||||
│ ├── capabilities/ # 权限配置
|
**Running Tests**:
|
||||||
│ └── icons/ # 应用图标资源
|
|
||||||
└── screenshots/ # 界面截图
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# Watch mode (auto re-run)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# With coverage report
|
||||||
|
pnpm test:unit --coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
## 更新日志
|
## Tech Stack
|
||||||
|
|
||||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
**Frontend**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
||||||
|
|
||||||
## Electron 旧版
|
**Backend**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
||||||
|
|
||||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
**Testing**: vitest · MSW · @testing-library/react
|
||||||
|
|
||||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
## Project Structure
|
||||||
|
|
||||||
## 贡献
|
```
|
||||||
|
├── src/ # Frontend (React + TypeScript)
|
||||||
|
│ ├── components/ # UI components (providers/settings/mcp/ui)
|
||||||
|
│ ├── hooks/ # Custom hooks (business logic)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/ # Tauri API wrapper (type-safe)
|
||||||
|
│ │ └── query/ # TanStack Query config
|
||||||
|
│ ├── i18n/locales/ # Translations (zh/en)
|
||||||
|
│ ├── config/ # Presets (providers/mcp)
|
||||||
|
│ └── types/ # TypeScript definitions
|
||||||
|
├── src-tauri/ # Backend (Rust)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── commands/ # Tauri command layer (by domain)
|
||||||
|
│ ├── services/ # Business logic layer
|
||||||
|
│ ├── app_config.rs # Config data models
|
||||||
|
│ ├── provider.rs # Provider domain models
|
||||||
|
│ ├── mcp.rs # MCP sync & validation
|
||||||
|
│ └── lib.rs # App entry & tray menu
|
||||||
|
├── tests/ # Frontend tests
|
||||||
|
│ ├── hooks/ # Unit tests
|
||||||
|
│ └── components/ # Integration tests
|
||||||
|
└── assets/ # Screenshots & partner resources
|
||||||
|
```
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for version update details.
|
||||||
|
|
||||||
|
## Legacy Electron Version
|
||||||
|
|
||||||
|
[Releases](../../releases) retains v2.0.3 legacy Electron version
|
||||||
|
|
||||||
|
If you need legacy Electron code, you can pull the electron-legacy branch
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and suggestions are welcome!
|
||||||
|
|
||||||
|
Before submitting PRs, please ensure:
|
||||||
|
|
||||||
|
- Pass type check: `pnpm typecheck`
|
||||||
|
- Pass format check: `pnpm format:check`
|
||||||
|
- Pass unit tests: `pnpm test:unit`
|
||||||
|
- 💡 For new features, please open an issue for discussion before submitting a PR
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
369
README_ZH.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://github.com/trending/typescript)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
|
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
|
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## ❤️赞助商
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
|
||||||
|
|
||||||
|
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
|
||||||
|
|
||||||
|
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||||
|
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
| 主界面 | 添加供应商 |
|
||||||
|
| :---------------------------------------: | :------------------------------------------: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 当前版本:v3.7.0 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
|
**核心功能**
|
||||||
|
|
||||||
|
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||||
|
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
||||||
|
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||||
|
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||||
|
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
||||||
|
- **Claude 插件同步**:一键应用或恢复 Claude 插件配置
|
||||||
|
|
||||||
|
**v3.6 亮点**
|
||||||
|
|
||||||
|
- 供应商复制 & 拖拽排序
|
||||||
|
- 多端点管理 & 自定义配置目录(支持云同步)
|
||||||
|
- 细粒度模型配置(四层:Haiku/Sonnet/Opus/自定义)
|
||||||
|
- WSL 环境支持,配置目录切换自动同步
|
||||||
|
- 100% hooks 测试覆盖 & 完整架构重构
|
||||||
|
- 新增预设:DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
|
||||||
|
|
||||||
|
**系统功能**
|
||||||
|
|
||||||
|
- 系统托盘快速切换
|
||||||
|
- 单实例守护
|
||||||
|
- 内置自动更新器
|
||||||
|
- 原子写入与回滚保护
|
||||||
|
|
||||||
|
## 下载安装
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- **Windows**: Windows 10 及以上
|
||||||
|
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||||
|
|
||||||
|
### Windows 用户
|
||||||
|
|
||||||
|
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
|
||||||
|
|
||||||
|
### macOS 用户
|
||||||
|
|
||||||
|
**方式一:通过 Homebrew 安装(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:手动下载**
|
||||||
|
|
||||||
|
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
|
### ArchLinux 用户
|
||||||
|
|
||||||
|
**通过 paru 安装(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux 用户
|
||||||
|
|
||||||
|
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 基本使用
|
||||||
|
|
||||||
|
1. **添加供应商**:点击"添加供应商" → 选择预设或创建自定义配置
|
||||||
|
2. **切换供应商**:
|
||||||
|
- 主界面:选择供应商 → 点击"启用"
|
||||||
|
- 系统托盘:直接点击供应商名称(立即生效)
|
||||||
|
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
||||||
|
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
||||||
|
|
||||||
|
### MCP 管理
|
||||||
|
|
||||||
|
- **位置**:点击右上角"MCP"按钮
|
||||||
|
- **添加服务器**:使用内置模板(mcp-fetch、mcp-filesystem)或自定义配置
|
||||||
|
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
|
||||||
|
- **同步**:启用的服务器自动同步到 `~/.claude.json`(Claude)或 `~/.codex/config.toml`(Codex)
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
**Claude Code**
|
||||||
|
|
||||||
|
- Live 配置:`~/.claude/settings.json`(或 `claude.json`)
|
||||||
|
- API key 字段:`env.ANTHROPIC_AUTH_TOKEN` 或 `env.ANTHROPIC_API_KEY`
|
||||||
|
- MCP 服务器:`~/.claude.json` → `mcpServers`
|
||||||
|
|
||||||
|
**Codex**
|
||||||
|
|
||||||
|
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
||||||
|
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||||
|
- MCP 服务器:`~/.codex/config.toml` → `[mcp_servers]` 表
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换)
|
||||||
|
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
|
||||||
|
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,Gemini CLI 无需额外操作即可使用新配置
|
||||||
|
|
||||||
|
**CC Switch 存储**
|
||||||
|
|
||||||
|
- 主配置(SSOT):`~/.cc-switch/config.json`
|
||||||
|
- 设置:`~/.cc-switch/settings.json`
|
||||||
|
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
||||||
|
|
||||||
|
### 云同步设置
|
||||||
|
|
||||||
|
1. 前往设置 → "自定义配置目录"
|
||||||
|
2. 选择您的云同步文件夹(Dropbox、OneDrive、iCloud、坚果云等)
|
||||||
|
3. 重启应用以应用
|
||||||
|
4. 在其他设备上重复操作以启用跨设备同步
|
||||||
|
|
||||||
|
> **注意**:首次启动会自动导入现有 Claude/Codex 配置作为默认供应商。
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
|
||||||
|
### 设计原则
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 前端 (React + TS) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
|
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ Tauri IPC
|
||||||
|
┌────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ 后端 (Tauri + Rust) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
|
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心设计模式**
|
||||||
|
|
||||||
|
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
|
||||||
|
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
||||||
|
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
||||||
|
- **并发安全**:RwLock 与作用域守卫避免死锁
|
||||||
|
- **分层架构**:清晰分离(Commands → Services → Models)
|
||||||
|
|
||||||
|
**核心组件**
|
||||||
|
|
||||||
|
- **ProviderService**:供应商增删改查、切换、回填、排序
|
||||||
|
- **McpService**:MCP 服务器管理、导入导出、live 文件同步
|
||||||
|
- **ConfigService**:配置导入导出、备份轮换
|
||||||
|
- **SpeedtestService**:API 端点延迟测量
|
||||||
|
|
||||||
|
**v3.6 重构**
|
||||||
|
|
||||||
|
- 后端:5 阶段重构(错误处理 → 命令拆分 → 测试 → 服务 → 并发)
|
||||||
|
- 前端:4 阶段重构(测试基础 → hooks → 组件 → 清理)
|
||||||
|
- 测试:100% hooks 覆盖 + 集成测试(vitest + MSW)
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm 8+
|
||||||
|
- Rust 1.85+
|
||||||
|
- Tauri CLI 2.8+
|
||||||
|
|
||||||
|
### 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 开发模式(热重载)
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 类型检查
|
||||||
|
pnpm typecheck
|
||||||
|
|
||||||
|
# 代码格式化
|
||||||
|
pnpm format
|
||||||
|
|
||||||
|
# 检查代码格式
|
||||||
|
pnpm format:check
|
||||||
|
|
||||||
|
# 运行前端单元测试
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# 监听模式运行测试(推荐开发时使用)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 构建调试版本
|
||||||
|
pnpm tauri build --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust 后端开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri
|
||||||
|
|
||||||
|
# 格式化 Rust 代码
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# 运行 clippy 检查
|
||||||
|
cargo clippy
|
||||||
|
|
||||||
|
# 运行后端测试
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# 运行特定测试
|
||||||
|
cargo test test_name
|
||||||
|
|
||||||
|
# 运行带测试 hooks 的测试
|
||||||
|
cargo test --features test-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试说明(v3.6 新增)
|
||||||
|
|
||||||
|
**前端测试**:
|
||||||
|
|
||||||
|
- 使用 **vitest** 作为测试框架
|
||||||
|
- 使用 **MSW (Mock Service Worker)** 模拟 Tauri API 调用
|
||||||
|
- 使用 **@testing-library/react** 进行组件测试
|
||||||
|
|
||||||
|
**测试覆盖**:
|
||||||
|
|
||||||
|
- Hooks 单元测试(100% 覆盖)
|
||||||
|
- `useProviderActions` - 供应商操作
|
||||||
|
- `useMcpActions` - MCP 管理
|
||||||
|
- `useSettings` 系列 - 设置管理
|
||||||
|
- `useImportExport` - 导入导出
|
||||||
|
- 集成测试
|
||||||
|
- App 主应用流程
|
||||||
|
- SettingsDialog 完整交互
|
||||||
|
- MCP 面板功能
|
||||||
|
|
||||||
|
**运行测试**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# 监听模式(自动重跑)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# 带覆盖率报告
|
||||||
|
pnpm test:unit --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
**前端**:React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
||||||
|
|
||||||
|
**后端**:Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
||||||
|
|
||||||
|
**测试**:vitest · MSW · @testing-library/react
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── src/ # 前端 (React + TypeScript)
|
||||||
|
│ ├── components/ # UI 组件 (providers/settings/mcp/ui)
|
||||||
|
│ ├── hooks/ # 自定义 hooks (业务逻辑)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/ # Tauri API 封装(类型安全)
|
||||||
|
│ │ └── query/ # TanStack Query 配置
|
||||||
|
│ ├── i18n/locales/ # 翻译 (zh/en)
|
||||||
|
│ ├── config/ # 预设 (providers/mcp)
|
||||||
|
│ └── types/ # TypeScript 类型定义
|
||||||
|
├── src-tauri/ # 后端 (Rust)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── commands/ # Tauri 命令层(按领域)
|
||||||
|
│ ├── services/ # 业务逻辑层
|
||||||
|
│ ├── app_config.rs # 配置数据模型
|
||||||
|
│ ├── provider.rs # 供应商领域模型
|
||||||
|
│ ├── mcp.rs # MCP 同步与校验
|
||||||
|
│ └── lib.rs # 应用入口 & 托盘菜单
|
||||||
|
├── tests/ # 前端测试
|
||||||
|
│ ├── hooks/ # 单元测试
|
||||||
|
│ └── components/ # 集成测试
|
||||||
|
└── assets/ # 截图 & 合作商资源
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||||
|
|
||||||
|
## Electron 旧版
|
||||||
|
|
||||||
|
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||||
|
|
||||||
|
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 反馈问题和建议!
|
||||||
|
|
||||||
|
提交 PR 前请确保:
|
||||||
|
|
||||||
|
- 通过类型检查:`pnpm typecheck`
|
||||||
|
- 通过格式检查:`pnpm format:check`
|
||||||
|
- 通过单元测试:`pnpm test:unit`
|
||||||
|
- 💡 新功能开发前,欢迎先开 issue 讨论实现方案
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © Jason Young
|
||||||
76
README_i18n.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# CC Switch 国际化功能说明
|
||||||
|
|
||||||
|
## 已完成的工作
|
||||||
|
|
||||||
|
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
||||||
|
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
||||||
|
3. **翻译文件**:创建了英文和中文翻译文件
|
||||||
|
4. **组件更新**:替换了主要组件中的硬编码文案
|
||||||
|
5. **语言切换器**:添加了语言切换按钮
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── i18n/
|
||||||
|
│ ├── index.ts # 国际化配置文件
|
||||||
|
│ └── locales/
|
||||||
|
│ ├── en.json # 英文翻译
|
||||||
|
│ └── zh.json # 中文翻译
|
||||||
|
├── components/
|
||||||
|
│ └── LanguageSwitcher.tsx # 语言切换组件
|
||||||
|
└── main.tsx # 导入国际化配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 默认语言设置
|
||||||
|
|
||||||
|
- **默认语言**:英文 (en)
|
||||||
|
- **回退语言**:英文 (en)
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 在组件中导入 `useTranslation`:
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <div>{t('common.save')}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 切换语言:
|
||||||
|
```tsx
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
i18n.changeLanguage('zh'); // 切换到中文
|
||||||
|
```
|
||||||
|
|
||||||
|
## 翻译键结构
|
||||||
|
|
||||||
|
- `common.*` - 通用文案(保存、取消、设置等)
|
||||||
|
- `header.*` - 头部相关文案
|
||||||
|
- `provider.*` - 供应商相关文案
|
||||||
|
- `notifications.*` - 通知消息
|
||||||
|
- `settings.*` - 设置页面文案
|
||||||
|
- `apps.*` - 应用名称
|
||||||
|
- `console.*` - 控制台日志信息
|
||||||
|
|
||||||
|
## 测试功能
|
||||||
|
|
||||||
|
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
|
||||||
|
|
||||||
|
## 已更新的组件
|
||||||
|
|
||||||
|
- ✅ App.tsx - 主应用组件
|
||||||
|
- ✅ ConfirmDialog.tsx - 确认对话框
|
||||||
|
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
||||||
|
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||||
|
- ✅ ProviderList.tsx - 供应商列表
|
||||||
|
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||||
|
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
||||||
|
2. 翻译键名应该有意义且结构化
|
||||||
|
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
||||||
BIN
assets/partners/banners/glm-en.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/partners/banners/glm-zh.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
assets/partners/logos/packycode.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/screenshots/add-en.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
assets/screenshots/add-zh.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
assets/screenshots/main-en.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/screenshots/main-zh.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
551
deplink.html
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CC Switch 深链接测试</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card .description {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link:hover {
|
||||||
|
background: linear-gradient(135deg, #2980b9 0%, #1f6391 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-link:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h4 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box ul {
|
||||||
|
list-style: disc;
|
||||||
|
margin-left: 20px;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section {
|
||||||
|
background: #e8f4f8;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: linear-gradient(135deg, #229954 0%, #1e8449 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box strong {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #2c3e50;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: linear-gradient(135deg, #8e44ad 0%, #7d3c98 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-claude {
|
||||||
|
background: #e8f4f8;
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-codex {
|
||||||
|
background: #fef5e7;
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gemini {
|
||||||
|
background: #fdeef4;
|
||||||
|
color: #e91e63;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔗 CC Switch 深链接测试</h1>
|
||||||
|
<p>点击下方链接测试深链接导入功能</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Claude 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Claude Code 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-claude">Claude</span>
|
||||||
|
Claude Official (官方)
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com,默认模型 claude-haiku-4.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=claude&name=Claude%20Official&homepage=https%3A%2F%2Fclaude.ai&endpoint=https%3A%2F%2Fapi.anthropic.com%2Fv1&apiKey=sk-ant-test-demo-key-12345&model=claude-haiku-4.1¬es=%E5%AE%98%E6%96%B9%E6%B5%8B%E8%AF%95%E9%85%8D%E7%BD%AE"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Claude Official
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-claude">Claude</span>
|
||||||
|
Claude 测试环境
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-haiku-4.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=claude&name=%E5%85%AC%E5%8F%B8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Ftest.company.com&endpoint=https%3A%2F%2Fapi-test.company.com%2Fv1&apiKey=sk-ant-test-company-key&model=claude-haiku-4.1¬es=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入测试环境
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Codex 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Codex 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-codex">Codex</span>
|
||||||
|
OpenAI Official (官方)
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com,默认模型 gpt-5.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=codex&name=OpenAI%20Official&homepage=https%3A%2F%2Fopenai.com&endpoint=https%3A%2F%2Fapi.openai.com%2Fv1&apiKey=sk-test-demo-openai-key-67890&model=gpt-5.1¬es=OpenAI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 OpenAI Official
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-codex">Codex</span>
|
||||||
|
Azure OpenAI
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-5.1。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=codex&name=Azure%20OpenAI&homepage=https%3A%2F%2Fazure.microsoft.com%2Fopenai&endpoint=https%3A%2F%2Fyour-resource.openai.azure.com%2F&apiKey=azure-test-api-key-xyz&model=gpt-5.1¬es=Azure%20%E4%BC%81%E4%B8%9A%E7%89%88%E6%9C%AC"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Azure OpenAI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini 示例 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Gemini 供应商</h2>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-gemini">Gemini</span>
|
||||||
|
Google Gemini Official
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
导入 Google Gemini 官方 API 配置。默认模型 gemini-3-pro-preview。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=Google%20Gemini&homepage=https%3A%2F%2Fai.google.dev&endpoint=https%3A%2F%2Fgenerativelanguage.googleapis.com%2Fv1beta&apiKey=AIzaSy-test-demo-key-abc123&model=gemini-3-pro-preview¬es=Google%20AI%20%E5%AE%98%E6%96%B9%E6%9C%8D%E5%8A%A1"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Google Gemini
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-card">
|
||||||
|
<h3>
|
||||||
|
<span class="app-badge badge-gemini">Gemini</span>
|
||||||
|
Gemini 测试环境
|
||||||
|
</h3>
|
||||||
|
<p class="description">
|
||||||
|
公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程,请求地址为:https://api-gemini-test.company.com/v1beta。默认模型 gemini-3-pro-preview。
|
||||||
|
</p>
|
||||||
|
<a href="ccswitch://v1/import?resource=provider&app=gemini&name=%E5%85%AC%E5%8F%B8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83&homepage=https%3A%2F%2Fgemini-test.company.com&endpoint=https%3A%2F%2Fapi-gemini-test.company.com%2Fv1beta&apiKey=sk-gemini-test-company-key&model=gemini-3-pro-preview¬es=%E5%85%AC%E5%8F%B8%E5%86%85%E9%83%A8%20Gemini%20%E6%B5%8B%E8%AF%95%E7%8E%AF%E5%A2%83%EF%BC%8C%E4%BB%85%E4%BE%9B%E5%BC%80%E5%8F%91%E4%BD%BF%E7%94%A8"
|
||||||
|
class="deep-link">
|
||||||
|
📥 导入 Gemini 测试环境
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注意事项 -->
|
||||||
|
<div class="info-box">
|
||||||
|
<h4>⚠️ 使用注意事项</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>首次点击</strong>:浏览器会询问是否允许打开 CC Switch,请点击"允许"或"打开"</li>
|
||||||
|
<li><strong>macOS 用户</strong>:可能需要在"系统设置" → "隐私与安全性"中允许应用</li>
|
||||||
|
<li><strong>测试 API Key</strong>:示例中的 API Key 仅用于测试格式,无法实际使用</li>
|
||||||
|
<li><strong>导入确认</strong>:点击链接后会弹出确认对话框,API Key 会被掩码显示(前4位+****)</li>
|
||||||
|
<li><strong>编辑配置</strong>:导入后可以在 CC Switch 中随时编辑或删除配置</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 深链接生成器 -->
|
||||||
|
<div class="generator-section">
|
||||||
|
<h2>🛠️ 深链接生成器</h2>
|
||||||
|
<p style="color: #7f8c8d; margin-bottom: 24px;">填写下方表单,生成您自己的深链接</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>应用类型 *</label>
|
||||||
|
<select id="app">
|
||||||
|
<option value="claude">Claude Code</option>
|
||||||
|
<option value="codex">Codex</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>供应商名称 *</label>
|
||||||
|
<input type="text" id="name" placeholder="例如: Claude Official">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>官网地址 *</label>
|
||||||
|
<input type="url" id="homepage" placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API 端点 *</label>
|
||||||
|
<input type="url" id="endpoint" placeholder="https://api.example.com/v1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key *</label>
|
||||||
|
<input type="text" id="apiKey" placeholder="sk-xxxxx 或 AIzaSyXXXXX">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型(可选)</label>
|
||||||
|
<input type="text" id="model" placeholder="例如: claude-haiku-4.1, gpt-5.1, gemini-3-pro-preview">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>备注(可选)</label>
|
||||||
|
<textarea id="notes" rows="2" placeholder="例如: 公司专用账号"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="generateLink()">🚀 生成深链接</button>
|
||||||
|
|
||||||
|
<div id="result" style="display: none;">
|
||||||
|
<div class="result-box">
|
||||||
|
<strong>✅ 生成的深链接:</strong>
|
||||||
|
<div class="result-text" id="linkText"></div>
|
||||||
|
<button class="btn btn-copy" onclick="copyLink()">📋 复制链接</button>
|
||||||
|
<a id="testLink" class="deep-link" style="text-decoration: none;">
|
||||||
|
🧪 测试链接
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function generateLink() {
|
||||||
|
const app = document.getElementById('app').value;
|
||||||
|
const name = document.getElementById('name').value.trim();
|
||||||
|
const homepage = document.getElementById('homepage').value.trim();
|
||||||
|
const endpoint = document.getElementById('endpoint').value.trim();
|
||||||
|
const apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
const model = document.getElementById('model').value.trim();
|
||||||
|
const notes = document.getElementById('notes').value.trim();
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!name || !homepage || !endpoint || !apiKey) {
|
||||||
|
alert('❌ 请填写所有必填字段(标记 * 的字段)!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
try {
|
||||||
|
new URL(homepage);
|
||||||
|
new URL(endpoint);
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ 请输入有效的 URL 格式(需包含 http:// 或 https://)!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建参数
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
resource: 'provider',
|
||||||
|
app: app,
|
||||||
|
name: name,
|
||||||
|
homepage: homepage,
|
||||||
|
endpoint: endpoint,
|
||||||
|
apiKey: apiKey
|
||||||
|
});
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
params.append('model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes) {
|
||||||
|
params.append('notes', notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLink = `ccswitch://v1/import?${params.toString()}`;
|
||||||
|
|
||||||
|
// 显示结果
|
||||||
|
document.getElementById('linkText').textContent = deepLink;
|
||||||
|
document.getElementById('testLink').href = deepLink;
|
||||||
|
document.getElementById('result').style.display = 'block';
|
||||||
|
|
||||||
|
// 滚动到结果区域
|
||||||
|
document.getElementById('result').scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
const linkText = document.getElementById('linkText').textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(linkText).then(() => {
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '✅ 已复制!';
|
||||||
|
btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
alert('❌ 复制失败,请手动复制链接');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阻止表单默认提交行为
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const inputs = document.querySelectorAll('input, textarea, select');
|
||||||
|
inputs.forEach(input => {
|
||||||
|
input.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
|
||||||
|
e.preventDefault();
|
||||||
|
generateLink();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
169
docs/BACKEND_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# CC Switch Rust 后端重构方案
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- [背景与现状](#背景与现状)
|
||||||
|
- [问题确认](#问题确认)
|
||||||
|
- [方案评估](#方案评估)
|
||||||
|
- [渐进式重构路线](#渐进式重构路线)
|
||||||
|
- [测试策略](#测试策略)
|
||||||
|
- [风险与对策](#风险与对策)
|
||||||
|
- [总结](#总结)
|
||||||
|
|
||||||
|
## 背景与现状
|
||||||
|
- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。
|
||||||
|
- 核心文件集中在 `src-tauri/src/commands.rs`、`lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。
|
||||||
|
- 测试覆盖率低,只有零散单元测试,缺乏集成验证。
|
||||||
|
|
||||||
|
## 问题确认
|
||||||
|
|
||||||
|
| 提案问题 | 实际情况 | 严重程度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 |
|
||||||
|
| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 |
|
||||||
|
| `Result<T, String>` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 |
|
||||||
|
| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 |
|
||||||
|
| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 |
|
||||||
|
|
||||||
|
代码规模分布(约 5.4k SLOC):
|
||||||
|
- `commands.rs`: 1526 行(28%)→ 第一优先级 🎯
|
||||||
|
- `lib.rs`: 541 行(10%)→ 托盘逻辑与业务耦合
|
||||||
|
- `mcp.rs`: 732 行(14%)→ 相对清晰
|
||||||
|
- `migration.rs`: 431 行(8%)→ 一次性逻辑
|
||||||
|
- 其他文件合计:2156 行(40%)
|
||||||
|
|
||||||
|
## 方案评估
|
||||||
|
|
||||||
|
### ✅ 优点
|
||||||
|
1. **分层架构清晰**
|
||||||
|
- `commands/`:Tauri 命令薄层
|
||||||
|
- `services/`:业务流程,如供应商切换、MCP 同步
|
||||||
|
- `infrastructure/`:配置读写、外设交互
|
||||||
|
- `domain/`:数据模型 (`Provider`, `AppType` 等)
|
||||||
|
→ 提升可测试性、降低耦合度、方便团队协作。
|
||||||
|
|
||||||
|
2. **统一错误处理**
|
||||||
|
- 引入 `AppError`(`thiserror`),保留错误链和上下文。
|
||||||
|
- Tauri 命令仍返回 `Result<T, String>`,通过 `From<AppError>` 自动转换。
|
||||||
|
- 改善日志可读性,利于排查。
|
||||||
|
|
||||||
|
3. **并发优化**
|
||||||
|
- `AppState` 切换为 `RwLock<MultiAppConfig>`。
|
||||||
|
- 读多写少的场景提升吞吐(如频繁查询供应商列表)。
|
||||||
|
|
||||||
|
### ⚠️ 风险
|
||||||
|
1. **过度设计**
|
||||||
|
- 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。
|
||||||
|
- Rust trait + repository 样板较多,收益不足。
|
||||||
|
- 推荐“轻量分层”而非正统 DDD。
|
||||||
|
|
||||||
|
2. **迁移成本高**
|
||||||
|
- `commands.rs` 拆分、错误统一、锁改造触及多文件。
|
||||||
|
- 测试缺失导致重构风险高,需先补测试。
|
||||||
|
- 估算完整改造需 5-6 周;建议分阶段输出可落地价值。
|
||||||
|
|
||||||
|
3. **技术选型需谨慎**
|
||||||
|
- `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。
|
||||||
|
- `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。
|
||||||
|
- 以现有依赖为主,控制复杂度。
|
||||||
|
|
||||||
|
## 实施进度
|
||||||
|
- **阶段 1:统一错误处理 ✅**
|
||||||
|
- 引入 `thiserror` 并在 `src-tauri/src/error.rs` 定义 `AppError`,提供常用构造函数和 `From<AppError> for String`,保留错误链路。
|
||||||
|
- 配置、存储、同步等核心模块(`config.rs`、`app_config.rs`、`app_store.rs`、`store.rs`、`codex_config.rs`、`claude_mcp.rs`、`claude_plugin.rs`、`import_export.rs`、`mcp.rs`、`migration.rs`、`speedtest.rs`、`usage_script.rs`、`settings.rs`、`lib.rs` 等)已统一返回 `Result<_, AppError>`,避免字符串错误丢失上下文。
|
||||||
|
- Tauri 命令层继续返回 `Result<_, String>`,通过 `?` + `Into<String>` 统一转换,前端无需调整。
|
||||||
|
- `cargo check` 通过,`rg "Result<[^>]+, String"` 巡检确认除命令层外已无字符串错误返回。
|
||||||
|
- **阶段 2:拆分命令层 ✅**
|
||||||
|
- 已将单一 `src-tauri/src/commands.rs` 拆分为 `commands/{provider,mcp,config,settings,misc,plugin}.rs` 并通过 `commands/mod.rs` 统一导出,保持对外 API 不变。
|
||||||
|
- 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。
|
||||||
|
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
|
||||||
|
- **阶段 3:补充测试 ✅**
|
||||||
|
- `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。
|
||||||
|
- 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。
|
||||||
|
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
|
||||||
|
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
|
||||||
|
- 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。
|
||||||
|
- 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。
|
||||||
|
- 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。
|
||||||
|
- 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试,校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。
|
||||||
|
- 新增 `tests/mcp_commands.rs`,通过测试钩子覆盖 `import_default_config`、`import_mcp_from_claude`、`set_mcp_enabled` 等命令层行为,验证缺失文件/非法 JSON 的错误回滚以及成功路径落盘效果;阶段三目标达成,命令层关键边界已具备回归保障。
|
||||||
|
- **阶段 4:服务层抽象 🚧(进行中)**
|
||||||
|
- 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`,集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs`、`tests/provider_commands.rs` 中完成成功与失败路径的集成验证。
|
||||||
|
- 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。
|
||||||
|
- `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。
|
||||||
|
- 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。
|
||||||
|
- 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。
|
||||||
|
- 后续可选:应用设置(Store)命令仍较薄,可按需评估是否抽象;当前阶段四核心服务已基本齐备。
|
||||||
|
- **阶段 5:锁与阻塞优化 ✅(首轮)**
|
||||||
|
- `AppState` 已由 `Mutex<MultiAppConfig>` 切换为 `RwLock<MultiAppConfig>`,托盘、命令与测试均按读写语义区分 `read()` / `write()`;`cargo test` 全量通过验证并未破坏现有流程。
|
||||||
|
- 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。
|
||||||
|
- 其余命令梳理后确认仍属轻量同步操作,暂不额外引入 `spawn_blocking`;若后续出现新的长耗时流程,再按同一模式扩展。
|
||||||
|
|
||||||
|
## 渐进式重构路线
|
||||||
|
|
||||||
|
### 阶段 1:统一错误处理(高收益 / 低风险)
|
||||||
|
- 新增 `src-tauri/src/error.rs`,定义 `AppError`。
|
||||||
|
- 底层文件 IO、配置解析等函数返回 `Result<T, AppError>`。
|
||||||
|
- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`。
|
||||||
|
- 预估 3-5 天,立即启动。
|
||||||
|
|
||||||
|
### 阶段 2:拆分 `commands.rs`(高收益 / 中风险)
|
||||||
|
- 按业务拆分为 `commands/provider.rs`、`commands/mcp.rs`、`commands/config.rs`、`commands/settings.rs`、`commands/misc.rs`。
|
||||||
|
- `commands/mod.rs` 统一导出和注册。
|
||||||
|
- 文件行数降低到 200-300 行/文件,职责单一。
|
||||||
|
- 预估 5-7 天,可并行进行部分重构。
|
||||||
|
|
||||||
|
### 阶段 3:补充测试(中收益 / 中风险)
|
||||||
|
- 引入 `tests/` 或 `src-tauri/tests/` 集成测试,覆盖供应商切换、MCP 同步、配置迁移。
|
||||||
|
- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。
|
||||||
|
- 预估 5-7 天,为后续重构提供安全网。
|
||||||
|
|
||||||
|
### 阶段 4:提取轻量服务层(中收益 / 中风险)
|
||||||
|
- 新增 `services/provider_service.rs`、`services/mcp_service.rs`。
|
||||||
|
- 不强制使用 trait;直接以自由函数/结构体实现业务流程。
|
||||||
|
```rust
|
||||||
|
pub struct ProviderService;
|
||||||
|
impl ProviderService {
|
||||||
|
pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
// 业务流程:验证、回填、落盘、更新 current、触发事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。
|
||||||
|
- 预估 7-10 天,可在测试补齐后执行。
|
||||||
|
|
||||||
|
### 阶段 5:锁与阻塞优化(低收益 / 低风险)
|
||||||
|
- ✅ `AppState` 已从 `Mutex` 切换为 `RwLock`,命令与托盘读写按需区分,现有测试全部通过。
|
||||||
|
- ✅ 配置导入/导出命令通过 `spawn_blocking` 处理高开销文件 IO;其他命令维持同步执行以避免不必要调度。
|
||||||
|
- 🔄 持续监控:若后续引入新的批量迁移或耗时任务,再按相同模式扩展到阻塞线程;观察运行时锁竞争情况,必要时考虑进一步拆分状态或引入缓存。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
- **优先覆盖场景**
|
||||||
|
- 供应商切换:状态更新 + live 配置同步
|
||||||
|
- MCP 同步:enabled 服务器快照与落盘
|
||||||
|
- 配置迁移:归档、备份与版本升级
|
||||||
|
- **推荐结构**
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_live_config() { /* ... */ }
|
||||||
|
#[test]
|
||||||
|
fn sync_mcp_to_codex_updates_claude_config() { /* ... */ }
|
||||||
|
#[test]
|
||||||
|
fn migration_preserves_backup() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。
|
||||||
|
|
||||||
|
## 风险与对策
|
||||||
|
- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。
|
||||||
|
- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。
|
||||||
|
- **回滚困难** → 每阶段结束打 tag(如 `v3.6.0-backend-step1`),保留回滚点。
|
||||||
|
- **功能回归** → 重构后执行手动冒烟流程:供应商切换、托盘操作、MCP 同步、配置导入导出。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。
|
||||||
|
- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。
|
||||||
|
- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。
|
||||||
|
- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。
|
||||||
1309
docs/CODEX_MCP_RAW_TOML_PLAN.md
Normal file
490
docs/REFACTORING_CHECKLIST.md
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
# CC Switch 重构实施清单
|
||||||
|
|
||||||
|
> 用于跟踪重构进度的详细检查清单
|
||||||
|
|
||||||
|
**开始日期**: ___________
|
||||||
|
**预计完成**: ___________
|
||||||
|
**当前阶段**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 0: 准备阶段 (预计 1 天)
|
||||||
|
|
||||||
|
### 环境准备
|
||||||
|
|
||||||
|
- [ ] 创建新分支 `refactor/modernization`
|
||||||
|
- [ ] 创建备份标签 `git tag backup-before-refactor`
|
||||||
|
- [ ] 备份用户配置文件 `~/.cc-switch/config.json`
|
||||||
|
- [ ] 通知团队成员重构开始
|
||||||
|
|
||||||
|
### 依赖安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @tanstack/react-query
|
||||||
|
pnpm add react-hook-form @hookform/resolvers
|
||||||
|
pnpm add zod
|
||||||
|
pnpm add sonner
|
||||||
|
pnpm add next-themes
|
||||||
|
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
|
||||||
|
pnpm add @radix-ui/react-label @radix-ui/react-select
|
||||||
|
pnpm add @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs
|
||||||
|
pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 安装核心依赖 (上述命令)
|
||||||
|
- [ ] 验证依赖安装成功 `pnpm install`
|
||||||
|
- [ ] 验证编译通过 `pnpm typecheck`
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
- [ ] 创建 `components.json`
|
||||||
|
- [ ] 更新 `tsconfig.json` 添加路径别名
|
||||||
|
- [ ] 更新 `vite.config.mts` 添加路径解析
|
||||||
|
- [ ] 验证开发服务器启动 `pnpm dev`
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 1: 基础设施 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 1.1 工具函数和基础组件
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/utils.ts` (cn 函数)
|
||||||
|
- [ ] 创建 `src/components/ui/button.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/dialog.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/input.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/label.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/textarea.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/select.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/switch.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/tabs.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/sonner.tsx`
|
||||||
|
- [ ] 创建 `src/components/ui/form.tsx`
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证所有 UI 组件可以正常导入
|
||||||
|
- [ ] 创建一个测试页面验证组件样式
|
||||||
|
|
||||||
|
### 1.2 Query Client 设置
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/queryClient.ts`
|
||||||
|
- [ ] 配置默认选项 (retry, staleTime 等)
|
||||||
|
- [ ] 导出 queryClient 实例
|
||||||
|
|
||||||
|
### 1.3 API 层
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/providers.ts`
|
||||||
|
- [ ] getAll
|
||||||
|
- [ ] getCurrent
|
||||||
|
- [ ] add
|
||||||
|
- [ ] update
|
||||||
|
- [ ] delete
|
||||||
|
- [ ] switch
|
||||||
|
- [ ] importDefault
|
||||||
|
- [ ] updateTrayMenu
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/settings.ts`
|
||||||
|
- [ ] get
|
||||||
|
- [ ] save
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/mcp.ts`
|
||||||
|
- [ ] getConfig
|
||||||
|
- [ ] upsertServer
|
||||||
|
- [ ] deleteServer
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/api/index.ts` (聚合导出)
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证 API 调用不会出现运行时错误
|
||||||
|
- [ ] 确认类型定义正确
|
||||||
|
|
||||||
|
### 1.4 Query Hooks
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/queries.ts`
|
||||||
|
- [ ] useProvidersQuery
|
||||||
|
- [ ] useSettingsQuery
|
||||||
|
- [ ] useMcpConfigQuery
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/mutations.ts`
|
||||||
|
- [ ] useAddProviderMutation
|
||||||
|
- [ ] useSwitchProviderMutation
|
||||||
|
- [ ] useDeleteProviderMutation
|
||||||
|
- [ ] useUpdateProviderMutation
|
||||||
|
- [ ] useSaveSettingsMutation
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/query/index.ts` (聚合导出)
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 在临时组件中测试每个 hook
|
||||||
|
- [ ] 验证 loading/error 状态正确
|
||||||
|
- [ ] 验证缓存和自动刷新工作
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 2: 核心功能重构 (预计 3-4 天)
|
||||||
|
|
||||||
|
### 2.1 主题系统
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/theme-provider.tsx`
|
||||||
|
- [ ] 创建 `src/components/mode-toggle.tsx`
|
||||||
|
- [ ] 更新 `src/index.css` 添加主题变量
|
||||||
|
- [ ] 删除 `src/hooks/useDarkMode.ts`
|
||||||
|
- [ ] 更新所有组件使用新的主题系统
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证主题切换正常工作
|
||||||
|
- [ ] 验证系统主题跟随功能
|
||||||
|
- [ ] 验证主题持久化
|
||||||
|
|
||||||
|
### 2.2 更新 main.tsx
|
||||||
|
|
||||||
|
- [ ] 引入 QueryClientProvider
|
||||||
|
- [ ] 引入 ThemeProvider
|
||||||
|
- [ ] 添加 Toaster 组件
|
||||||
|
- [ ] 移除旧的 API 导入
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证应用可以正常启动
|
||||||
|
- [ ] 验证 Context 正确传递
|
||||||
|
|
||||||
|
### 2.3 重构 App.tsx
|
||||||
|
|
||||||
|
- [ ] 使用 useProvidersQuery 替代手动状态管理
|
||||||
|
- [ ] 移除所有 loadProviders 相关代码
|
||||||
|
- [ ] 移除手动 notification 状态
|
||||||
|
- [ ] 简化事件监听逻辑
|
||||||
|
- [ ] 更新对话框为新的 Dialog 组件
|
||||||
|
|
||||||
|
**目标**: 将 412 行代码减少到 ~100 行
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证供应商列表正常加载
|
||||||
|
- [ ] 验证切换 Claude/Codex 正常工作
|
||||||
|
- [ ] 验证事件监听正常工作
|
||||||
|
|
||||||
|
### 2.4 重构 ProviderList
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/ProviderList.tsx`
|
||||||
|
- [ ] 使用 mutation hooks 处理操作
|
||||||
|
- [ ] 移除 onNotify prop
|
||||||
|
- [ ] 移除手动状态管理
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证供应商列表渲染
|
||||||
|
- [ ] 验证切换操作
|
||||||
|
- [ ] 验证删除操作
|
||||||
|
|
||||||
|
### 2.5 重构表单系统
|
||||||
|
|
||||||
|
- [ ] 创建 `src/lib/schemas/provider.ts` (Zod schema)
|
||||||
|
- [ ] 创建 `src/components/providers/ProviderForm.tsx`
|
||||||
|
- [ ] 使用 react-hook-form
|
||||||
|
- [ ] 使用 zodResolver
|
||||||
|
- [ ] 字段级验证
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/AddProviderDialog.tsx`
|
||||||
|
- [ ] 使用新的 Dialog 组件
|
||||||
|
- [ ] 集成 ProviderForm
|
||||||
|
- [ ] 使用 useAddProviderMutation
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/providers/EditProviderDialog.tsx`
|
||||||
|
- [ ] 使用新的 Dialog 组件
|
||||||
|
- [ ] 集成 ProviderForm
|
||||||
|
- [ ] 使用 useUpdateProviderMutation
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证表单验证正常工作
|
||||||
|
- [ ] 验证错误提示显示正确
|
||||||
|
- [ ] 验证提交操作成功
|
||||||
|
- [ ] 验证表单重置功能
|
||||||
|
|
||||||
|
### 2.6 清理旧组件
|
||||||
|
|
||||||
|
- [x] 删除 `src/components/AddProviderModal.tsx`
|
||||||
|
- [x] 删除 `src/components/EditProviderModal.tsx`
|
||||||
|
- [x] 更新所有引用这些组件的地方
|
||||||
|
- [x] 删除 `src/components/ProviderForm.tsx` 及 `src/components/ProviderForm/`
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 3: 设置和辅助功能 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 3.1 重构 SettingsDialog
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/SettingsDialog.tsx`
|
||||||
|
- [ ] 使用 Tabs 组件
|
||||||
|
- [ ] 集成各个设置子组件
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/GeneralSettings.tsx`
|
||||||
|
- [ ] 语言设置
|
||||||
|
- [ ] 配置目录设置
|
||||||
|
- [ ] 其他通用设置
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/AboutSection.tsx`
|
||||||
|
- [ ] 版本信息
|
||||||
|
- [ ] 更新检查
|
||||||
|
- [ ] 链接
|
||||||
|
|
||||||
|
- [ ] 创建 `src/components/settings/ImportExportSection.tsx`
|
||||||
|
- [ ] 导入功能
|
||||||
|
- [ ] 导出功能
|
||||||
|
|
||||||
|
**目标**: 将 643 行拆分为 4-5 个小组件,每个 100-150 行
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证设置保存功能
|
||||||
|
- [ ] 验证导入导出功能
|
||||||
|
- [ ] 验证更新检查功能
|
||||||
|
|
||||||
|
### 3.2 重构通知系统
|
||||||
|
|
||||||
|
- [ ] 在所有 mutations 中使用 `toast` 替代 `showNotification`
|
||||||
|
- [ ] 移除 App.tsx 中的 notification 状态
|
||||||
|
- [ ] 移除自定义通知组件
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证成功通知显示
|
||||||
|
- [ ] 验证错误通知显示
|
||||||
|
- [ ] 验证通知自动消失
|
||||||
|
|
||||||
|
### 3.3 重构确认对话框
|
||||||
|
|
||||||
|
- [ ] 更新 `src/components/ConfirmDialog.tsx` 使用新的 Dialog
|
||||||
|
- [ ] 或者直接使用 shadcn/ui 的 AlertDialog
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
- [ ] 验证删除确认对话框
|
||||||
|
- [ ] 验证其他确认场景
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 4: 清理和优化 (预计 1-2 天)
|
||||||
|
|
||||||
|
### 4.1 移除旧代码
|
||||||
|
|
||||||
|
- [x] 删除 `src/lib/styles.ts`
|
||||||
|
- [x] 从 `src/lib/tauri-api.ts` 移除 `window.api` 绑定
|
||||||
|
- [x] 精简 `src/lib/tauri-api.ts`,只保留事件监听相关
|
||||||
|
- [x] 删除或更新 `src/vite-env.d.ts` 中的过时类型
|
||||||
|
|
||||||
|
### 4.2 代码审查
|
||||||
|
|
||||||
|
- [ ] 检查所有 TODO 注释
|
||||||
|
- [x] 检查是否还有 `window.api` 调用
|
||||||
|
- [ ] 检查是否还有手动状态管理
|
||||||
|
- [x] 统一代码风格
|
||||||
|
|
||||||
|
### 4.3 类型检查
|
||||||
|
|
||||||
|
- [x] 运行 `pnpm typecheck` 确保无错误
|
||||||
|
- [x] 修复所有类型错误
|
||||||
|
- [x] 更新类型定义
|
||||||
|
|
||||||
|
### 4.4 性能优化
|
||||||
|
|
||||||
|
- [ ] 检查是否有不必要的重渲染
|
||||||
|
- [ ] 添加必要的 React.memo
|
||||||
|
- [ ] 优化 Query 缓存配置
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段 5: 测试和修复 (预计 2-3 天)
|
||||||
|
|
||||||
|
### 5.1 功能测试
|
||||||
|
|
||||||
|
#### 供应商管理
|
||||||
|
- [ ] 添加供应商 (Claude)
|
||||||
|
- [ ] 添加供应商 (Codex)
|
||||||
|
- [ ] 编辑供应商
|
||||||
|
- [ ] 删除供应商
|
||||||
|
- [ ] 切换供应商
|
||||||
|
- [ ] 导入默认配置
|
||||||
|
|
||||||
|
#### 应用切换
|
||||||
|
- [ ] Claude <-> Codex 切换
|
||||||
|
- [ ] 切换后数据正确加载
|
||||||
|
- [ ] 切换后托盘菜单更新
|
||||||
|
|
||||||
|
#### 设置
|
||||||
|
- [ ] 保存通用设置
|
||||||
|
- [ ] 切换语言
|
||||||
|
- [ ] 配置目录选择
|
||||||
|
- [ ] 导入配置
|
||||||
|
- [ ] 导出配置
|
||||||
|
|
||||||
|
#### UI 交互
|
||||||
|
- [ ] 主题切换 (亮色/暗色)
|
||||||
|
- [ ] 对话框打开/关闭
|
||||||
|
- [ ] 表单验证
|
||||||
|
- [ ] Toast 通知
|
||||||
|
|
||||||
|
#### MCP 管理
|
||||||
|
- [ ] 列表显示
|
||||||
|
- [ ] 添加 MCP
|
||||||
|
- [ ] 编辑 MCP
|
||||||
|
- [ ] 删除 MCP
|
||||||
|
- [ ] 启用/禁用 MCP
|
||||||
|
|
||||||
|
### 5.2 边界情况测试
|
||||||
|
|
||||||
|
- [ ] 空供应商列表
|
||||||
|
- [ ] 无效配置文件
|
||||||
|
- [ ] 网络错误
|
||||||
|
- [ ] 后端错误响应
|
||||||
|
- [ ] 并发操作
|
||||||
|
- [ ] 表单输入边界值
|
||||||
|
|
||||||
|
### 5.3 兼容性测试
|
||||||
|
|
||||||
|
- [ ] Windows 测试
|
||||||
|
- [ ] macOS 测试
|
||||||
|
- [ ] Linux 测试
|
||||||
|
|
||||||
|
### 5.4 性能测试
|
||||||
|
|
||||||
|
- [ ] 100+ 供应商加载速度
|
||||||
|
- [ ] 快速切换供应商
|
||||||
|
- [ ] 内存使用情况
|
||||||
|
- [ ] CPU 使用情况
|
||||||
|
|
||||||
|
### 5.5 Bug 修复
|
||||||
|
|
||||||
|
**Bug 列表** (发现后记录):
|
||||||
|
|
||||||
|
1. ___________
|
||||||
|
- [ ] 已修复
|
||||||
|
- [ ] 已验证
|
||||||
|
|
||||||
|
2. ___________
|
||||||
|
- [ ] 已修复
|
||||||
|
- [ ] 已验证
|
||||||
|
|
||||||
|
**完成时间**: ___________
|
||||||
|
**遇到的问题**: ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 最终检查
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
- [ ] 所有 TypeScript 错误已修复
|
||||||
|
- [ ] 运行 `pnpm format` 格式化代码
|
||||||
|
- [ ] 运行 `pnpm typecheck` 通过
|
||||||
|
- [ ] 代码审查完成
|
||||||
|
|
||||||
|
### 文档更新
|
||||||
|
|
||||||
|
- [ ] 更新 `CLAUDE.md` 反映新架构
|
||||||
|
- [ ] 更新 `README.md` (如有必要)
|
||||||
|
- [ ] 添加 Migration Guide (可选)
|
||||||
|
|
||||||
|
### 性能基准
|
||||||
|
|
||||||
|
记录性能数据:
|
||||||
|
|
||||||
|
**旧版本**:
|
||||||
|
- 启动时间: _____ms
|
||||||
|
- 供应商加载: _____ms
|
||||||
|
- 内存占用: _____MB
|
||||||
|
|
||||||
|
**新版本**:
|
||||||
|
- 启动时间: _____ms
|
||||||
|
- 供应商加载: _____ms
|
||||||
|
- 内存占用: _____MB
|
||||||
|
|
||||||
|
### 代码统计
|
||||||
|
|
||||||
|
**代码行数对比**:
|
||||||
|
|
||||||
|
| 文件 | 旧版本 | 新版本 | 减少 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| App.tsx | 412 | ~100 | -76% |
|
||||||
|
| tauri-api.ts | 712 | ~50 | -93% |
|
||||||
|
| ProviderForm.tsx | 271 | ~150 | -45% |
|
||||||
|
| settings 模块 | 1046 | ~470 (拆分) | -55% |
|
||||||
|
| **总计** | 2038 | ~700 | **-66%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 发布准备
|
||||||
|
|
||||||
|
### Pre-release 测试
|
||||||
|
|
||||||
|
- [ ] 创建 beta 版本 `v4.0.0-beta.1`
|
||||||
|
- [ ] 在测试环境验证
|
||||||
|
- [ ] 收集用户反馈
|
||||||
|
|
||||||
|
### 正式发布
|
||||||
|
|
||||||
|
- [ ] 合并到 main 分支
|
||||||
|
- [ ] 创建 Release Tag `v4.0.0`
|
||||||
|
- [ ] 更新 Changelog
|
||||||
|
- [ ] 发布 GitHub Release
|
||||||
|
- [ ] 通知用户更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 回滚触发条件
|
||||||
|
|
||||||
|
如果出现以下情况,考虑回滚:
|
||||||
|
|
||||||
|
- [ ] 重大功能无法使用
|
||||||
|
- [ ] 用户数据丢失
|
||||||
|
- [ ] 严重性能问题
|
||||||
|
- [ ] 无法修复的兼容性问题
|
||||||
|
|
||||||
|
**回滚命令**:
|
||||||
|
```bash
|
||||||
|
git reset --hard backup-before-refactor
|
||||||
|
# 或
|
||||||
|
git revert <commit-range>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 总结报告
|
||||||
|
|
||||||
|
### 成功指标
|
||||||
|
|
||||||
|
- [ ] 所有现有功能正常工作
|
||||||
|
- [ ] 代码量减少 40%+
|
||||||
|
- [ ] 无用户数据丢失
|
||||||
|
- [ ] 性能未下降
|
||||||
|
|
||||||
|
### 经验教训
|
||||||
|
|
||||||
|
**遇到的主要挑战**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
**未来改进**:
|
||||||
|
1. ___________
|
||||||
|
2. ___________
|
||||||
|
3. ___________
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**重构完成日期**: ___________
|
||||||
|
**总耗时**: _____ 天
|
||||||
|
**参与人员**: ___________
|
||||||
1658
docs/REFACTORING_MASTER_PLAN.md
Normal file
834
docs/REFACTORING_REFERENCE.md
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
# 重构快速参考指南
|
||||||
|
|
||||||
|
> 常见模式和代码示例的速查表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 目录
|
||||||
|
|
||||||
|
1. [React Query 使用](#react-query-使用)
|
||||||
|
2. [react-hook-form 使用](#react-hook-form-使用)
|
||||||
|
3. [shadcn/ui 组件使用](#shadcnui-组件使用)
|
||||||
|
4. [代码迁移示例](#代码迁移示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Query 使用
|
||||||
|
|
||||||
|
### 基础查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义查询 Hook
|
||||||
|
export const useProvidersQuery = (appId: AppId) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['providers', appId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await providersApi.getAll(appId)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在组件中使用
|
||||||
|
function MyComponent() {
|
||||||
|
const { data, isLoading, error } = useProvidersQuery('claude')
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>
|
||||||
|
if (error) return <div>Error: {error.message}</div>
|
||||||
|
|
||||||
|
return <div>{/* 使用 data */}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutation (变更操作)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义 Mutation Hook
|
||||||
|
export const useAddProviderMutation = (appId: AppId) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (provider: Provider) => {
|
||||||
|
return await providersApi.add(provider, appId)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// 重新获取数据
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
toast.success('添加成功')
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`添加失败: ${error.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在组件中使用
|
||||||
|
function AddProviderDialog() {
|
||||||
|
const mutation = useAddProviderMutation('claude')
|
||||||
|
|
||||||
|
const handleSubmit = (data: Provider) => {
|
||||||
|
mutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(formData)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? '添加中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 乐观更新
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const useSwitchProviderMutation = (appId: AppId) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (providerId: string) => {
|
||||||
|
return await providersApi.switch(providerId, appId)
|
||||||
|
},
|
||||||
|
// 乐观更新: 在请求发送前立即更新 UI
|
||||||
|
onMutate: async (providerId) => {
|
||||||
|
// 取消正在进行的查询
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 保存当前数据(以便回滚)
|
||||||
|
const previousData = queryClient.getQueryData(['providers', appId])
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
|
queryClient.setQueryData(['providers', appId], (old: any) => ({
|
||||||
|
...old,
|
||||||
|
currentProviderId: providerId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { previousData }
|
||||||
|
},
|
||||||
|
// 如果失败,回滚
|
||||||
|
onError: (err, providerId, context) => {
|
||||||
|
queryClient.setQueryData(['providers', appId], context?.previousData)
|
||||||
|
toast.error('切换失败')
|
||||||
|
},
|
||||||
|
// 无论成功失败,都重新获取数据
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 第二个查询依赖第一个查询的结果
|
||||||
|
const { data: providers } = useProvidersQuery(appId)
|
||||||
|
const currentProviderId = providers?.currentProviderId
|
||||||
|
|
||||||
|
const { data: currentProvider } = useQuery({
|
||||||
|
queryKey: ['provider', currentProviderId],
|
||||||
|
queryFn: () => providersApi.getById(currentProviderId!),
|
||||||
|
enabled: !!currentProviderId, // 只有当 ID 存在时才执行
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## react-hook-form 使用
|
||||||
|
|
||||||
|
### 基础表单
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// 定义验证 schema
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, '请输入名称'),
|
||||||
|
email: z.string().email('邮箱格式不正确'),
|
||||||
|
age: z.number().min(18, '年龄必须大于18'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
age: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
console.log(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<input {...form.register('name')} />
|
||||||
|
{form.formState.errors.name && (
|
||||||
|
<span>{form.formState.errors.name.message}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit">提交</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 shadcn/ui Form 组件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>名称</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="请输入名称" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态表单验证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 根据条件动态验证
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(['official', 'custom']),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// 如果是自定义供应商,必须填写 baseUrl
|
||||||
|
if (data.type === 'custom') {
|
||||||
|
return !!data.baseUrl
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: '自定义供应商必须填写 Base URL',
|
||||||
|
path: ['baseUrl'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动触发验证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormData>()
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
// 验证单个字段
|
||||||
|
await form.trigger('name')
|
||||||
|
|
||||||
|
// 验证多个字段
|
||||||
|
await form.trigger(['name', 'email'])
|
||||||
|
|
||||||
|
// 验证所有字段
|
||||||
|
const isValid = await form.trigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <form>...</form>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## shadcn/ui 组件使用
|
||||||
|
|
||||||
|
### Dialog (对话框)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
function MyDialog() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
<DialogDescription>描述信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div>对话框内容</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm}>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select (选择器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
function MySelect() {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={setValue}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="option1">选项1</SelectItem>
|
||||||
|
<SelectItem value="option2">选项2</SelectItem>
|
||||||
|
<SelectItem value="option3">选项3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs (标签页)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
|
function MyTabs() {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="tab1">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tab1">标签1</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab2">标签2</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab3">标签3</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tab1">
|
||||||
|
<div>标签1的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tab2">
|
||||||
|
<div>标签2的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tab3">
|
||||||
|
<div>标签3的内容</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast 通知 (Sonner)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// 成功通知
|
||||||
|
toast.success('操作成功')
|
||||||
|
|
||||||
|
// 错误通知
|
||||||
|
toast.error('操作失败')
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
const toastId = toast.loading('处理中...')
|
||||||
|
// 完成后更新
|
||||||
|
toast.success('处理完成', { id: toastId })
|
||||||
|
// 或
|
||||||
|
toast.dismiss(toastId)
|
||||||
|
|
||||||
|
// 自定义持续时间
|
||||||
|
toast.success('消息', { duration: 5000 })
|
||||||
|
|
||||||
|
// 带操作按钮
|
||||||
|
toast('确认删除?', {
|
||||||
|
action: {
|
||||||
|
label: '删除',
|
||||||
|
onClick: () => handleDelete(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码迁移示例
|
||||||
|
|
||||||
|
### 示例 1: 状态管理迁移
|
||||||
|
|
||||||
|
**旧代码** (手动状态管理):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [providers, setProviders] = useState<Record<string, Provider>>({})
|
||||||
|
const [currentProviderId, setCurrentProviderId] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await window.api.getProviders(appType)
|
||||||
|
const currentId = await window.api.getCurrentProvider(appType)
|
||||||
|
setProviders(data)
|
||||||
|
setCurrentProviderId(currentId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [appId])
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (React Query):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data, isLoading, error } = useProvidersQuery(appId)
|
||||||
|
const providers = data?.providers || {}
|
||||||
|
const currentProviderId = data?.currentProviderId || ''
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 20+ 行到 3 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 2: 表单验证迁移
|
||||||
|
|
||||||
|
**旧代码** (手动验证):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [nameError, setNameError] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [apiKeyError, setApiKeyError] = useState('')
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setNameError('请输入名称')
|
||||||
|
valid = false
|
||||||
|
} else {
|
||||||
|
setNameError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setApiKeyError('请输入 API Key')
|
||||||
|
valid = false
|
||||||
|
} else if (apiKey.length < 10) {
|
||||||
|
setApiKeyError('API Key 长度不足')
|
||||||
|
valid = false
|
||||||
|
} else {
|
||||||
|
setApiKeyError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (validate()) {
|
||||||
|
// 提交
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
{nameError && <span>{nameError}</span>}
|
||||||
|
|
||||||
|
<input value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||||
|
{apiKeyError && <span>{apiKeyError}</span>}
|
||||||
|
|
||||||
|
<button onClick={handleSubmit}>提交</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (react-hook-form + zod):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, '请输入名称'),
|
||||||
|
apiKey: z.string().min(10, 'API Key 长度不足'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apiKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 40+ 行到 30 行,且更健壮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 3: 通知系统迁移
|
||||||
|
|
||||||
|
**旧代码** (自定义通知):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error'
|
||||||
|
} | null>(null)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
const showNotification = (message: string, type: 'success' | 'error') => {
|
||||||
|
setNotification({ message, type })
|
||||||
|
setIsVisible(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => setNotification(null), 300)
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{notification && (
|
||||||
|
<div className={`notification ${isVisible ? 'visible' : ''} ${notification.type}`}>
|
||||||
|
{notification.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 其他内容 */}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (Sonner):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// 在需要的地方直接调用
|
||||||
|
toast.success('操作成功')
|
||||||
|
toast.error('操作失败')
|
||||||
|
|
||||||
|
// 在 main.tsx 中只需添加一次
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
```
|
||||||
|
|
||||||
|
**减少**: 从 20+ 行到 1 行调用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 4: 对话框迁移
|
||||||
|
|
||||||
|
**旧代码** (自定义 Modal):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setIsOpen(true)}>打开</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setIsOpen(false)}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>标题</h2>
|
||||||
|
<button onClick={() => setIsOpen(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* 内容 */}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={() => setIsOpen(false)}>取消</button>
|
||||||
|
<button onClick={handleConfirm}>确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (shadcn/ui Dialog):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setIsOpen(true)}>打开</Button>
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* 内容 */}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleConfirm}>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- 无需自定义样式
|
||||||
|
- 内置无障碍支持
|
||||||
|
- 自动管理焦点和 ESC 键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 示例 5: API 调用迁移
|
||||||
|
|
||||||
|
**旧代码** (window.api):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 添加供应商
|
||||||
|
const handleAdd = async (provider: Provider) => {
|
||||||
|
try {
|
||||||
|
await window.api.addProvider(provider, appType)
|
||||||
|
await loadProviders()
|
||||||
|
showNotification('添加成功', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('添加失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码** (React Query Mutation):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在组件中
|
||||||
|
const addMutation = useAddProviderMutation(appId)
|
||||||
|
|
||||||
|
const handleAdd = (provider: Provider) => {
|
||||||
|
addMutation.mutate(provider)
|
||||||
|
// 成功和错误处理已在 mutation 定义中处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- 自动处理 loading 状态
|
||||||
|
- 统一的错误处理
|
||||||
|
- 自动刷新数据
|
||||||
|
- 更少的样板代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何在 mutation 成功后关闭对话框?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mutation = useAddProviderMutation(appId)
|
||||||
|
|
||||||
|
const handleSubmit = (data: Provider) => {
|
||||||
|
mutation.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsOpen(false) // 关闭对话框
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何在表单中使用异步验证?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().refine(
|
||||||
|
async (name) => {
|
||||||
|
// 检查名称是否已存在
|
||||||
|
const exists = await checkNameExists(name)
|
||||||
|
return !exists
|
||||||
|
},
|
||||||
|
{ message: '名称已存在' }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何手动刷新 Query 数据?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// 方式1: 使缓存失效,触发重新获取
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 方式2: 直接刷新
|
||||||
|
queryClient.refetchQueries({ queryKey: ['providers', appId] })
|
||||||
|
|
||||||
|
// 方式3: 更新缓存数据
|
||||||
|
queryClient.setQueryData(['providers', appId], newData)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何在组件外部使用 toast?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 直接导入并使用即可
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export const someUtil = () => {
|
||||||
|
toast.success('工具函数中的通知')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### React Query DevTools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 main.tsx 中添加
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看表单状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = useForm()
|
||||||
|
|
||||||
|
// 在开发模式下打印表单状态
|
||||||
|
console.log('Form values:', form.watch())
|
||||||
|
console.log('Form errors:', form.formState.errors)
|
||||||
|
console.log('Is valid:', form.formState.isValid)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
### 1. 避免不必要的重渲染
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用 React.memo
|
||||||
|
export const ProviderCard = React.memo(({ provider, onEdit }: Props) => {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// 或使用 useMemo
|
||||||
|
const sortedProviders = useMemo(
|
||||||
|
() => Object.values(providers).sort(...),
|
||||||
|
[providers]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Query 配置优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['providers', appId],
|
||||||
|
queryFn: fetchProviders,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5分钟内不重新获取
|
||||||
|
gcTime: 1000 * 60 * 10, // 10分钟后清除缓存
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 表单性能优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用 mode 控制验证时机
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'onBlur', // 失去焦点时验证
|
||||||
|
// mode: 'onChange', // 每次输入都验证(较慢)
|
||||||
|
// mode: 'onSubmit', // 提交时验证(最快)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**: 将此文档保存在浏览器书签或编辑器中,方便随时查阅!
|
||||||
73
docs/TEST_DEVELOPMENT_PLAN.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 前端测试开发计划
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
- **背景**:v3.5.0 起前端功能快速扩张(供应商管理、MCP、导入导出、端点测速、国际化),缺失系统化测试导致回归风险与人工验证成本攀升。
|
||||||
|
- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。
|
||||||
|
|
||||||
|
## 2. 范围与优先级
|
||||||
|
| 范围 | 内容 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 |
|
||||||
|
| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 |
|
||||||
|
| MCP 管理 | 列表、启停、模板、命令校验 | P1 |
|
||||||
|
| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 |
|
||||||
|
| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 |
|
||||||
|
| 国际化 | 中英切换、缺省文案回退 | P2 |
|
||||||
|
|
||||||
|
## 3. 测试分层策略
|
||||||
|
- **单元测试(Vitest)**:纯函数与 Hook(`useProviderActions`、`useSettingsForm`、`useDragSort`、`useImportExport` 等)验证数据处理、错误分支、排序逻辑。
|
||||||
|
- **组件测试(React Testing Library)**:关键组件(`ProviderList`、`AddProviderDialog`、`SettingsDialog`、`McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。
|
||||||
|
- **集成测试(App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。
|
||||||
|
- **端到端测试(Playwright)**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。
|
||||||
|
- **手动冒烟**:Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。
|
||||||
|
|
||||||
|
## 4. 环境与工具
|
||||||
|
- 依赖:Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。
|
||||||
|
- 配置要点:
|
||||||
|
- 在 `tsconfig` 中共享别名,Vitest 配合 `vite.config.mts`。
|
||||||
|
- `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。
|
||||||
|
- Playwright 使用多浏览器矩阵(Chromium 必选,WebKit 可选),并共享 `.env.test`。
|
||||||
|
- Mock `@tauri-apps/api` 与 `providersApi`/`settingsApi`,隔离 Rust 层。
|
||||||
|
|
||||||
|
## 5. 自动化建设里程碑
|
||||||
|
| 周期 | 目标 | 交付 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sprint 1 | Vitest 基础设施、核心 Hook 单测(P0) | `pnpm test:unit`、覆盖率报告、10+ 用例 |
|
||||||
|
| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 |
|
||||||
|
| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 |
|
||||||
|
| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 |
|
||||||
|
|
||||||
|
## 6. 用例规划概览
|
||||||
|
- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。
|
||||||
|
- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。
|
||||||
|
- **MCP**:模板应用、协议切换(stdio/http)、命令校验、启停状态持久化。
|
||||||
|
- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。
|
||||||
|
- **端点速度测试**:触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。
|
||||||
|
- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。
|
||||||
|
|
||||||
|
## 7. 数据与 Mock 策略
|
||||||
|
- 在 `tests/fixtures/` 维护标准供应商、MCP、设置数据集。
|
||||||
|
- 使用 MSW 拦截 `providersApi`、`settingsApi`、`providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。
|
||||||
|
- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`)+ 伪配置文件,以验证真实文件交互路径。
|
||||||
|
|
||||||
|
## 8. 质量门禁与指标
|
||||||
|
- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。
|
||||||
|
- CI 阶段:`pnpm typecheck` → `pnpm format:check` → `pnpm test:unit` → `pnpm test:component` → `pnpm test:e2e`(可在 nightly 执行)。
|
||||||
|
- 缺陷处理:修复前补充最小复现测试;E2E 冒烟必须陪跑重大功能发布。
|
||||||
|
|
||||||
|
## 9. 工作流与职责
|
||||||
|
- **测试负责人**:前端工程师轮值;负责测试计划维护、PR 流水线健康。
|
||||||
|
- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。
|
||||||
|
- **Code Review 检查**:测试覆盖说明、mock 合理性、易读性。
|
||||||
|
|
||||||
|
## 10. 风险与缓解
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 |
|
||||||
|
| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 |
|
||||||
|
| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key |
|
||||||
|
|
||||||
|
## 11. 输出与维护
|
||||||
|
- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。
|
||||||
|
- 交付物:测试报告(CI artifact)、Playwright Trace、覆盖率摘要。
|
||||||
|
- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。
|
||||||
249
docs/release-note-v3.6.0-en.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
## Major architecture refactoring with enhanced config sync and data protection
|
||||||
|
|
||||||
|
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.0-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
### Edit Mode & Provider Management
|
||||||
|
|
||||||
|
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
|
||||||
|
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
|
||||||
|
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
||||||
|
|
||||||
|
### Custom Endpoint Management
|
||||||
|
|
||||||
|
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
|
||||||
|
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
|
||||||
|
|
||||||
|
### Usage Query Enhancements
|
||||||
|
|
||||||
|
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
|
||||||
|
- **Test Script API** - Validate JavaScript usage query scripts before execution
|
||||||
|
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
|
||||||
|
Thanks to @Sirhexs
|
||||||
|
|
||||||
|
### Custom Configuration Directory (Cloud Sync)
|
||||||
|
|
||||||
|
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
|
||||||
|
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
|
||||||
|
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
|
||||||
|
Thanks to @ZyphrZero
|
||||||
|
|
||||||
|
### Configuration Directory Switching (WSL Support)
|
||||||
|
|
||||||
|
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
|
||||||
|
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
|
||||||
|
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
|
||||||
|
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
|
||||||
|
|
||||||
|
### Configuration Editor Improvements
|
||||||
|
|
||||||
|
- **JSON Format Button** - One-click JSON formatting in configuration editors
|
||||||
|
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
|
||||||
|
|
||||||
|
### Load Live Config When Editing
|
||||||
|
|
||||||
|
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
|
||||||
|
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
|
||||||
|
|
||||||
|
### Claude Configuration Data Structure Enhancements
|
||||||
|
|
||||||
|
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
|
||||||
|
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
|
||||||
|
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
|
||||||
|
- Backend normalizes old configs on first read/write with smart fallback chain
|
||||||
|
- UI expanded from 2 to 4 model input fields with intelligent defaults
|
||||||
|
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
|
||||||
|
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
|
||||||
|
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
|
||||||
|
- **Visual Theme Configuration** - Custom icons and colors for provider cards
|
||||||
|
|
||||||
|
### Updated Provider Models
|
||||||
|
|
||||||
|
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
|
||||||
|
|
||||||
|
### New Provider Presets
|
||||||
|
|
||||||
|
Added 5 new provider presets:
|
||||||
|
|
||||||
|
- **DMXAPI** - Multi-model aggregation service
|
||||||
|
- **Azure Codex** - Microsoft Azure OpenAI endpoint
|
||||||
|
- **AnyRouter** - None-profit routing service
|
||||||
|
- **AiHubMix** - Multi-model aggregation service
|
||||||
|
- **MiniMax** - Open source AI model provider
|
||||||
|
|
||||||
|
### Partner Promotion Mechanism
|
||||||
|
|
||||||
|
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
|
||||||
|
- Sponsored banner integration in README
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Configuration & Sync
|
||||||
|
|
||||||
|
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
|
||||||
|
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
|
||||||
|
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
|
||||||
|
- **Import Config Sync** - Fixed sync issues after configuration import
|
||||||
|
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
|
||||||
|
|
||||||
|
### UI/UX Enhancements
|
||||||
|
|
||||||
|
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
|
||||||
|
- **Unified Border System** - Consistent border design across all components
|
||||||
|
- **Drag Interaction** - Push effect animation and improved drag handle icons
|
||||||
|
- **Enhanced Visual Feedback** - Better current provider visual indication
|
||||||
|
- **Dialog Standardization** - Unified dialog sizes and layout consistency
|
||||||
|
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
|
||||||
|
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
|
||||||
|
|
||||||
|
### Complete Internationalization
|
||||||
|
|
||||||
|
- **Error Messages i18n** - All backend error messages support Chinese/English
|
||||||
|
- **Tray Menu i18n** - System tray menu fully internationalized
|
||||||
|
- **UI Components i18n** - 100% coverage across all user-facing components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
- Fixed `apiKeyUrl` priority issue
|
||||||
|
- Fixed MCP sync-to-other-side functionality failure
|
||||||
|
- Fixed sync issues after config import
|
||||||
|
- Fixed Codex API Key auto-sync
|
||||||
|
- Fixed endpoint speed test functionality
|
||||||
|
- Fixed provider duplicate insertion position (now inserts next to original)
|
||||||
|
- Fixed custom endpoint preservation in edit mode
|
||||||
|
- Prevent silent fallback and data loss on config error
|
||||||
|
|
||||||
|
### Usage Query
|
||||||
|
|
||||||
|
- Fixed auto-query interval timing issue
|
||||||
|
- Ensured refresh button shows loading animation on click
|
||||||
|
|
||||||
|
### UI Issues
|
||||||
|
|
||||||
|
- Fixed name collision error (`get_init_error` command)
|
||||||
|
- Fixed language setting rollback after successful save
|
||||||
|
- Fixed language switch state reset (dependency cycle)
|
||||||
|
- Fixed edit mode button alignment
|
||||||
|
|
||||||
|
### Startup Issues
|
||||||
|
|
||||||
|
- Force exit on config error (no silent fallback)
|
||||||
|
- Eliminated code duplication causing initialization errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Refactoring
|
||||||
|
|
||||||
|
### Backend (Rust) - 5 Phase Refactoring
|
||||||
|
|
||||||
|
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||||
|
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
|
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||||
|
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
||||||
|
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||||
|
|
||||||
|
### Frontend (React + TypeScript) - 4 Stage Refactoring
|
||||||
|
|
||||||
|
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
||||||
|
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||||
|
3. **Stage 3**: Component splitting and business logic extraction
|
||||||
|
4. **Stage 4**: Code cleanup and formatting unification
|
||||||
|
|
||||||
|
### Testing System
|
||||||
|
|
||||||
|
- **Hooks Unit Tests** - 100% coverage for all custom hooks
|
||||||
|
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
|
||||||
|
- **MSW Mocking** - Backend API mocking to ensure test independence
|
||||||
|
- **Test Infrastructure** - vitest + MSW + @testing-library/react
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
|
||||||
|
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
|
||||||
|
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
|
||||||
|
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
|
||||||
|
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal Optimizations (User Transparent)
|
||||||
|
|
||||||
|
### Removed Legacy Migration Logic
|
||||||
|
|
||||||
|
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
|
||||||
|
|
||||||
|
- **Impact**: Improved startup performance, cleaner codebase
|
||||||
|
- **Compatibility**: v2 format configs fully compatible, no action required
|
||||||
|
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
|
||||||
|
|
||||||
|
### Command Parameter Standardization
|
||||||
|
|
||||||
|
Backend unified to use `app` parameter (values: `claude` or `codex`):
|
||||||
|
|
||||||
|
- **Impact**: More standardized code, friendlier error prompts
|
||||||
|
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Updated to **Tauri 2.8.x**
|
||||||
|
- Updated to **TailwindCSS 4.x**
|
||||||
|
- Updated to **TanStack Query v5.90.x**
|
||||||
|
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
**Via Homebrew (Recommended):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual Download:**
|
||||||
|
|
||||||
|
- Download `CC-Switch-v3.6.0-macOS.zip` from [Assets](#assets) below
|
||||||
|
|
||||||
|
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- **Installer**: `CC-Switch-v3.6.0-Windows.msi`
|
||||||
|
- **Portable**: `CC-Switch-v3.6.0-Windows-Portable.zip`
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
- **AppImage**: `CC-Switch-v3.6.0-Linux.AppImage`
|
||||||
|
- **Debian**: `CC-Switch-v3.6.0-Linux.deb`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
||||||
|
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
||||||
|
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Special thanks to **Zhipu AI** for sponsoring this project with their GLM CODING PLAN!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0
|
||||||
249
docs/release-note-v3.6.0-zh.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# CC Switch v3.6.0
|
||||||
|
|
||||||
|
> 全栈架构重构,增强配置同步与数据保护
|
||||||
|
|
||||||
|
**[English Version →](../release-note-v3.6.0.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### 编辑模式与供应商管理
|
||||||
|
|
||||||
|
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
|
||||||
|
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
|
||||||
|
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
|
||||||
|
|
||||||
|
### 自定义端点管理
|
||||||
|
|
||||||
|
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
|
||||||
|
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
|
||||||
|
|
||||||
|
### 自定义配置目录(云同步)
|
||||||
|
|
||||||
|
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
|
||||||
|
- **云同步支持** - 指定到云同步文件夹(Dropbox、OneDrive、iCloud Drive、坚果云等)即可实现跨设备配置自动同步
|
||||||
|
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
|
||||||
|
|
||||||
|
### 使用量查询增强
|
||||||
|
|
||||||
|
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
|
||||||
|
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
|
||||||
|
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
|
||||||
|
|
||||||
|
### 配置目录切换(WSL 支持)
|
||||||
|
|
||||||
|
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
|
||||||
|
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
|
||||||
|
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
|
||||||
|
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
|
||||||
|
|
||||||
|
### 配置编辑器改进
|
||||||
|
|
||||||
|
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
|
||||||
|
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
|
||||||
|
|
||||||
|
### 编辑时加载 Live 配置
|
||||||
|
|
||||||
|
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
|
||||||
|
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
|
||||||
|
|
||||||
|
### Claude 配置数据结构增强
|
||||||
|
|
||||||
|
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
|
||||||
|
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`
|
||||||
|
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
|
||||||
|
- 后端在首次读写时自动规范化旧配置,带有智能回退链
|
||||||
|
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
|
||||||
|
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
|
||||||
|
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
|
||||||
|
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
|
||||||
|
- **视觉主题配置** - 供应商卡片自定义图标和颜色
|
||||||
|
|
||||||
|
### 供应商模型更新
|
||||||
|
|
||||||
|
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
|
||||||
|
|
||||||
|
### 新增供应商预设
|
||||||
|
|
||||||
|
新增 5 个供应商预设:
|
||||||
|
|
||||||
|
- **DMXAPI** - 多模型聚合服务
|
||||||
|
- **Azure Codex** - 微软 Azure OpenAI 端点
|
||||||
|
- **AnyRouter** - API 路由服务
|
||||||
|
- **AiHubMix** - AI 模型集合
|
||||||
|
- **MiniMax** - 国产 AI 模型提供商
|
||||||
|
|
||||||
|
### 合作伙伴推广机制
|
||||||
|
|
||||||
|
- 支持生态合作伙伴推广(智谱 GLM Z.ai)
|
||||||
|
- README 中集成赞助商横幅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改进优化
|
||||||
|
|
||||||
|
### 配置与同步
|
||||||
|
|
||||||
|
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
|
||||||
|
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
|
||||||
|
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
|
||||||
|
- **导入配置同步** - 修复配置导入后的同步问题
|
||||||
|
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
|
||||||
|
|
||||||
|
### UI/UX 增强
|
||||||
|
|
||||||
|
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
|
||||||
|
- **统一边框系统** - 所有组件采用一致的边框设计
|
||||||
|
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
|
||||||
|
- **增强视觉反馈** - 更好的当前供应商视觉指示
|
||||||
|
- **对话框标准化** - 统一的对话框尺寸和布局一致性
|
||||||
|
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
|
||||||
|
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
|
||||||
|
|
||||||
|
### 完整国际化
|
||||||
|
|
||||||
|
- **错误消息国际化** - 所有后端错误消息支持中英文
|
||||||
|
- **托盘菜单国际化** - 系统托盘菜单完全国际化
|
||||||
|
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
|
||||||
|
- 修复 `apiKeyUrl` 优先级问题
|
||||||
|
- 修复 MCP 同步到另一端功能失效
|
||||||
|
- 修复配置导入后的同步问题
|
||||||
|
- 修复 Codex API Key 自动同步
|
||||||
|
- 修复端点速度测试功能
|
||||||
|
- 修复供应商复制插入位置(现在插入到原供应商旁边)
|
||||||
|
- 修复编辑模式下自定义端点保留问题
|
||||||
|
- 防止配置错误时的静默回退和数据丢失
|
||||||
|
|
||||||
|
### 使用量查询
|
||||||
|
|
||||||
|
- 修复自动查询间隔时间问题
|
||||||
|
- 确保刷新按钮点击时显示加载动画
|
||||||
|
|
||||||
|
### UI 问题
|
||||||
|
|
||||||
|
- 修复名称冲突错误(`get_init_error` 命令)
|
||||||
|
- 修复保存成功后语言设置回滚
|
||||||
|
- 修复语言切换状态重置(依赖循环)
|
||||||
|
- 修复编辑模式按钮对齐
|
||||||
|
|
||||||
|
### 启动问题
|
||||||
|
|
||||||
|
- 配置错误时强制退出(不再静默回退)
|
||||||
|
- 消除导致初始化错误的代码重复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构重构
|
||||||
|
|
||||||
|
### 后端(Rust)- 5 阶段重构
|
||||||
|
|
||||||
|
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
|
||||||
|
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
|
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
|
||||||
|
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`)
|
||||||
|
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
|
||||||
|
|
||||||
|
### 前端(React + TypeScript)- 4 阶段重构
|
||||||
|
|
||||||
|
1. **阶段 1**:测试基础设施(vitest + MSW + @testing-library/react)
|
||||||
|
2. **阶段 2**:提取自定义 hooks(`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等)
|
||||||
|
3. **阶段 3**:组件拆分和业务逻辑提取
|
||||||
|
4. **阶段 4**:代码清理和格式化统一
|
||||||
|
|
||||||
|
### 测试体系
|
||||||
|
|
||||||
|
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
|
||||||
|
- **集成测试** - 关键流程覆盖(App、SettingsDialog、MCP 面板)
|
||||||
|
- **MSW 模拟** - 后端 API 模拟确保测试独立性
|
||||||
|
- **测试基础设施** - vitest + MSW + @testing-library/react
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase(Tauri 2 规范)
|
||||||
|
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
|
||||||
|
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
|
||||||
|
- **DRY 违规清理** - 消除整个代码库中的代码重复
|
||||||
|
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内部优化(用户无感知)
|
||||||
|
|
||||||
|
### 移除遗留迁移逻辑
|
||||||
|
|
||||||
|
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
|
||||||
|
|
||||||
|
- **影响**:提升启动性能,代码更简洁
|
||||||
|
- **兼容性**:v2 格式配置完全兼容,无需任何操作
|
||||||
|
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
|
||||||
|
|
||||||
|
### 命令参数标准化
|
||||||
|
|
||||||
|
后端统一使用 `app` 参数(取值:`claude` 或 `codex`):
|
||||||
|
|
||||||
|
- **影响**:代码更规范,错误提示更友好
|
||||||
|
- **兼容性**:前端已完全适配,用户无需关心此变更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖更新
|
||||||
|
|
||||||
|
- 更新到 **Tauri 2.8.x**
|
||||||
|
- 更新到 **TailwindCSS 4.x**
|
||||||
|
- 更新到 **TanStack Query v5.90.x**
|
||||||
|
- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装方式
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
**通过 Homebrew 安装(推荐):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动下载:**
|
||||||
|
|
||||||
|
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.0-macOS.zip`
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- **安装包**:`CC-Switch-v3.6.0-Windows.msi`
|
||||||
|
- **便携版**:`CC-Switch-v3.6.0-Windows-Portable.zip`
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
- **AppImage**:`CC-Switch-v3.6.0-Linux.AppImage`
|
||||||
|
- **Debian**:`CC-Switch-v3.6.0-Linux.deb`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
||||||
|
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
||||||
|
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
特别感谢**智谱 AI** 通过 GLM CODING PLAN 赞助本项目!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0
|
||||||
391
docs/release-note-v3.6.1-en.md
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
# CC Switch v3.6.1
|
||||||
|
|
||||||
|
> Stability improvements and user experience optimization (based on v3.6.0)
|
||||||
|
|
||||||
|
**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-note-v3.6.1-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 What's New in v3.6.1 (2025-11-10)
|
||||||
|
|
||||||
|
This release focuses on **user experience optimization** and **configuration parsing robustness**, fixing several critical bugs and enhancing the usage query system.
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
#### Usage Query System Enhancements
|
||||||
|
|
||||||
|
- **Credential Decoupling** - Usage queries can now use independent API Key and Base URL, no longer dependent on provider configuration
|
||||||
|
- Support for different query endpoints and authentication methods
|
||||||
|
- Automatically displays credential input fields based on template type
|
||||||
|
- General template: API Key + Base URL
|
||||||
|
- NewAPI template: Base URL + Access Token + User ID
|
||||||
|
- Custom template: Fully customizable
|
||||||
|
- **UI Component Upgrade** - Replaced native checkbox with shadcn/ui Switch component for modern experience
|
||||||
|
- **Form Unification** - Unified use of shadcn/ui Input components, consistent styling with the application
|
||||||
|
- **Password Visibility Toggle** - Added show/hide password functionality (API Key, Access Token)
|
||||||
|
|
||||||
|
#### Form Validation Infrastructure
|
||||||
|
|
||||||
|
- **Common Schema Library** - New JSON/TOML generic validators to reduce code duplication
|
||||||
|
- `jsonConfigSchema`: Generic JSON object validator
|
||||||
|
- `tomlConfigSchema`: Generic TOML format validator
|
||||||
|
- `mcpJsonConfigSchema`: MCP-specific JSON validator
|
||||||
|
- **MCP Conditional Field Validation** - Strict type checking
|
||||||
|
- stdio type requires `command` field
|
||||||
|
- http type requires `url` field
|
||||||
|
|
||||||
|
#### Partner Integration
|
||||||
|
|
||||||
|
- **PackyCode** - New official partner
|
||||||
|
- Added to Claude and Codex provider presets
|
||||||
|
- 10% discount promotion support
|
||||||
|
- New logo and partner identification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
|
||||||
|
#### User Experience
|
||||||
|
|
||||||
|
- **Drag Sort Sync** - Tray menu order now syncs with drag-and-drop sorting in real-time
|
||||||
|
- **Enhanced Error Notifications** - Provider switch failures now display copyable error messages
|
||||||
|
- **Removed Misleading Placeholders** - Deleted example text from model input fields to avoid user confusion
|
||||||
|
- **Auto-fill Base URL** - All non-official provider categories automatically populate the Base URL input field
|
||||||
|
|
||||||
|
#### Configuration Parsing
|
||||||
|
|
||||||
|
- **CJK Quote Normalization** - Automatically handles IME-input fullwidth quotes to prevent TOML parsing errors
|
||||||
|
- Supports automatic conversion of Chinese quotes (" " ' ') to ASCII quotes
|
||||||
|
- Applied in TOML input handlers
|
||||||
|
- Disabled browser auto-correction in Textarea component
|
||||||
|
- **Preserve Custom Fields** - Editing Codex MCP TOML configuration now preserves unknown fields
|
||||||
|
- Supports extension fields like timeout_ms, retry_count
|
||||||
|
- Forward compatibility with future MCP protocol extensions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
#### Critical Fixes
|
||||||
|
|
||||||
|
- **Fixed usage script panel white screen crash** - FormLabel component missing FormField context caused entire app to crash
|
||||||
|
- Replaced with standalone Label component
|
||||||
|
- Root cause: FormLabel internally calls useFormField() hook which requires FormFieldContext
|
||||||
|
- **Fixed CJK input quote parsing failure** - IME-input fullwidth quotes caused TOML parsing errors
|
||||||
|
- Added textNormalization utility function
|
||||||
|
- Automatically normalizes quotes before parsing
|
||||||
|
- **Fixed drag sort tray desync** (#179) - Tray menu order not updated after drag-and-drop sorting
|
||||||
|
- Automatically calls updateTrayMenu after sorting completes
|
||||||
|
- Ensures UI and tray menu stay consistent
|
||||||
|
- **Fixed MCP custom field loss** - Custom fields silently dropped when editing Codex MCP configuration
|
||||||
|
- Uses spread operator to retain all fields
|
||||||
|
- Preserves unknown fields in normalizeServerConfig
|
||||||
|
|
||||||
|
#### Stability Improvements
|
||||||
|
|
||||||
|
- **Error Isolation** - Tray menu update failures no longer affect main operations
|
||||||
|
- Decoupled tray update errors from main operations
|
||||||
|
- Provides warning when main operation succeeds but tray update fails
|
||||||
|
- **Safe Pattern Matching** - Replaced `unwrap()` with safe pattern matching
|
||||||
|
- Avoids panic-induced app crashes
|
||||||
|
- Tray menu event handling uses match patterns
|
||||||
|
- **Import Config Classification** - Importing from default config now automatically sets category to `custom`
|
||||||
|
- Avoids imported configs being mistaken for official presets
|
||||||
|
- Provides clearer configuration source identification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Technical Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Commits: 17 commits
|
||||||
|
Code Changes: 31 files
|
||||||
|
- Additions: 1,163 lines
|
||||||
|
- Deletions: 811 lines
|
||||||
|
- Net Growth: +352 lines
|
||||||
|
Contributors: Jason (16), ZyphrZero (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**By Module**:
|
||||||
|
- UI/User Interface: 3 commits
|
||||||
|
- Usage Query System: 3 commits
|
||||||
|
- Configuration Parsing: 2 commits
|
||||||
|
- Form Validation: 1 commit
|
||||||
|
- Other Improvements: 8 commits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📥 Installation
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
**Via Homebrew (Recommended):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual Download:**
|
||||||
|
|
||||||
|
- Download `CC-Switch-v3.6.1-macOS.zip` from [Assets](#assets) below
|
||||||
|
|
||||||
|
> **Note**: Due to lack of Apple Developer account, you may see "unidentified developer" warning. Go to System Settings → Privacy & Security → Click "Open Anyway"
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
- **Installer**: `CC-Switch-v3.6.1-Windows.msi`
|
||||||
|
- **Portable**: `CC-Switch-v3.6.1-Windows-Portable.zip`
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
- **AppImage**: `CC-Switch-v3.6.1-Linux.AppImage`
|
||||||
|
- **Debian**: `CC-Switch-v3.6.1-Linux.deb`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
||||||
|
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
||||||
|
- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🙏 Acknowledgments
|
||||||
|
|
||||||
|
Special thanks to:
|
||||||
|
- **Zhipu AI** - For sponsoring this project with GLM CODING PLAN
|
||||||
|
- **PackyCode** - New official partner
|
||||||
|
- **ZyphrZero** - For contributing tray menu sync fix (#179)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 v3.6.0 Complete Feature Review
|
||||||
|
|
||||||
|
> Content below is from v3.6.0 (2025-11-07), helping you understand the complete feature set
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Click to expand v3.6.0 detailed content →</b></summary>
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
### Edit Mode & Provider Management
|
||||||
|
|
||||||
|
- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click
|
||||||
|
- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero
|
||||||
|
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
||||||
|
|
||||||
|
### Custom Endpoint Management
|
||||||
|
|
||||||
|
- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints
|
||||||
|
- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically
|
||||||
|
|
||||||
|
### Usage Query Enhancements
|
||||||
|
|
||||||
|
- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals
|
||||||
|
- **Test Script API** - Validate JavaScript usage query scripts before execution
|
||||||
|
- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support
|
||||||
|
Thanks to @Sirhexs
|
||||||
|
|
||||||
|
### Custom Configuration Directory (Cloud Sync)
|
||||||
|
|
||||||
|
- **Customizable Storage Location** - Customize CC Switch's configuration storage directory
|
||||||
|
- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices
|
||||||
|
- **Independent Management** - Managed via Tauri Store for better isolation and reliability
|
||||||
|
Thanks to @ZyphrZero
|
||||||
|
|
||||||
|
### Configuration Directory Switching (WSL Support)
|
||||||
|
|
||||||
|
- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation
|
||||||
|
- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow
|
||||||
|
- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness
|
||||||
|
- **Smart Conflict Resolution** - Distinguishes "fully successful" and "partially successful" states for precise user feedback
|
||||||
|
|
||||||
|
### Configuration Editor Improvements
|
||||||
|
|
||||||
|
- **JSON Format Button** - One-click JSON formatting in configuration editors
|
||||||
|
- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting
|
||||||
|
|
||||||
|
### Load Live Config When Editing
|
||||||
|
|
||||||
|
- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files
|
||||||
|
- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones
|
||||||
|
|
||||||
|
### Claude Configuration Data Structure Enhancements
|
||||||
|
|
||||||
|
- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation
|
||||||
|
- New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`
|
||||||
|
- Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration
|
||||||
|
- Backend normalizes old configs on first read/write with smart fallback chain
|
||||||
|
- UI expanded from 2 to 4 model input fields with intelligent defaults
|
||||||
|
- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`
|
||||||
|
- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)
|
||||||
|
- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management
|
||||||
|
- **Visual Theme Configuration** - Custom icons and colors for provider cards
|
||||||
|
|
||||||
|
### Updated Provider Models
|
||||||
|
|
||||||
|
- **Kimi k2** - Updated to latest `kimi-k2-thinking` model
|
||||||
|
|
||||||
|
### New Provider Presets
|
||||||
|
|
||||||
|
Added 5 new provider presets:
|
||||||
|
|
||||||
|
- **DMXAPI** - Multi-model aggregation service
|
||||||
|
- **Azure Codex** - Microsoft Azure OpenAI endpoint
|
||||||
|
- **AnyRouter** - None-profit routing service
|
||||||
|
- **AiHubMix** - Multi-model aggregation service
|
||||||
|
- **MiniMax** - Open source AI model provider
|
||||||
|
|
||||||
|
### Partner Promotion Mechanism
|
||||||
|
|
||||||
|
- Support for ecosystem partner promotion (Zhipu GLM Z.ai)
|
||||||
|
- Sponsored banner integration in README
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Configuration & Sync
|
||||||
|
|
||||||
|
- **Unified Error Handling** - AppError with internationalized error messages throughout backend
|
||||||
|
- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution
|
||||||
|
- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures
|
||||||
|
- **Import Config Sync** - Fixed sync issues after configuration import
|
||||||
|
- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss
|
||||||
|
|
||||||
|
### UI/UX Enhancements
|
||||||
|
|
||||||
|
- **Unique Provider Icons** - Each provider card now has unique icons and color identification
|
||||||
|
- **Unified Border System** - Consistent border design across all components
|
||||||
|
- **Drag Interaction** - Push effect animation and improved drag handle icons
|
||||||
|
- **Enhanced Visual Feedback** - Better current provider visual indication
|
||||||
|
- **Dialog Standardization** - Unified dialog sizes and layout consistency
|
||||||
|
- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints
|
||||||
|
- **Usage Display Inline** - Usage info moved next to enable button for better space utilization
|
||||||
|
|
||||||
|
### Complete Internationalization
|
||||||
|
|
||||||
|
- **Error Messages i18n** - All backend error messages support Chinese/English
|
||||||
|
- **Tray Menu i18n** - System tray menu fully internationalized
|
||||||
|
- **UI Components i18n** - 100% coverage across all user-facing components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
- Fixed `apiKeyUrl` priority issue
|
||||||
|
- Fixed MCP sync-to-other-side functionality failure
|
||||||
|
- Fixed sync issues after config import
|
||||||
|
- Fixed Codex API Key auto-sync
|
||||||
|
- Fixed endpoint speed test functionality
|
||||||
|
- Fixed provider duplicate insertion position (now inserts next to original)
|
||||||
|
- Fixed custom endpoint preservation in edit mode
|
||||||
|
- Prevent silent fallback and data loss on config error
|
||||||
|
|
||||||
|
### Usage Query
|
||||||
|
|
||||||
|
- Fixed auto-query interval timing issue
|
||||||
|
- Ensured refresh button shows loading animation on click
|
||||||
|
|
||||||
|
### UI Issues
|
||||||
|
|
||||||
|
- Fixed name collision error (`get_init_error` command)
|
||||||
|
- Fixed language setting rollback after successful save
|
||||||
|
- Fixed language switch state reset (dependency cycle)
|
||||||
|
- Fixed edit mode button alignment
|
||||||
|
|
||||||
|
### Startup Issues
|
||||||
|
|
||||||
|
- Force exit on config error (no silent fallback)
|
||||||
|
- Eliminated code duplication causing initialization errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Refactoring
|
||||||
|
|
||||||
|
### Backend (Rust) - 5 Phase Refactoring
|
||||||
|
|
||||||
|
1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||||
|
2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
|
3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||||
|
4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
||||||
|
5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||||
|
|
||||||
|
### Frontend (React + TypeScript) - 4 Stage Refactoring
|
||||||
|
|
||||||
|
1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
||||||
|
2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||||
|
3. **Stage 3**: Component splitting and business logic extraction
|
||||||
|
4. **Stage 4**: Code cleanup and formatting unification
|
||||||
|
|
||||||
|
### Testing System
|
||||||
|
|
||||||
|
- **Hooks Unit Tests** - 100% coverage for all custom hooks
|
||||||
|
- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)
|
||||||
|
- **MSW Mocking** - Backend API mocking to ensure test independence
|
||||||
|
- **Test Infrastructure** - vitest + MSW + @testing-library/react
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)
|
||||||
|
- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics
|
||||||
|
- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait
|
||||||
|
- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase
|
||||||
|
- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal Optimizations (User Transparent)
|
||||||
|
|
||||||
|
### Removed Legacy Migration Logic
|
||||||
|
|
||||||
|
v3.6.0 removed v1 config auto-migration and copy file scanning logic:
|
||||||
|
|
||||||
|
- **Impact**: Improved startup performance, cleaner codebase
|
||||||
|
- **Compatibility**: v2 format configs fully compatible, no action required
|
||||||
|
- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0
|
||||||
|
|
||||||
|
### Command Parameter Standardization
|
||||||
|
|
||||||
|
Backend unified to use `app` parameter (values: `claude` or `codex`):
|
||||||
|
|
||||||
|
- **Impact**: More standardized code, friendlier error prompts
|
||||||
|
- **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Updated to **Tauri 2.8.x**
|
||||||
|
- Updated to **TailwindCSS 4.x**
|
||||||
|
- Updated to **TanStack Query v5.90.x**
|
||||||
|
- Maintained **React 18.2.x** and **TypeScript 5.3.x**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 About CC Switch
|
||||||
|
|
||||||
|
CC Switch is a cross-platform desktop application for managing and switching between different provider configurations for Claude Code and Codex. Built with Tauri 2.0 + React 18 + TypeScript, supporting Windows, macOS, and Linux.
|
||||||
|
|
||||||
|
**Core Features**:
|
||||||
|
- 🔄 One-click switching between multiple AI providers
|
||||||
|
- 📦 Support for both Claude Code and Codex applications
|
||||||
|
- 🎨 Modern UI with complete Chinese/English internationalization
|
||||||
|
- 🔐 Local storage, secure and reliable data
|
||||||
|
- ☁️ Support for cloud sync configurations
|
||||||
|
- 🧩 Unified MCP server management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Project Repository**: https://github.com/farion1231/cc-switch
|
||||||
389
docs/release-note-v3.6.1-zh.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# CC Switch v3.6.1
|
||||||
|
|
||||||
|
> 稳定性提升与用户体验优化(基于 v3.6.0)
|
||||||
|
|
||||||
|
**[English Version →](../release-note-v3.6.1.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 v3.6.1 新增内容 (2025-11-10)
|
||||||
|
|
||||||
|
本次更新主要聚焦于**用户体验优化**和**配置解析健壮性**,修复了多个关键 Bug,并增强了用量查询系统。
|
||||||
|
|
||||||
|
### ✨ 新增功能
|
||||||
|
|
||||||
|
#### 用量查询系统增强
|
||||||
|
|
||||||
|
- **凭证解耦** - 用量查询可使用独立的 API Key 和 Base URL,不再依赖供应商配置
|
||||||
|
- 支持不同的查询端点和认证方式
|
||||||
|
- 根据模板类型自动显示对应的凭证输入框
|
||||||
|
- General 模板:API Key + Base URL
|
||||||
|
- NewAPI 模板:Base URL + Access Token + User ID
|
||||||
|
- Custom 模板:完全自定义
|
||||||
|
- **UI 组件升级** - 使用 shadcn/ui Switch 替代原生 checkbox,体验更现代
|
||||||
|
- **表单统一化** - 统一使用 shadcn/ui 输入组件,样式与应用保持一致
|
||||||
|
- **密码显示切换** - 添加查看/隐藏密码功能(API Key、Access Token)
|
||||||
|
|
||||||
|
#### 表单验证基础设施
|
||||||
|
|
||||||
|
- **通用 Schema 库** - 新增 JSON/TOML 通用验证器,减少重复代码
|
||||||
|
- `jsonConfigSchema`:通用 JSON 对象验证器
|
||||||
|
- `tomlConfigSchema`:通用 TOML 格式验证器
|
||||||
|
- `mcpJsonConfigSchema`:MCP 专用 JSON 验证器
|
||||||
|
- **MCP 条件字段验证** - 严格的类型检查
|
||||||
|
- stdio 类型强制要求 `command` 字段
|
||||||
|
- http 类型强制要求 `url` 字段
|
||||||
|
|
||||||
|
#### 合作伙伴集成
|
||||||
|
|
||||||
|
- **PackyCode** - 新增官方合作伙伴
|
||||||
|
- 添加到 Claude 和 Codex 供应商预设
|
||||||
|
- 支持 10% 折扣优惠(促销信息集成)
|
||||||
|
- 新增 Logo 和合作伙伴标识
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔧 改进优化
|
||||||
|
|
||||||
|
#### 用户体验
|
||||||
|
|
||||||
|
- **拖拽排序同步** - 托盘菜单顺序实时同步拖拽排序结果
|
||||||
|
- **错误通知增强** - 切换供应商失败时显示可复制的错误信息
|
||||||
|
- **移除误导性占位符** - 删除模型输入框的示例文本,避免用户混淆
|
||||||
|
- **Base URL 自动填充** - 所有非官方供应商类别自动填充 Base URL 输入框
|
||||||
|
|
||||||
|
#### 配置解析
|
||||||
|
|
||||||
|
- **中文引号规范化** - 自动处理 IME 输入的全角引号,防止 TOML 解析错误
|
||||||
|
- 支持中文引号(" " ' ')自动转换为 ASCII 引号
|
||||||
|
- 在 TOML 输入处理器中应用
|
||||||
|
- Textarea 组件禁用浏览器自动纠正
|
||||||
|
- **自定义字段保留** - 编辑 Codex MCP TOML 配置时保留未知字段
|
||||||
|
- 支持 timeout_ms、retry_count 等扩展字段
|
||||||
|
- 向前兼容未来的 MCP 协议扩展
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🐛 Bug 修复
|
||||||
|
|
||||||
|
#### 关键修复
|
||||||
|
|
||||||
|
- **修复用量脚本面板白屏崩溃** - FormLabel 组件缺少 FormField context 导致整个应用崩溃
|
||||||
|
- 替换为独立的 Label 组件
|
||||||
|
- 根本原因:FormLabel 内部调用 useFormField() hook 需要 FormFieldContext
|
||||||
|
- **修复中文输入法引号解析失败** - IME 输入的全角引号导致 TOML 解析错误
|
||||||
|
- 新增 textNormalization 工具函数
|
||||||
|
- 在解析前自动规范化引号
|
||||||
|
- **修复拖拽排序托盘不同步** (#179) - 拖拽排序后托盘菜单顺序未更新
|
||||||
|
- 在排序完成后自动调用 updateTrayMenu
|
||||||
|
- 确保 UI 和托盘菜单保持一致
|
||||||
|
- **修复 MCP 自定义字段丢失** - 编辑 Codex MCP 配置时自定义字段被静默丢弃
|
||||||
|
- 使用 spread 操作符保留所有字段
|
||||||
|
- normalizeServerConfig 中保留未知字段
|
||||||
|
|
||||||
|
#### 稳定性改进
|
||||||
|
|
||||||
|
- **错误隔离** - 托盘菜单更新失败不再影响主操作流程
|
||||||
|
- 将托盘更新错误与主操作解耦
|
||||||
|
- 主操作成功但托盘更新失败时给出警告
|
||||||
|
- **安全模式匹配** - 替换 `unwrap()` 为安全的 pattern matching
|
||||||
|
- 避免 panic 导致应用崩溃
|
||||||
|
- 托盘菜单事件处理使用 match 模式
|
||||||
|
- **导入配置分类** - 从默认配置导入时自动设置 category 为 `custom`
|
||||||
|
- 避免导入的配置被误认为官方预设
|
||||||
|
- 提供更清晰的配置来源标识
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 技术统计
|
||||||
|
|
||||||
|
```
|
||||||
|
提交数: 17 commits
|
||||||
|
代码变更: 31 个文件
|
||||||
|
- 新增: 1,163 行
|
||||||
|
- 删除: 811 行
|
||||||
|
- 净增长: +352 行
|
||||||
|
贡献者: Jason (16), ZyphrZero (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**按模块分类**:
|
||||||
|
- UI/用户界面:3 commits
|
||||||
|
- 用量查询系统:3 commits
|
||||||
|
- 配置解析:2 commits
|
||||||
|
- 表单验证:1 commit
|
||||||
|
- 其他改进:8 commits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📥 安装方式
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
**通过 Homebrew 安装(推荐):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动下载:**
|
||||||
|
|
||||||
|
- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.1-macOS.zip`
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告。请前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
- **安装包**:`CC-Switch-v3.6.1-Windows.msi`
|
||||||
|
- **便携版**:`CC-Switch-v3.6.1-Windows-Portable.zip`
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
- **AppImage**:`CC-Switch-v3.6.1-Linux.AppImage`
|
||||||
|
- **Debian**:`CC-Switch-v3.6.1-Linux.deb`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📚 文档
|
||||||
|
|
||||||
|
- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)
|
||||||
|
- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)
|
||||||
|
- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🙏 致谢
|
||||||
|
|
||||||
|
特别感谢:
|
||||||
|
- **智谱 AI** - 通过 GLM CODING PLAN 赞助本项目
|
||||||
|
- **PackyCode** - 新加入的官方合作伙伴
|
||||||
|
- **ZyphrZero** - 贡献托盘菜单同步修复 (#179)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 v3.6.0 完整功能回顾
|
||||||
|
|
||||||
|
> 以下内容来自 v3.6.0 (2025-11-07),帮助您了解完整的功能集
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>点击展开 v3.6.0 的详细内容 →</b></summary>
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### 编辑模式与供应商管理
|
||||||
|
|
||||||
|
- **供应商复制功能** - 一键快速复制现有供应商配置,轻松创建变体配置
|
||||||
|
- **手动排序功能** - 通过拖拽对供应商进行重新排序,带有视觉推送效果动画
|
||||||
|
- **编辑模式切换** - 显示/隐藏拖拽手柄,优化编辑体验
|
||||||
|
|
||||||
|
### 自定义端点管理
|
||||||
|
|
||||||
|
- **多端点配置** - 支持聚合类供应商的多 API 端点配置
|
||||||
|
- **端点输入可见性** - 为所有非官方供应商自动显示端点字段
|
||||||
|
|
||||||
|
### 自定义配置目录(云同步)
|
||||||
|
|
||||||
|
- **自定义存储位置** - 自定义 CC Switch 的配置存储目录
|
||||||
|
- **云同步支持** - 指定到云同步文件夹(Dropbox、OneDrive、iCloud Drive、坚果云等)即可实现跨设备配置自动同步
|
||||||
|
- **独立管理** - 通过 Tauri Store 管理,更好的隔离性和可靠性
|
||||||
|
|
||||||
|
### 使用量查询增强
|
||||||
|
|
||||||
|
- **自动刷新间隔** - 配置定时自动使用量查询,支持自定义间隔时间
|
||||||
|
- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本
|
||||||
|
- **增强模板系统** - 自定义空白模板,支持 access token 和 user ID 参数
|
||||||
|
|
||||||
|
### 配置目录切换(WSL 支持)
|
||||||
|
|
||||||
|
- **目录变更自动同步** - 切换 Claude/Codex 配置目录(如 WSL 环境)时,自动同步当前供应商到新目录,无需手动操作
|
||||||
|
- **后置同步工具** - 统一的 `postChangeSync.ts` 工具,优雅处理错误而不阻塞主流程
|
||||||
|
- **导入配置自动同步** - 配置导入后自动同步,确保立即生效
|
||||||
|
- **智能冲突解决** - 区分"完全成功"和"部分成功"状态,提供精确的用户反馈
|
||||||
|
|
||||||
|
### 配置编辑器改进
|
||||||
|
|
||||||
|
- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化
|
||||||
|
- **实时 TOML 验证** - Codex 配置的实时语法验证,带有错误高亮
|
||||||
|
|
||||||
|
### 编辑时加载 Live 配置
|
||||||
|
|
||||||
|
- **保护手动修改** - 编辑当前激活的供应商时,优先显示来自 live 文件的实际生效配置
|
||||||
|
- **双源策略** - 活动供应商自动从 live 配置加载,非活动供应商从 SSOT 加载
|
||||||
|
|
||||||
|
### Claude 配置数据结构增强
|
||||||
|
|
||||||
|
- **细粒度模型配置** - 从双键系统升级到四键系统,以匹配官方最新数据结构
|
||||||
|
- 新增字段:`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`
|
||||||
|
- 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`,支持自动迁移
|
||||||
|
- 后端在首次读写时自动规范化旧配置,带有智能回退链
|
||||||
|
- UI 从 2 个模型输入字段扩展到 4 个,具有智能默认值
|
||||||
|
- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段(除 `ANTHROPIC_AUTH_TOKEN` 外)
|
||||||
|
- **模板变量系统** - 支持动态配置替换(如 KAT-Coder 的 `ENDPOINT_ID` 参数)
|
||||||
|
- **端点候选列表** - 预定义端点列表,用于速度测试和端点管理
|
||||||
|
- **视觉主题配置** - 供应商卡片自定义图标和颜色
|
||||||
|
|
||||||
|
### 供应商模型更新
|
||||||
|
|
||||||
|
- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型
|
||||||
|
|
||||||
|
### 新增供应商预设
|
||||||
|
|
||||||
|
新增 5 个供应商预设:
|
||||||
|
|
||||||
|
- **DMXAPI** - 多模型聚合服务
|
||||||
|
- **Azure Codex** - 微软 Azure OpenAI 端点
|
||||||
|
- **AnyRouter** - API 路由服务
|
||||||
|
- **AiHubMix** - AI 模型集合
|
||||||
|
- **MiniMax** - 国产 AI 模型提供商
|
||||||
|
|
||||||
|
### 合作伙伴推广机制
|
||||||
|
|
||||||
|
- 支持生态合作伙伴推广(智谱 GLM Z.ai)
|
||||||
|
- README 中集成赞助商横幅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改进优化
|
||||||
|
|
||||||
|
### 配置与同步
|
||||||
|
|
||||||
|
- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息
|
||||||
|
- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序
|
||||||
|
- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题
|
||||||
|
- **导入配置同步** - 修复配置导入后的同步问题
|
||||||
|
- **配置错误处理** - 配置错误时强制退出,防止静默回退和数据丢失
|
||||||
|
|
||||||
|
### UI/UX 增强
|
||||||
|
|
||||||
|
- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别
|
||||||
|
- **统一边框系统** - 所有组件采用一致的边框设计
|
||||||
|
- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标
|
||||||
|
- **增强视觉反馈** - 更好的当前供应商视觉指示
|
||||||
|
- **对话框标准化** - 统一的对话框尺寸和布局一致性
|
||||||
|
- **表单改进** - 优化模型占位符,简化供应商提示,分类特定提示
|
||||||
|
- **使用量内联显示** - 使用量信息移至启用按钮旁边,更好地利用空间
|
||||||
|
|
||||||
|
### 完整国际化
|
||||||
|
|
||||||
|
- **错误消息国际化** - 所有后端错误消息支持中英文
|
||||||
|
- **托盘菜单国际化** - 系统托盘菜单完全国际化
|
||||||
|
- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
|
||||||
|
- 修复 `apiKeyUrl` 优先级问题
|
||||||
|
- 修复 MCP 同步到另一端功能失效
|
||||||
|
- 修复配置导入后的同步问题
|
||||||
|
- 修复 Codex API Key 自动同步
|
||||||
|
- 修复端点速度测试功能
|
||||||
|
- 修复供应商复制插入位置(现在插入到原供应商旁边)
|
||||||
|
- 修复编辑模式下自定义端点保留问题
|
||||||
|
- 防止配置错误时的静默回退和数据丢失
|
||||||
|
|
||||||
|
### 使用量查询
|
||||||
|
|
||||||
|
- 修复自动查询间隔时间问题
|
||||||
|
- 确保刷新按钮点击时显示加载动画
|
||||||
|
|
||||||
|
### UI 问题
|
||||||
|
|
||||||
|
- 修复名称冲突错误(`get_init_error` 命令)
|
||||||
|
- 修复保存成功后语言设置回滚
|
||||||
|
- 修复语言切换状态重置(依赖循环)
|
||||||
|
- 修复编辑模式按钮对齐
|
||||||
|
|
||||||
|
### 启动问题
|
||||||
|
|
||||||
|
- 配置错误时强制退出(不再静默回退)
|
||||||
|
- 消除导致初始化错误的代码重复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构重构
|
||||||
|
|
||||||
|
### 后端(Rust)- 5 阶段重构
|
||||||
|
|
||||||
|
1. **阶段 1**:统一错误处理(`AppError` + 国际化错误消息)
|
||||||
|
2. **阶段 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
|
3. **阶段 3**:集成测试和事务机制(配置快照 + 失败回滚)
|
||||||
|
4. **阶段 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`)
|
||||||
|
5. **阶段 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
|
||||||
|
|
||||||
|
### 前端(React + TypeScript)- 4 阶段重构
|
||||||
|
|
||||||
|
1. **阶段 1**:测试基础设施(vitest + MSW + @testing-library/react)
|
||||||
|
2. **阶段 2**:提取自定义 hooks(`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等)
|
||||||
|
3. **阶段 3**:组件拆分和业务逻辑提取
|
||||||
|
4. **阶段 4**:代码清理和格式化统一
|
||||||
|
|
||||||
|
### 测试体系
|
||||||
|
|
||||||
|
- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖
|
||||||
|
- **集成测试** - 关键流程覆盖(App、SettingsDialog、MCP 面板)
|
||||||
|
- **MSW 模拟** - 后端 API 模拟确保测试独立性
|
||||||
|
- **测试基础设施** - vitest + MSW + @testing-library/react
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase(Tauri 2 规范)
|
||||||
|
- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义
|
||||||
|
- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析
|
||||||
|
- **DRY 违规清理** - 消除整个代码库中的代码重复
|
||||||
|
- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内部优化(用户无感知)
|
||||||
|
|
||||||
|
### 移除遗留迁移逻辑
|
||||||
|
|
||||||
|
v3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑:
|
||||||
|
|
||||||
|
- **影响**:提升启动性能,代码更简洁
|
||||||
|
- **兼容性**:v2 格式配置完全兼容,无需任何操作
|
||||||
|
- **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移,然后再升级到 v3.6.0
|
||||||
|
|
||||||
|
### 命令参数标准化
|
||||||
|
|
||||||
|
后端统一使用 `app` 参数(取值:`claude` 或 `codex`):
|
||||||
|
|
||||||
|
- **影响**:代码更规范,错误提示更友好
|
||||||
|
- **兼容性**:前端已完全适配,用户无需关心此变更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖更新
|
||||||
|
|
||||||
|
- 更新到 **Tauri 2.8.x**
|
||||||
|
- 更新到 **TailwindCSS 4.x**
|
||||||
|
- 更新到 **TanStack Query v5.90.x**
|
||||||
|
- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 关于 CC Switch
|
||||||
|
|
||||||
|
CC Switch 是一个跨平台桌面应用,用于管理和切换 Claude Code 与 Codex 的不同供应商配置。基于 Tauri 2.0 + React 18 + TypeScript 构建,支持 Windows、macOS、Linux。
|
||||||
|
|
||||||
|
**核心特性**:
|
||||||
|
- 🔄 一键切换多个 AI 供应商
|
||||||
|
- 📦 支持 Claude Code 和 Codex 双应用
|
||||||
|
- 🎨 现代化 UI,完整的中英文国际化
|
||||||
|
- 🔐 本地存储,数据安全可靠
|
||||||
|
- ☁️ 支持云同步配置
|
||||||
|
- 🧩 MCP 服务器统一管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**项目地址**: https://github.com/farion1231/cc-switch
|
||||||
439
docs/release-note-v3.7.0-en.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# CC Switch v3.7.0
|
||||||
|
|
||||||
|
> From Provider Switcher to All-in-One AI CLI Management Platform
|
||||||
|
|
||||||
|
**[中文更新说明 Chinese Documentation →](release-note-v3.7.0-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.
|
||||||
|
|
||||||
|
**Release Date**: 2025-11-19
|
||||||
|
**Commits**: 85 from v3.6.0
|
||||||
|
**Code Changes**: 152 files, +18,104 / -3,732 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Gemini CLI Integration
|
||||||
|
|
||||||
|
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
|
||||||
|
|
||||||
|
**Core Capabilities**:
|
||||||
|
|
||||||
|
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
|
||||||
|
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- **Full MCP support** - Complete MCP server management for Gemini
|
||||||
|
- **Deep link integration** - Import via `ccswitch://` protocol
|
||||||
|
- **System tray** - Quick-switch from tray menu
|
||||||
|
|
||||||
|
**Provider Presets**:
|
||||||
|
|
||||||
|
- **Google Official** - OAuth authentication support
|
||||||
|
- **PackyCode** - Partner integration
|
||||||
|
- **Custom** - Full customization support
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
|
||||||
|
- Form synchronization with environment editor
|
||||||
|
- Dual-file atomic writes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 Unified Architecture
|
||||||
|
|
||||||
|
Complete refactoring of MCP management system for cross-application unification.
|
||||||
|
|
||||||
|
**Architecture Improvements**:
|
||||||
|
|
||||||
|
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
|
||||||
|
- **SSE transport** - New Server-Sent Events support
|
||||||
|
- **Smart parser** - Fault-tolerant JSON parsing
|
||||||
|
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
|
||||||
|
- **Extended fields** - Preserve custom TOML fields
|
||||||
|
|
||||||
|
**User Experience**:
|
||||||
|
|
||||||
|
- Default app selection in forms
|
||||||
|
- JSON formatter for validation
|
||||||
|
- Improved visual hierarchy
|
||||||
|
- Better error messages
|
||||||
|
|
||||||
|
**Import/Export**:
|
||||||
|
|
||||||
|
- Unified import from all three apps
|
||||||
|
- Bidirectional synchronization
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills Management System
|
||||||
|
|
||||||
|
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
|
||||||
|
|
||||||
|
**GitHub Integration**:
|
||||||
|
|
||||||
|
- Auto-scan skills from GitHub repositories
|
||||||
|
- Pre-configured repos:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - Curated collection
|
||||||
|
- `anthropics/skills` - Official Anthropic skills
|
||||||
|
- `cexll/myclaude` - Community contributions
|
||||||
|
- Add custom repositories
|
||||||
|
- Subdirectory scanning support (`skillsPath`)
|
||||||
|
|
||||||
|
**Lifecycle Management**:
|
||||||
|
|
||||||
|
- **Discover** - Auto-detect `SKILL.md` files
|
||||||
|
- **Install** - One-click to `~/.claude/skills/`
|
||||||
|
- **Uninstall** - Safe removal with tracking
|
||||||
|
- **Update** - Check for updates (infrastructure ready)
|
||||||
|
|
||||||
|
**Technical Architecture**:
|
||||||
|
|
||||||
|
- **Backend**: `SkillService` (526 lines) with GitHub API integration
|
||||||
|
- **Frontend**: SkillsPage, SkillCard, RepoManager
|
||||||
|
- **UI Components**: Badge, Card, Table (shadcn/ui)
|
||||||
|
- **State**: Persistent storage in `skills.json`
|
||||||
|
- **i18n**: 47+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts Management System
|
||||||
|
|
||||||
|
**Approximately 1,300 lines of code** - Complete system prompt management.
|
||||||
|
|
||||||
|
**Multi-Preset Management**:
|
||||||
|
|
||||||
|
- Create unlimited prompt presets
|
||||||
|
- Quick switch between presets
|
||||||
|
- One active prompt at a time
|
||||||
|
- Delete protection for active prompts
|
||||||
|
|
||||||
|
**Cross-App Support**:
|
||||||
|
|
||||||
|
- **Claude**: `~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**: `~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**: `~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown Editor**:
|
||||||
|
|
||||||
|
- Full-featured CodeMirror 6 integration
|
||||||
|
- Syntax highlighting
|
||||||
|
- Dark theme (One Dark)
|
||||||
|
- Real-time preview
|
||||||
|
|
||||||
|
**Smart Synchronization**:
|
||||||
|
|
||||||
|
- **Auto-write** - Immediately write to live files
|
||||||
|
- **Backfill protection** - Save current content before switching
|
||||||
|
- **Auto-import** - Import from live files on first launch
|
||||||
|
- **Modification protection** - Preserve manual modifications
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- **Backend**: `PromptService` (213 lines)
|
||||||
|
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
|
||||||
|
- **Hooks**: usePromptActions (152 lines)
|
||||||
|
- **i18n**: 41+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deep Link Protocol (ccswitch://)
|
||||||
|
|
||||||
|
One-click provider configuration import via URL scheme.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Protocol registration on all platforms
|
||||||
|
- Import from shared links
|
||||||
|
- Lifecycle integration
|
||||||
|
- Security validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Environment Variable Conflict Detection
|
||||||
|
|
||||||
|
Intelligent detection and management of configuration conflicts.
|
||||||
|
|
||||||
|
**Detection Scope**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - Cross-app conflicts
|
||||||
|
- **Gemini** - Auto-discovery
|
||||||
|
- **MCP** - Server configuration conflicts
|
||||||
|
|
||||||
|
**Management Features**:
|
||||||
|
|
||||||
|
- Visual conflict indicators
|
||||||
|
- Resolution suggestions
|
||||||
|
- Override warnings
|
||||||
|
- Backup before changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Provider Management
|
||||||
|
|
||||||
|
**New Presets**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - ByteDance's DouBao
|
||||||
|
- **Kimi For Coding** - Moonshot AI
|
||||||
|
- **BaiLing** - BaiLing AI
|
||||||
|
- **Removed AnyRouter** - To avoid confusion
|
||||||
|
|
||||||
|
**Enhancements**:
|
||||||
|
|
||||||
|
- Model name configuration for Codex and Gemini
|
||||||
|
- Provider notes field for organization
|
||||||
|
- Enhanced preset metadata
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
- **Common config migration** - From localStorage to `config.json`
|
||||||
|
- **Unified persistence** - Shared across all apps
|
||||||
|
- **Auto-import** - First launch configuration import
|
||||||
|
- **Backfill priority** - Correct handling of live files
|
||||||
|
|
||||||
|
### UI/UX Improvements
|
||||||
|
|
||||||
|
**Design System**:
|
||||||
|
|
||||||
|
- **macOS native** - System-aligned color scheme
|
||||||
|
- **Window centering** - Default centered position
|
||||||
|
- **Visual polish** - Improved spacing and hierarchy
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
|
||||||
|
- **Password input** - Fixed Edge/IE reveal buttons
|
||||||
|
- **URL overflow** - Fixed card overflow
|
||||||
|
- **Error copying** - Copy-to-clipboard errors
|
||||||
|
- **Tray sync** - Real-time drag-and-drop sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Critical Fixes
|
||||||
|
|
||||||
|
- **Usage script validation** - Boundary checks
|
||||||
|
- **Gemini validation** - Relaxed constraints
|
||||||
|
- **TOML parsing** - CJK quote handling
|
||||||
|
- **MCP fields** - Custom field preservation
|
||||||
|
- **White screen** - FormLabel crash fix
|
||||||
|
|
||||||
|
### Stability
|
||||||
|
|
||||||
|
- **Tray safety** - Pattern matching instead of unwrap
|
||||||
|
- **Error isolation** - Tray failures don't block operations
|
||||||
|
- **Import classification** - Correct category assignment
|
||||||
|
|
||||||
|
### UI Fixes
|
||||||
|
|
||||||
|
- **Model placeholders** - Removed misleading hints
|
||||||
|
- **Base URL** - Auto-fill for third-party providers
|
||||||
|
- **Drag sort** - Tray menu synchronization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- Removed legacy code (~1,000 lines)
|
||||||
|
- Unified initialization structure
|
||||||
|
- Backward compatibility maintained
|
||||||
|
- Comprehensive code formatting
|
||||||
|
|
||||||
|
**Platform Compatibility**:
|
||||||
|
|
||||||
|
- Windows winreg API fix (v0.52)
|
||||||
|
- Safe pattern matching (no `unwrap()`)
|
||||||
|
- Cross-platform tray handling
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**Synchronization**:
|
||||||
|
|
||||||
|
- MCP sync across all apps
|
||||||
|
- Gemini form-editor sync
|
||||||
|
- Dual-file reading (.env + settings.json)
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
|
||||||
|
- Input boundary checks
|
||||||
|
- TOML quote normalization (CJK)
|
||||||
|
- Custom field preservation
|
||||||
|
- Enhanced error messages
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
**Type Safety**:
|
||||||
|
|
||||||
|
- Complete TypeScript coverage
|
||||||
|
- Rust type refinements
|
||||||
|
- API contract validation
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
|
||||||
|
- Simplified assertions
|
||||||
|
- Better test coverage
|
||||||
|
- Integration test updates
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
|
||||||
|
- Frontend: CodeMirror 6 packages
|
||||||
|
- winreg 0.52 (Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Changes:
|
||||||
|
- Commits: 85
|
||||||
|
- Files: 152 changed
|
||||||
|
- Additions: +18,104 lines
|
||||||
|
- Deletions: -3,732 lines
|
||||||
|
|
||||||
|
New Modules:
|
||||||
|
- Skills Management: 2,034 lines (21 files)
|
||||||
|
- Prompts Management: 1,302 lines (20 files)
|
||||||
|
- Gemini Integration: ~1,000 lines
|
||||||
|
- MCP Refactor: ~3,000 lines refactored
|
||||||
|
|
||||||
|
Code Distribution:
|
||||||
|
- Backend (Rust): ~4,500 lines new
|
||||||
|
- Frontend (React): ~3,000 lines new
|
||||||
|
- Configuration: ~1,500 lines refactored
|
||||||
|
- Tests: ~500 lines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Positioning
|
||||||
|
|
||||||
|
### From Tool to Platform
|
||||||
|
|
||||||
|
v3.7.0 represents a shift in CC Switch's positioning:
|
||||||
|
|
||||||
|
| Aspect | v3.6 | v3.7.0 |
|
||||||
|
| ----------------- | ------------------------ | ---------------------------- |
|
||||||
|
| **Identity** | Provider Switcher | AI CLI Management Platform |
|
||||||
|
| **Scope** | Configuration Management | Ecosystem Management |
|
||||||
|
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
|
||||||
|
| **Customization** | Manual editing | Visual management (Prompts) |
|
||||||
|
| **Integration** | Isolated apps | Unified management (MCP) |
|
||||||
|
|
||||||
|
### Six Pillars of AI CLI Management
|
||||||
|
|
||||||
|
1. **Configuration Management** - Provider switching and management
|
||||||
|
2. **Capability Extension** - Skills installation and lifecycle
|
||||||
|
3. **Behavior Customization** - System prompt presets
|
||||||
|
4. **Ecosystem Integration** - Deep links and sharing
|
||||||
|
5. **Multi-AI Support** - Claude/Codex/Gemini
|
||||||
|
6. **Intelligent Detection** - Conflict prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- **Windows**: Windows 10+
|
||||||
|
- **macOS**: macOS 10.15 (Catalina)+
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### Download Links
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
||||||
|
|
||||||
|
- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`
|
||||||
|
- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`
|
||||||
|
- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### From v3.6.x
|
||||||
|
|
||||||
|
**Automatic migration** - No action required, configs are fully compatible
|
||||||
|
|
||||||
|
### From v3.1.x or Earlier
|
||||||
|
|
||||||
|
**Two-step migration required**:
|
||||||
|
|
||||||
|
1. First upgrade to v3.2.x (performs one-time migration)
|
||||||
|
2. Then upgrade to v3.7.0
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Skills**: No migration needed, start fresh
|
||||||
|
- **Prompts**: Auto-import from live files on first launch
|
||||||
|
- **Gemini**: Install Gemini CLI separately if needed
|
||||||
|
- **MCP v3.7.0**: Backward compatible with previous configs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors who made this release possible:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
|
||||||
|
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
|
||||||
|
- Community members for testing and feedback
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
|
||||||
|
**Z.ai** - GLM CODING PLAN sponsor
|
||||||
|
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API relay service partner
|
||||||
|
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback & Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **Documentation**: [README](../README.md)
|
||||||
|
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
**v3.8.0 Preview** (Tentative):
|
||||||
|
|
||||||
|
- Local proxy functionality
|
||||||
|
|
||||||
|
Stay tuned for more updates!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
435
docs/release-note-v3.7.0-zh.md
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
# CC Switch v3.7.0
|
||||||
|
|
||||||
|
> 从供应商切换器到 AI CLI 一体化管理平台
|
||||||
|
|
||||||
|
**[English Version →](release-note-v3.7.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.7.0 新增六大核心功能,新增超过 18,000 行代码。
|
||||||
|
|
||||||
|
**发布日期**:2025-11-19
|
||||||
|
**提交数量**:从 v3.6.0 开始 85 个提交
|
||||||
|
**代码变更**:152 个文件,+18,104 / -3,732 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### Gemini CLI 集成
|
||||||
|
|
||||||
|
完整支持 Google Gemini CLI,成为第三个支持的应用(Claude Code、Codex、Gemini)。
|
||||||
|
|
||||||
|
**核心能力**:
|
||||||
|
|
||||||
|
- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式
|
||||||
|
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量
|
||||||
|
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
|
||||||
|
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
|
||||||
|
- **系统托盘** - 从托盘菜单快速切换
|
||||||
|
|
||||||
|
**供应商预设**:
|
||||||
|
|
||||||
|
- **Google Official** - 支持 OAuth 认证
|
||||||
|
- **PackyCode** - 合作伙伴集成
|
||||||
|
- **自定义** - 完全自定义支持
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- 新增后端模块:`gemini_config.rs`(20KB)、`gemini_mcp.rs`
|
||||||
|
- 表单与环境编辑器同步
|
||||||
|
- 双文件原子写入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 统一架构
|
||||||
|
|
||||||
|
MCP 管理系统完整重构,实现跨应用统一管理。
|
||||||
|
|
||||||
|
**架构改进**:
|
||||||
|
|
||||||
|
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
|
||||||
|
- **SSE 传输类型** - 新增 Server-Sent Events 支持
|
||||||
|
- **智能解析器** - 容错性 JSON 解析
|
||||||
|
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
|
||||||
|
- **扩展字段** - 保留自定义 TOML 字段
|
||||||
|
|
||||||
|
**用户体验**:
|
||||||
|
|
||||||
|
- 表单中的默认应用选择
|
||||||
|
- JSON 格式化器用于验证
|
||||||
|
- 改进的视觉层次
|
||||||
|
- 更好的错误消息
|
||||||
|
|
||||||
|
**导入/导出**:
|
||||||
|
|
||||||
|
- 统一从三个应用导入
|
||||||
|
- 双向同步
|
||||||
|
- 状态保持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills 管理系统
|
||||||
|
|
||||||
|
**约 2,000 行代码** - 完整的技能生态平台。
|
||||||
|
|
||||||
|
**GitHub 集成**:
|
||||||
|
|
||||||
|
- 从 GitHub 仓库自动扫描技能
|
||||||
|
- 预配置仓库:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - 精选集合
|
||||||
|
- `anthropics/skills` - Anthropic 官方技能
|
||||||
|
- `cexll/myclaude` - 社区贡献
|
||||||
|
- 添加自定义仓库
|
||||||
|
- 子目录扫描支持(`skillsPath`)
|
||||||
|
|
||||||
|
**生命周期管理**:
|
||||||
|
|
||||||
|
- **发现** - 自动检测 `SKILL.md` 文件
|
||||||
|
- **安装** - 一键安装到 `~/.claude/skills/`
|
||||||
|
- **卸载** - 安全移除并跟踪状态
|
||||||
|
- **更新** - 检查更新(基础设施已就绪)
|
||||||
|
|
||||||
|
**技术架构**:
|
||||||
|
|
||||||
|
- **后端**:`SkillService`(526 行)集成 GitHub API
|
||||||
|
- **前端**:SkillsPage、SkillCard、RepoManager
|
||||||
|
- **UI 组件**:Badge、Card、Table(shadcn/ui)
|
||||||
|
- **状态**:持久化存储在 `skills.json`
|
||||||
|
- **国际化**:47+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts 管理系统
|
||||||
|
|
||||||
|
**约 1,300 行代码** - 完整的系统提示词管理。
|
||||||
|
|
||||||
|
**多预设管理**:
|
||||||
|
|
||||||
|
- 创建无限数量的提示词预设
|
||||||
|
- 快速在预设间切换
|
||||||
|
- 同时只能激活一个提示词
|
||||||
|
- 活动提示词删除保护
|
||||||
|
|
||||||
|
**跨应用支持**:
|
||||||
|
|
||||||
|
- **Claude**:`~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**:`~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**:`~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown 编辑器**:
|
||||||
|
|
||||||
|
- 完整的 CodeMirror 6 集成
|
||||||
|
- 语法高亮
|
||||||
|
- 暗色主题(One Dark)
|
||||||
|
- 实时预览
|
||||||
|
|
||||||
|
**智能同步**:
|
||||||
|
|
||||||
|
- **自动写入** - 立即写入 live 文件
|
||||||
|
- **回填保护** - 切换前保存当前内容
|
||||||
|
- **自动导入** - 首次启动从 live 文件导入
|
||||||
|
- **修改保护** - 保留手动修改
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- **后端**:`PromptService`(213 行)
|
||||||
|
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
||||||
|
- **Hooks**:usePromptActions(152 行)
|
||||||
|
- **国际化**:41+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 深度链接协议(ccswitch://)
|
||||||
|
|
||||||
|
通过 URL 方案一键导入供应商配置。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
|
||||||
|
- 所有平台的协议注册
|
||||||
|
- 从共享链接导入
|
||||||
|
- 生命周期集成
|
||||||
|
- 安全验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 环境变量冲突检测
|
||||||
|
|
||||||
|
智能检测和管理配置冲突。
|
||||||
|
|
||||||
|
**检测范围**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - 跨应用冲突
|
||||||
|
- **Gemini** - 自动发现
|
||||||
|
- **MCP** - 服务器配置冲突
|
||||||
|
|
||||||
|
**管理功能**:
|
||||||
|
|
||||||
|
- 可视化冲突指示器
|
||||||
|
- 解决建议
|
||||||
|
- 覆盖警告
|
||||||
|
- 更改前备份
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改进优化
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
**新增预设**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - 字节跳动的豆包
|
||||||
|
- **Kimi For Coding** - 月之暗面
|
||||||
|
- **BaiLing** - 百灵 AI
|
||||||
|
- **移除 AnyRouter** - 避免误导
|
||||||
|
|
||||||
|
**增强功能**:
|
||||||
|
|
||||||
|
- Codex 和 Gemini 的模型名称配置
|
||||||
|
- 供应商备注字段用于组织
|
||||||
|
- 增强的预设元数据
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
|
||||||
|
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
|
||||||
|
- **统一持久化** - 跨所有应用共享
|
||||||
|
- **自动导入** - 首次启动配置导入
|
||||||
|
- **回填优先级** - 正确处理 live 文件
|
||||||
|
|
||||||
|
### UI/UX 改进
|
||||||
|
|
||||||
|
**设计系统**:
|
||||||
|
|
||||||
|
- **macOS 原生** - 与系统对齐的配色方案
|
||||||
|
- **窗口居中** - 默认居中位置
|
||||||
|
- **视觉优化** - 改进的间距和层次
|
||||||
|
|
||||||
|
**交互优化**:
|
||||||
|
|
||||||
|
- **密码输入** - 修复 Edge/IE 显示按钮
|
||||||
|
- **URL 溢出** - 修复卡片溢出
|
||||||
|
- **错误复制** - 可复制到剪贴板的错误
|
||||||
|
- **托盘同步** - 实时拖放同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 关键修复
|
||||||
|
|
||||||
|
- **用量脚本验证** - 边界检查
|
||||||
|
- **Gemini 验证** - 放宽约束
|
||||||
|
- **TOML 解析** - CJK 引号处理
|
||||||
|
- **MCP 字段** - 自定义字段保留
|
||||||
|
- **白屏** - FormLabel 崩溃修复
|
||||||
|
|
||||||
|
### 稳定性
|
||||||
|
|
||||||
|
- **托盘安全** - 模式匹配替代 unwrap
|
||||||
|
- **错误隔离** - 托盘失败不阻塞操作
|
||||||
|
- **导入分类** - 正确的类别分配
|
||||||
|
|
||||||
|
### UI 修复
|
||||||
|
|
||||||
|
- **模型占位符** - 移除误导性提示
|
||||||
|
- **Base URL** - 第三方供应商自动填充
|
||||||
|
- **拖拽排序** - 托盘菜单同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术改进
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- 移除遗留代码(约 1,000 行)
|
||||||
|
- 统一初始化结构
|
||||||
|
- 保持向后兼容性
|
||||||
|
- 全面的代码格式化
|
||||||
|
|
||||||
|
**平台兼容性**:
|
||||||
|
|
||||||
|
- Windows winreg API 修复(v0.52)
|
||||||
|
- 安全模式匹配(无 `unwrap()`)
|
||||||
|
- 跨平台托盘处理
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
**同步机制**:
|
||||||
|
|
||||||
|
- 跨所有应用的 MCP 同步
|
||||||
|
- Gemini 表单-编辑器同步
|
||||||
|
- 双文件读取(.env + settings.json)
|
||||||
|
|
||||||
|
**验证增强**:
|
||||||
|
|
||||||
|
- 输入边界检查
|
||||||
|
- TOML 引号规范化(CJK)
|
||||||
|
- 自定义字段保留
|
||||||
|
- 增强的错误消息
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
**类型安全**:
|
||||||
|
|
||||||
|
- 完整的 TypeScript 覆盖
|
||||||
|
- Rust 类型改进
|
||||||
|
- API 契约验证
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- 简化的断言
|
||||||
|
- 更好的测试覆盖
|
||||||
|
- 集成测试更新
|
||||||
|
|
||||||
|
**依赖项**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust:`anyhow`、`zip`、`serde_yaml`、`tempfile`
|
||||||
|
- 前端:CodeMirror 6 包
|
||||||
|
- winreg 0.52(Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术统计
|
||||||
|
|
||||||
|
```
|
||||||
|
总体变更:
|
||||||
|
- 提交数:85
|
||||||
|
- 文件数:152 个文件变更
|
||||||
|
- 新增:+18,104 行
|
||||||
|
- 删除:-3,732 行
|
||||||
|
|
||||||
|
新增模块:
|
||||||
|
- Skills 管理:2,034 行(21 个文件)
|
||||||
|
- Prompts 管理:1,302 行(20 个文件)
|
||||||
|
- Gemini 集成:约 1,000 行
|
||||||
|
- MCP 重构:约 3,000 行重构
|
||||||
|
|
||||||
|
代码分布:
|
||||||
|
- 后端(Rust):约 4,500 行新增
|
||||||
|
- 前端(React):约 3,000 行新增
|
||||||
|
- 配置:约 1,500 行重构
|
||||||
|
- 测试:约 500 行
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 战略定位
|
||||||
|
|
||||||
|
### 从工具到平台
|
||||||
|
|
||||||
|
v3.7.0 代表了 CC Switch 定位的转变:
|
||||||
|
|
||||||
|
| 方面 | v3.6 | v3.7.0 |
|
||||||
|
| -------- | -------------- | ----------------------- |
|
||||||
|
| **身份** | 供应商切换器 | AI CLI 管理平台 |
|
||||||
|
| **范围** | 配置管理 | 生态系统管理 |
|
||||||
|
| **应用** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **能力** | 切换配置 | 扩展能力(Skills) |
|
||||||
|
| **定制** | 手动编辑 | 可视化管理(Prompts) |
|
||||||
|
| **集成** | 孤立应用 | 统一管理(MCP) |
|
||||||
|
|
||||||
|
### AI CLI 管理六大支柱
|
||||||
|
|
||||||
|
1. **配置管理** - 供应商切换和管理
|
||||||
|
2. **能力扩展** - Skills 安装和生命周期
|
||||||
|
3. **行为定制** - 系统提示词预设
|
||||||
|
4. **生态集成** - 深度链接和共享
|
||||||
|
5. **多 AI 支持** - Claude/Codex/Gemini
|
||||||
|
6. **智能检测** - 冲突预防
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- **Windows**:Windows 10+
|
||||||
|
- **macOS**:macOS 10.15(Catalina)+
|
||||||
|
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### 下载链接
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
||||||
|
|
||||||
|
- **Windows**:`CC-Switch-v3.7.0-Windows.msi` 或 `-Portable.zip`
|
||||||
|
- **macOS**:`CC-Switch-v3.7.0-macOS.tar.gz` 或 `.zip`
|
||||||
|
- **Linux**:`CC-Switch-v3.7.0-Linux.AppImage` 或 `.deb`
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移说明
|
||||||
|
|
||||||
|
### 从 v3.6.x 升级
|
||||||
|
|
||||||
|
**自动迁移** - 无需任何操作,配置完全兼容
|
||||||
|
|
||||||
|
### 从 v3.1.x 或更早版本升级
|
||||||
|
|
||||||
|
**需要两步迁移**:
|
||||||
|
|
||||||
|
1. 首先升级到 v3.2.x(执行一次性迁移)
|
||||||
|
2. 然后升级到 v3.7.0
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- **Skills**:无需迁移,全新开始
|
||||||
|
- **Prompts**:首次启动时从 live 文件自动导入
|
||||||
|
- **Gemini**:需要单独安装 Gemini CLI
|
||||||
|
- **MCP v3.7.0**:与之前的配置向后兼容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
### 贡献者
|
||||||
|
|
||||||
|
感谢所有让这个版本成为可能的贡献者:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Geimini 集成实现
|
||||||
|
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
|
||||||
|
- 社区成员的测试和反馈
|
||||||
|
|
||||||
|
### 赞助商
|
||||||
|
|
||||||
|
**Z.ai** - GLM CODING PLAN 赞助商
|
||||||
|
[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API 中继服务合作伙伴
|
||||||
|
[使用 "cc-switch" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与支持
|
||||||
|
|
||||||
|
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **文档**:[README](../README_ZH.md)
|
||||||
|
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未来展望
|
||||||
|
|
||||||
|
**v3.8.0 预览**(暂定):
|
||||||
|
|
||||||
|
- 本地代理功能
|
||||||
|
|
||||||
|
敬请期待更多更新!
|
||||||
10
docs/roadmap.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- 自动升级自定义路径 ✅
|
||||||
|
- win 绿色版报毒问题 ✅
|
||||||
|
- mcp 管理器 ✅
|
||||||
|
- i18n ✅
|
||||||
|
- gemini cli
|
||||||
|
- homebrew 支持 ✅
|
||||||
|
- memory 管理
|
||||||
|
- codex 更多预设供应商
|
||||||
|
- 云同步
|
||||||
|
- 本地代理
|
||||||
863
docs/v3.7.0-unified-mcp-refactor.md
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
# v3.7.0 统一 MCP 管理重构计划
|
||||||
|
|
||||||
|
## 📋 项目概述
|
||||||
|
|
||||||
|
**目标**:将原有的按应用分离的 MCP 管理(Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
|
||||||
|
|
||||||
|
**版本**:v3.6.2 → v3.7.0
|
||||||
|
|
||||||
|
**开始时间**:2025-11-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心需求
|
||||||
|
|
||||||
|
### 原有架构(v3.6.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
|
||||||
|
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
↓ ↓ ↓
|
||||||
|
mcp.claude mcp.codex mcp.gemini
|
||||||
|
{servers} {servers} {servers}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新架构(v3.7.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ 统一 MCP 管理面板 │
|
||||||
|
│ ┌────────┬────────┬────────┬────┐ │
|
||||||
|
│ │ 服务器 │ Claude │ Codex │Gem │ │
|
||||||
|
│ ├────────┼────────┼────────┼────┤ │
|
||||||
|
│ │ mcp-1 │ ✓ │ ✓ │ │ │
|
||||||
|
│ │ mcp-2 │ ✓ │ │ ✓ │ │
|
||||||
|
│ └────────┴────────┴────────┴────┘ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
mcp.servers
|
||||||
|
{
|
||||||
|
"mcp-1": {
|
||||||
|
apps: {claude: true, codex: true, gemini: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 技术架构
|
||||||
|
|
||||||
|
### 数据结构设计
|
||||||
|
|
||||||
|
#### 新增:McpApps(应用启用状态)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpServer(统一服务器定义)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value, // 连接配置(stdio/http)
|
||||||
|
pub apps: McpApps, // 新增:标记应用到哪些客户端
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub docs: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpRoot(新旧结构并存)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpRoot {
|
||||||
|
// v3.7.0 新结构
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
// v3.6.x 旧结构(保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub claude: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移策略
|
||||||
|
|
||||||
|
```
|
||||||
|
旧配置 (v3.6.x) 新配置 (v3.7.0)
|
||||||
|
───────────────── ─────────────────
|
||||||
|
mcp: mcp:
|
||||||
|
claude: servers:
|
||||||
|
servers: mcp-fetch:
|
||||||
|
mcp-fetch: {...} → id: "mcp-fetch"
|
||||||
|
codex: server: {...}
|
||||||
|
servers: apps:
|
||||||
|
mcp-filesystem: {...} claude: true
|
||||||
|
codex: true
|
||||||
|
gemini: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移逻辑**:
|
||||||
|
1. 检测 `mcp.servers` 是否存在
|
||||||
|
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
|
||||||
|
3. 合并同 id 服务器的 apps 字段
|
||||||
|
4. 清空旧结构字段
|
||||||
|
5. 保存配置(自动触发)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 开发进度
|
||||||
|
|
||||||
|
### Phase 1: 后端数据结构与迁移 ✅ 已完成
|
||||||
|
|
||||||
|
#### 1.1 修改数据结构(app_config.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 结构体(lines 30-62)
|
||||||
|
- ✅ 新增 `McpServer` 结构体(lines 64-79)
|
||||||
|
- ✅ 更新 `McpRoot` 支持新旧结构(lines 81-96)
|
||||||
|
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 1.2 实现迁移逻辑 ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- ✅ `migrate_mcp_to_unified()` 方法(lines 380-509)
|
||||||
|
- 从旧结构收集所有服务器
|
||||||
|
- 按 id 合并重复服务器
|
||||||
|
- 处理冲突(合并 apps 字段)
|
||||||
|
- 清空旧结构
|
||||||
|
- ✅ 集成到 `MultiAppConfig::load()` 方法(lines 252-257)
|
||||||
|
- 自动检测并执行迁移
|
||||||
|
- 迁移后保存配置
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 后端服务层重构 ✅ 已完成
|
||||||
|
|
||||||
|
#### 2.1 重写 McpService ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/services/mcp.rs`
|
||||||
|
|
||||||
|
**新增方法**:
|
||||||
|
- ✅ `get_all_servers()` - 获取所有服务器(lines 13-27)
|
||||||
|
- ✅ `upsert_server()` - 添加/更新服务器(lines 30-52)
|
||||||
|
- ✅ `delete_server()` - 删除服务器(lines 55-75)
|
||||||
|
- ✅ `toggle_app()` - 切换应用启用状态(lines 78-111)
|
||||||
|
- ✅ `sync_all_enabled()` - 同步所有启用的服务器(lines 180-188)
|
||||||
|
|
||||||
|
**兼容层方法**(已废弃):
|
||||||
|
- ✅ `get_servers()` - 按应用过滤服务器(lines 196-210)
|
||||||
|
- ✅ `set_enabled()` - 委托到 toggle_app(lines 213-222)
|
||||||
|
- ✅ `sync_enabled()` - 同步特定应用(lines 225-236)
|
||||||
|
- ✅ `import_from_claude/codex/gemini()` - 导入包装(lines 239-266)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.2 新增同步函数(mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/mcp.rs`
|
||||||
|
|
||||||
|
**新增函数**(lines 800-965):
|
||||||
|
- ✅ `json_server_to_toml_table()` - JSON → TOML 转换助手(lines 828-889)
|
||||||
|
- ✅ `sync_single_server_to_claude()` - 同步单个服务器到 Claude(lines 800-814)
|
||||||
|
- ✅ `remove_server_from_claude()` - 从 Claude 移除服务器(lines 817-826)
|
||||||
|
- ✅ `sync_single_server_to_codex()` - 同步单个服务器到 Codex(lines 891-936)
|
||||||
|
- ✅ `remove_server_from_codex()` - 从 Codex 移除服务器(lines 939-965)
|
||||||
|
- ✅ `sync_single_server_to_gemini()` - 同步单个服务器到 Gemini(lines 967-977)
|
||||||
|
- ✅ `remove_server_from_gemini()` - 从 Gemini 移除服务器(lines 980-989)
|
||||||
|
|
||||||
|
**关键修复**:
|
||||||
|
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
|
||||||
|
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.3 新增 Tauri Commands ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/commands/mcp.rs`
|
||||||
|
|
||||||
|
**新增命令**(lines 147-196):
|
||||||
|
- ✅ `get_mcp_servers()` - 获取所有服务器(lines 154-159)
|
||||||
|
- ✅ `upsert_mcp_server()` - 添加/更新服务器(lines 162-168)
|
||||||
|
- ✅ `delete_mcp_server()` - 删除服务器(lines 171-177)
|
||||||
|
- ✅ `toggle_mcp_app()` - 切换应用状态(lines 180-189)
|
||||||
|
- ✅ `sync_all_mcp_servers()` - 同步所有服务器(lines 192-195)
|
||||||
|
|
||||||
|
**更新旧命令**(兼容层):
|
||||||
|
- ✅ `upsert_mcp_server_in_config()` - 转换为统一结构(lines 68-131)
|
||||||
|
- ✅ `delete_mcp_server_in_config()` - 忽略 app 参数(lines 134-141)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.4 注册新命令(lib.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 导出 `McpServer` 类型(line 21)
|
||||||
|
- ✅ 导出新增的 mcp 同步函数(lines 26-31)
|
||||||
|
- ✅ 注册 5 个新命令到 invoke_handler(lines 550-555)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.5 添加缺失的函数(claude_mcp.rs & gemini_mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
|
||||||
|
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
|
||||||
|
|
||||||
|
**新增**:
|
||||||
|
- ✅ `read_mcp_servers_map()` - 读取现有 MCP 服务器映射
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.6 编译验证 ✅
|
||||||
|
|
||||||
|
**状态**:✅ 编译成功
|
||||||
|
- ⚠️ 16 个警告(8 个废弃警告 + 8 个未使用函数警告 - 预期内)
|
||||||
|
- ✅ 0 个错误
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 前端开发 ⚠️ 部分完成
|
||||||
|
|
||||||
|
#### 3.1 TypeScript 类型定义 ✅
|
||||||
|
|
||||||
|
**文件**:`src/types.ts`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 接口(lines 129-133)
|
||||||
|
- ✅ 更新 `McpServer` 接口(lines 136-149)
|
||||||
|
- 新增 `apps: McpApps` 字段
|
||||||
|
- `name` 改为必填
|
||||||
|
- 标记 `enabled` 为废弃
|
||||||
|
- ✅ 新增 `McpServersMap` 类型别名(line 152)
|
||||||
|
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.2 API 层更新 ✅
|
||||||
|
|
||||||
|
**文件**:`src/lib/api/mcp.ts`
|
||||||
|
|
||||||
|
**新增方法**(lines 99-141):
|
||||||
|
- ✅ `getAllServers()` - 获取所有服务器(lines 106-108)
|
||||||
|
- ✅ `upsertUnifiedServer()` - 添加/更新服务器(lines 113-115)
|
||||||
|
- ✅ `deleteUnifiedServer()` - 删除服务器(lines 120-122)
|
||||||
|
- ✅ `toggleApp()` - 切换应用状态(lines 127-133)
|
||||||
|
- ✅ `syncAllServers()` - 同步所有服务器(lines 138-140)
|
||||||
|
|
||||||
|
**导入更新**:
|
||||||
|
- ✅ 导入 `McpServersMap` 类型(line 6)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.3 React Query Hooks 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/hooks/useMcp.ts`
|
||||||
|
|
||||||
|
**需要实现的 Hooks**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 查询 hooks
|
||||||
|
export function useAllMcpServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp', 'all'],
|
||||||
|
queryFn: () => mcpApi.getAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更 hooks
|
||||||
|
export function useUpsertMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleMcpApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ serverId, app, enabled }: {
|
||||||
|
serverId: string;
|
||||||
|
app: AppId;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncAllMcpServers() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => mcpApi.syncAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**:
|
||||||
|
- `@tanstack/react-query` (已安装)
|
||||||
|
- `src/lib/api/mcp.ts` (✅ 已完成)
|
||||||
|
- `src/types.ts` (✅ 已完成)
|
||||||
|
|
||||||
|
#### 3.4 统一 MCP 面板组件 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
|
||||||
|
**组件结构**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedMcpPanelProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: servers, isLoading } = useAllMcpServers();
|
||||||
|
const toggleApp = useToggleMcpApp();
|
||||||
|
const deleteServer = useDeleteMcpServer();
|
||||||
|
const syncAll = useSyncAllMcpServers();
|
||||||
|
|
||||||
|
// 组件实现...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ MCP 服务器管理 ┌──────────┐ │
|
||||||
|
│ │ 添加服务器 │ │
|
||||||
|
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
|
||||||
|
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
|
||||||
|
│ └─────┘ └──────────────┘ └─────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
|
||||||
|
│ ├─────────────────────────────────────────────┤ │
|
||||||
|
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
|
||||||
|
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
|
||||||
|
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 📋 服务器列表展示(名称、描述、标签)
|
||||||
|
- ☑️ 三个复选框控制应用启用状态(Claude/Codex/Gemini)
|
||||||
|
- ➕ 添加新服务器(表单模态框)
|
||||||
|
- ✏️ 编辑服务器(表单模态框)
|
||||||
|
- 🗑️ 删除服务器(确认对话框)
|
||||||
|
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
|
||||||
|
- 🔄 同步全部(手动触发同步到 live 配置)
|
||||||
|
- 🔍 搜索过滤
|
||||||
|
- 🏷️ 标签过滤
|
||||||
|
|
||||||
|
**子组件**:
|
||||||
|
|
||||||
|
1. **McpServerTable** (`McpServerTable.tsx`)
|
||||||
|
- 服务器列表表格
|
||||||
|
- 应用复选框
|
||||||
|
- 操作按钮(编辑、删除)
|
||||||
|
|
||||||
|
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
|
||||||
|
- 添加/编辑表单
|
||||||
|
- stdio/http 类型切换
|
||||||
|
- 应用选择(多选)
|
||||||
|
- 元信息编辑(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **McpImportDialog** (`McpImportDialog.tsx`)
|
||||||
|
- 选择导入来源(Claude/Codex/Gemini)
|
||||||
|
- 服务器预览
|
||||||
|
- 批量导入
|
||||||
|
|
||||||
|
**依赖组件**(来自 shadcn/ui):
|
||||||
|
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
||||||
|
- `Checkbox`
|
||||||
|
- `Button`
|
||||||
|
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
|
||||||
|
- `Input`, `Textarea`, `Label`
|
||||||
|
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
||||||
|
- `Badge`
|
||||||
|
- `Tooltip`
|
||||||
|
|
||||||
|
#### 3.5 主界面集成 📝 待开发
|
||||||
|
|
||||||
|
**文件**:`src/App.tsx`
|
||||||
|
|
||||||
|
**变更计划**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 原有代码(v3.6.x)
|
||||||
|
{currentApp === 'claude' && <ClaudeMcpPanel />}
|
||||||
|
{currentApp === 'codex' && <CodexMcpPanel />}
|
||||||
|
{currentApp === 'gemini' && <GeminiMcpPanel />}
|
||||||
|
|
||||||
|
// 新代码(v3.7.0)
|
||||||
|
<UnifiedMcpPanel />
|
||||||
|
```
|
||||||
|
|
||||||
|
**移除的组件**:
|
||||||
|
- `ClaudeMcpPanel.tsx`
|
||||||
|
- `CodexMcpPanel.tsx`
|
||||||
|
- `GeminiMcpPanel.tsx`
|
||||||
|
|
||||||
|
**注意**:保留旧组件文件备份,以便回滚
|
||||||
|
|
||||||
|
#### 3.6 国际化文本更新 📝 待开发
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src/locales/zh/translation.json`
|
||||||
|
- `src/locales/en/translation.json`
|
||||||
|
|
||||||
|
**需要添加的翻译键**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"unifiedPanel": {
|
||||||
|
"title": "MCP 服务器管理 / MCP Server Management",
|
||||||
|
"addServer": "添加服务器 / Add Server",
|
||||||
|
"editServer": "编辑服务器 / Edit Server",
|
||||||
|
"deleteServer": "删除服务器 / Delete Server",
|
||||||
|
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
|
||||||
|
"syncAll": "同步全部 / Sync All",
|
||||||
|
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
|
||||||
|
"importFrom": "导入自... / Import from...",
|
||||||
|
"search": "搜索服务器... / Search servers...",
|
||||||
|
"noServers": "暂无服务器 / No servers yet",
|
||||||
|
"enabledApps": "启用的应用 / Enabled Apps",
|
||||||
|
"apps": {
|
||||||
|
"claude": "Claude",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"id": "服务器 ID / Server ID",
|
||||||
|
"name": "显示名称 / Display Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"stdio": "本地进程 / Local Process",
|
||||||
|
"http": "远程服务 / Remote Service",
|
||||||
|
"command": "命令 / Command",
|
||||||
|
"args": "参数 / Arguments",
|
||||||
|
"env": "环境变量 / Environment Variables",
|
||||||
|
"cwd": "工作目录 / Working Directory",
|
||||||
|
"url": "URL",
|
||||||
|
"headers": "请求头 / Headers",
|
||||||
|
"description": "描述 / Description",
|
||||||
|
"tags": "标签 / Tags",
|
||||||
|
"homepage": "主页 / Homepage",
|
||||||
|
"docs": "文档 / Documentation",
|
||||||
|
"selectApps": "选择应用 / Select Apps",
|
||||||
|
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "名称 / Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"apps": "应用 / Apps",
|
||||||
|
"actions": "操作 / Actions",
|
||||||
|
"edit": "编辑 / Edit",
|
||||||
|
"delete": "删除 / Delete"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "导入 MCP 服务器 / Import MCP Servers",
|
||||||
|
"fromClaude": "从 Claude 导入 / Import from Claude",
|
||||||
|
"fromCodex": "从 Codex 导入 / Import from Codex",
|
||||||
|
"fromGemini": "从 Gemini 导入 / Import from Gemini",
|
||||||
|
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
|
||||||
|
"noServersFound": "未找到可导入的服务器 / No servers found to import"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 迁移流程
|
||||||
|
|
||||||
|
### 用户体验
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户升级到 v3.7.0
|
||||||
|
↓
|
||||||
|
2. 首次启动应用
|
||||||
|
↓
|
||||||
|
3. 后端自动执行迁移
|
||||||
|
- 检测旧结构 (mcp.claude/codex/gemini.servers)
|
||||||
|
- 合并到统一结构 (mcp.servers)
|
||||||
|
- 保存迁移后的配置
|
||||||
|
- 日志记录迁移详情
|
||||||
|
↓
|
||||||
|
4. 前端加载新面板
|
||||||
|
- 显示所有服务器
|
||||||
|
- 三个复选框显示各应用启用状态
|
||||||
|
↓
|
||||||
|
5. 用户无缝使用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据完整性保证
|
||||||
|
|
||||||
|
1. **迁移前验证**:
|
||||||
|
- ✅ 校验旧结构合法性
|
||||||
|
- ✅ 记录迁移前状态
|
||||||
|
|
||||||
|
2. **迁移中处理**:
|
||||||
|
- ✅ 合并同 id 服务器的 apps 字段
|
||||||
|
- ✅ 处理 id 冲突(保留第一个,记录警告)
|
||||||
|
- ✅ 保留所有元信息(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **迁移后清理**:
|
||||||
|
- ✅ 清空旧结构(claude/codex/gemini)
|
||||||
|
- ✅ 自动保存新配置
|
||||||
|
- ✅ 日志记录迁移完成
|
||||||
|
|
||||||
|
4. **回滚机制**:
|
||||||
|
- 配置文件有备份(`config.v1.backup.<timestamp>.json`)
|
||||||
|
- 迁移失败时可手动回滚
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试计划
|
||||||
|
|
||||||
|
### 后端测试 ✅ 已验证
|
||||||
|
|
||||||
|
- [x] 编译测试(cargo check)
|
||||||
|
- [x] 数据结构序列化/反序列化
|
||||||
|
- [ ] 迁移逻辑单元测试
|
||||||
|
- [ ] 服务层方法测试
|
||||||
|
- [ ] 同步函数测试
|
||||||
|
|
||||||
|
### 前端测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] TypeScript 类型检查
|
||||||
|
- [ ] API 调用测试
|
||||||
|
- [ ] 组件渲染测试
|
||||||
|
- [ ] 用户交互测试
|
||||||
|
- [ ] 国际化文本检查
|
||||||
|
|
||||||
|
### 集成测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] 完整迁移流程测试
|
||||||
|
- [ ] 从空配置启动
|
||||||
|
- [ ] 从 v3.6.x 配置升级
|
||||||
|
- [ ] 多服务器合并场景
|
||||||
|
- [ ] 冲突处理验证
|
||||||
|
- [ ] 多应用同步测试
|
||||||
|
- [ ] 启用单个应用
|
||||||
|
- [ ] 启用多个应用
|
||||||
|
- [ ] 动态切换应用
|
||||||
|
- [ ] 同步到 live 配置验证
|
||||||
|
- [ ] 边界情况测试
|
||||||
|
- [ ] 空服务器列表
|
||||||
|
- [ ] 超长服务器名称
|
||||||
|
- [ ] 特殊字符处理
|
||||||
|
- [ ] 并发操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 交付清单
|
||||||
|
|
||||||
|
### 代码文件
|
||||||
|
|
||||||
|
#### 后端(Rust)✅ 已完成
|
||||||
|
|
||||||
|
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
|
||||||
|
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
|
||||||
|
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
|
||||||
|
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
|
||||||
|
- [x] `src-tauri/src/lib.rs` - 命令注册
|
||||||
|
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
|
||||||
|
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
|
||||||
|
|
||||||
|
#### 前端(TypeScript/React)⚠️ 部分完成
|
||||||
|
|
||||||
|
- [x] `src/types.ts` - 类型定义更新
|
||||||
|
- [x] `src/lib/api/mcp.ts` - API 层更新
|
||||||
|
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
|
||||||
|
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
|
||||||
|
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
|
||||||
|
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
|
||||||
|
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
|
||||||
|
- [ ] `src/App.tsx` - 主界面集成
|
||||||
|
- [ ] `src/locales/zh/translation.json` - 中文翻译
|
||||||
|
- [ ] `src/locales/en/translation.json` - 英文翻译
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
|
||||||
|
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
|
||||||
|
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
|
||||||
|
|
||||||
|
### Git 提交记录 ✅
|
||||||
|
|
||||||
|
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
|
||||||
|
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
|
||||||
|
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步行动
|
||||||
|
|
||||||
|
### 立即任务(优先级 P0)
|
||||||
|
|
||||||
|
1. ⬜ **实现 useMcp Hook**
|
||||||
|
- 文件:`src/hooks/useMcp.ts`
|
||||||
|
- 估时:1-2 小时
|
||||||
|
- 依赖:API 层(已完成)
|
||||||
|
|
||||||
|
2. ⬜ **创建 UnifiedMcpPanel 核心组件**
|
||||||
|
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
- 估时:3-4 小时
|
||||||
|
- 依赖:useMcp Hook
|
||||||
|
|
||||||
|
3. ⬜ **添加国际化文本**
|
||||||
|
- 文件:`src/locales/{zh,en}/translation.json`
|
||||||
|
- 估时:30 分钟
|
||||||
|
|
||||||
|
4. ⬜ **集成到主界面**
|
||||||
|
- 文件:`src/App.tsx`
|
||||||
|
- 估时:30 分钟
|
||||||
|
- 依赖:UnifiedMcpPanel 组件
|
||||||
|
|
||||||
|
### 次要任务(优先级 P1)
|
||||||
|
|
||||||
|
5. ⬜ **实现子组件**
|
||||||
|
- McpServerTable
|
||||||
|
- McpServerFormModal
|
||||||
|
- McpImportDialog
|
||||||
|
- 估时:4-6 小时
|
||||||
|
|
||||||
|
6. ⬜ **编写测试用例**
|
||||||
|
- 后端单元测试
|
||||||
|
- 前端组件测试
|
||||||
|
- 集成测试
|
||||||
|
- 估时:6-8 小时
|
||||||
|
|
||||||
|
7. ⬜ **编写用户文档**
|
||||||
|
- 升级指南
|
||||||
|
- API 变更说明
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
### 优化任务(优先级 P2)
|
||||||
|
|
||||||
|
8. ⬜ **性能优化**
|
||||||
|
- 服务器列表虚拟滚动
|
||||||
|
- 批量操作优化
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
9. ⬜ **用户体验增强**
|
||||||
|
- 添加加载状态
|
||||||
|
- 添加错误提示
|
||||||
|
- 添加操作确认
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
10. ⬜ **代码清理**
|
||||||
|
- 移除旧的分应用面板组件
|
||||||
|
- 清理废弃代码
|
||||||
|
- 代码格式化
|
||||||
|
- 估时:1-2 小时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 技术亮点
|
||||||
|
|
||||||
|
### 1. 平滑迁移机制
|
||||||
|
|
||||||
|
- ✅ 自动检测旧配置并迁移
|
||||||
|
- ✅ 新旧结构并存(过渡期)
|
||||||
|
- ✅ 无需用户手动操作
|
||||||
|
- ✅ 保留所有历史数据
|
||||||
|
|
||||||
|
### 2. 向后兼容
|
||||||
|
|
||||||
|
- ✅ 旧命令继续可用(带废弃警告)
|
||||||
|
- ✅ 前端可增量更新
|
||||||
|
- ✅ 渐进式重构策略
|
||||||
|
|
||||||
|
### 3. 类型安全
|
||||||
|
|
||||||
|
- ✅ Rust 强类型保证数据完整性
|
||||||
|
- ✅ TypeScript 类型定义与后端一致
|
||||||
|
- ✅ serde 序列化/反序列化自动处理
|
||||||
|
|
||||||
|
### 4. 清晰的架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (React)
|
||||||
|
↓ (Tauri IPC)
|
||||||
|
Commands Layer
|
||||||
|
↓
|
||||||
|
Services Layer
|
||||||
|
↓
|
||||||
|
Data Layer (Config + Live Sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. SSOT 原则
|
||||||
|
|
||||||
|
- 单一配置源:`~/.cc-switch/config.json`
|
||||||
|
- 统一管理:`mcp.servers` 字段
|
||||||
|
- 按需同步:写入各应用 live 配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资源
|
||||||
|
|
||||||
|
### 内部文档
|
||||||
|
|
||||||
|
- [项目 README](../README.md)
|
||||||
|
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
|
||||||
|
- [架构文档](../CLAUDE.md#架构概述)
|
||||||
|
|
||||||
|
### 相关 Issues/PRs
|
||||||
|
|
||||||
|
- 无(新功能开发)
|
||||||
|
|
||||||
|
### 技术栈文档
|
||||||
|
|
||||||
|
- [Tauri 2.0](https://tauri.app/v1/guides/)
|
||||||
|
- [React 18](https://react.dev/)
|
||||||
|
- [TanStack Query](https://tanstack.com/query/latest)
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/)
|
||||||
|
- [serde](https://serde.rs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 变更日志
|
||||||
|
|
||||||
|
### 2025-11-14
|
||||||
|
|
||||||
|
- ✅ 完成后端 Phase 1 & 2(数据结构、服务层、命令层)
|
||||||
|
- ✅ 修复所有编译错误
|
||||||
|
- ✅ 完成前端类型定义和 API 层
|
||||||
|
- ✅ 创建本重构计划文档
|
||||||
|
|
||||||
|
### 待更新...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 团队协作
|
||||||
|
|
||||||
|
**开发者**:Claude Code (AI Assistant) + User
|
||||||
|
|
||||||
|
**审查者**:User
|
||||||
|
|
||||||
|
**测试者**:User
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 风险与对策
|
||||||
|
|
||||||
|
### 风险 1:迁移数据丢失
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:高
|
||||||
|
**对策**:
|
||||||
|
- ✅ 迁移前自动备份配置
|
||||||
|
- ✅ 详细日志记录
|
||||||
|
- ✅ 测试各种边界情况
|
||||||
|
|
||||||
|
### 风险 2:性能问题(大量服务器)
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 实现虚拟滚动
|
||||||
|
- ⬜ 分页或懒加载
|
||||||
|
- ⬜ 性能测试
|
||||||
|
|
||||||
|
### 风险 3:兼容性问题
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ✅ 保留旧命令兼容层
|
||||||
|
- ✅ 前端增量更新
|
||||||
|
- ⬜ 多版本测试
|
||||||
|
|
||||||
|
### 风险 4:用户学习成本
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:低
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 清晰的 UI 设计
|
||||||
|
- ⬜ 详细的升级指南
|
||||||
|
- ⬜ 操作提示和引导
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 预期收益
|
||||||
|
|
||||||
|
### 用户体验提升
|
||||||
|
|
||||||
|
- ⭐ **简化操作**:不再需要在不同应用面板切换
|
||||||
|
- ⭐ **统一视图**:一目了然看到所有 MCP 配置
|
||||||
|
- ⭐ **灵活配置**:轻松控制每个 MCP 应用到哪些客户端
|
||||||
|
|
||||||
|
### 代码质量提升
|
||||||
|
|
||||||
|
- ⭐ **架构优化**:统一数据源,消除冗余
|
||||||
|
- ⭐ **维护性**:单一面板组件,代码更简洁
|
||||||
|
- ⭐ **扩展性**:未来添加新应用(如 Cursor)更容易
|
||||||
|
|
||||||
|
### 性能提升
|
||||||
|
|
||||||
|
- ⭐ **减少重复加载**:统一管理减少配置文件读写
|
||||||
|
- ⭐ **更快同步**:批量操作更高效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
**问题反馈**:[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
|
||||||
|
|
||||||
|
**功能建议**:[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**最后更新**:2025-11-14
|
||||||
|
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)
|
||||||
61
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.1.1",
|
"version": "3.7.0",
|
||||||
"description": "Claude Code & Codex 供应商切换工具",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
"build": "pnpm tauri build",
|
"build": "pnpm tauri build",
|
||||||
@@ -10,24 +10,73 @@
|
|||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest watch"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Jason Young",
|
"author": "Jason Young",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.8.0",
|
"@tauri-apps/cli": "^2.8.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"cross-fetch": "^4.1.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"msw": "^2.11.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lint": "^6.8.5",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.38.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-store": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"i18next": "^25.5.2",
|
||||||
|
"jsonc-parser": "^3.2.1",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
}
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-i18next": "^16.0.0",
|
||||||
|
"smol-toml": "^1.4.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
3274
pnpm-lock.yaml
generated
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages: []
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 247 KiB |
1955
src-tauri/Cargo.lock
generated
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.1.1"
|
version = "3.7.0"
|
||||||
description = "Claude Code & Codex 供应商配置管理工具"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/jasonyoung/cc-switch"
|
repository = "https://github.com/farion1231/cc-switch"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
rust-version = "1.85.0"
|
rust-version = "1.85.0"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -14,6 +14,10 @@ rust-version = "1.85.0"
|
|||||||
name = "cc_switch_lib"
|
name = "cc_switch_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
test-hooks = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.4.0", features = [] }
|
tauri-build = { version = "2.4.0", features = [] }
|
||||||
|
|
||||||
@@ -21,12 +25,48 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.8.2", features = [] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
toml_edit = "0.22"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
futures = "0.3"
|
||||||
|
regex = "1.10"
|
||||||
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
zip = "2.2"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tempfile = "3"
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
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"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serial_test = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
19
src-tauri/Info.plist
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>CC Switch Deep Link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ccswitch</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"updater:default",
|
||||||
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"process:allow-restart",
|
||||||
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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 |
@@ -1,15 +1,151 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::services::skill::SkillStore;
|
||||||
|
|
||||||
|
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpApps {
|
||||||
|
/// 检查指定应用是否启用
|
||||||
|
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude,
|
||||||
|
AppType::Codex => self.codex,
|
||||||
|
AppType::Gemini => self.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的启用状态
|
||||||
|
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = enabled,
|
||||||
|
AppType::Codex => self.codex = enabled,
|
||||||
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有启用的应用列表
|
||||||
|
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
if self.claude {
|
||||||
|
apps.push(AppType::Claude);
|
||||||
|
}
|
||||||
|
if self.codex {
|
||||||
|
apps.push(AppType::Codex);
|
||||||
|
}
|
||||||
|
if self.gemini {
|
||||||
|
apps.push(AppType::Gemini);
|
||||||
|
}
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有应用都未启用
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
!self.claude && !self.codex && !self.gemini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value,
|
||||||
|
pub apps: McpApps,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub docs: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpConfig {
|
||||||
|
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpConfig {
|
||||||
|
/// 检查配置是否为空
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.servers.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 根配置(v3.7.0 新旧结构并存)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpRoot {
|
||||||
|
/// 统一的 MCP 服务器存储(v3.7.0+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub claude: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for McpRoot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// v3.7.0+ 默认使用新的统一结构(空 HashMap)
|
||||||
|
servers: Some(HashMap::new()),
|
||||||
|
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
||||||
|
claude: McpConfig::default(),
|
||||||
|
codex: McpConfig::default(),
|
||||||
|
gemini: McpConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 配置:单客户端维度
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 根:按客户端分开维护
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptRoot {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: PromptConfig,
|
||||||
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::prompt_files::prompt_file_path;
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
/// 应用类型
|
/// 应用类型
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
|
Gemini, // 新增
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -17,15 +153,58 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
|
AppType::Gemini => "gemini", // 新增
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&str> for AppType {
|
impl FromStr for AppType {
|
||||||
fn from(s: &str) -> Self {
|
type Err = AppError;
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"codex" => AppType::Codex,
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
_ => AppType::Claude, // 默认为 Claude
|
let normalized = s.trim().to_lowercase();
|
||||||
|
match normalized.as_str() {
|
||||||
|
"claude" => Ok(AppType::Claude),
|
||||||
|
"codex" => Ok(AppType::Codex),
|
||||||
|
"gemini" => Ok(AppType::Gemini), // 新增
|
||||||
|
other => Err(AppError::localized(
|
||||||
|
"unsupported_app",
|
||||||
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||||
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CommonConfigSnippets {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codex: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonConfigSnippets {
|
||||||
|
/// 获取指定应用的通用配置片段
|
||||||
|
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude.as_ref(),
|
||||||
|
AppType::Codex => self.codex.as_ref(),
|
||||||
|
AppType::Gemini => self.gemini.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的通用配置片段
|
||||||
|
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = snippet,
|
||||||
|
AppType::Codex => self.codex = snippet,
|
||||||
|
AppType::Gemini => self.gemini = snippet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,8 +214,24 @@ impl From<&str> for AppType {
|
|||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
#[serde(default = "default_version")]
|
#[serde(default = "default_version")]
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
|
/// 应用管理器(claude/codex)
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub apps: HashMap<String, ProviderManager>,
|
pub apps: HashMap<String, ProviderManager>,
|
||||||
|
/// MCP 配置(按客户端分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp: McpRoot,
|
||||||
|
/// Prompt 配置(按客户端分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: PromptRoot,
|
||||||
|
/// Claude Skills 配置
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: SkillStore,
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
|
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude_common_config_snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -48,66 +243,139 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||||
|
|
||||||
Self { version: 2, apps }
|
Self {
|
||||||
|
version: 2,
|
||||||
|
apps,
|
||||||
|
mcp: McpRoot::default(),
|
||||||
|
prompts: PromptRoot::default(),
|
||||||
|
skills: SkillStore::default(),
|
||||||
|
common_config_snippets: CommonConfigSnippets::default(),
|
||||||
|
claude_common_config_snippet: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiAppConfig {
|
impl MultiAppConfig {
|
||||||
/// 从文件加载配置(处理v1到v2的迁移)
|
/// 从文件加载配置(仅支持 v2 结构)
|
||||||
pub fn load() -> Result<Self, String> {
|
pub fn load() -> Result<Self, AppError> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
log::info!("配置文件不存在,创建新的多应用配置");
|
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
||||||
return Ok(Self::default());
|
// 使用新的方法,支持自动导入提示词
|
||||||
}
|
let config = Self::default_with_auto_import()?;
|
||||||
|
// 立即保存到磁盘
|
||||||
// 尝试读取文件
|
|
||||||
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()?;
|
config.save()?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取v2格式
|
// 尝试读取文件
|
||||||
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
let content =
|
||||||
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
||||||
|
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
|
let is_v1 = value.as_object().is_some_and(|map| {
|
||||||
|
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
|
||||||
|
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
|
||||||
|
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
||||||
|
let has_apps = map.contains_key("apps");
|
||||||
|
has_providers && has_current && !has_apps
|
||||||
|
});
|
||||||
|
if is_v1 {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"config.unsupported_v1",
|
||||||
|
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||||||
|
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_skills_in_config = value
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|map| map.contains_key("skills"));
|
||||||
|
|
||||||
|
// 解析 v2 结构
|
||||||
|
let mut config: Self =
|
||||||
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
|
let mut updated = false;
|
||||||
|
|
||||||
|
if !has_skills_in_config {
|
||||||
|
let skills_path = get_app_config_dir().join("skills.json");
|
||||||
|
if skills_path.exists() {
|
||||||
|
match std::fs::read_to_string(&skills_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||||
|
Ok(store) => {
|
||||||
|
config.skills = store;
|
||||||
|
updated = true;
|
||||||
|
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||||
|
if !config.apps.contains_key("gemini") {
|
||||||
|
config
|
||||||
|
.apps
|
||||||
|
.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
||||||
|
let migrated = config.migrate_mcp_to_unified()?;
|
||||||
|
if migrated {
|
||||||
|
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
|
||||||
|
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
|
||||||
|
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
|
||||||
|
if imported_prompts {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||||
|
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||||
|
log::info!(
|
||||||
|
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||||
|
);
|
||||||
|
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
|
config.save()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
write_json_file(&config_path, self)
|
// 先备份旧版(若存在)到 ~/.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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定应用的管理器
|
/// 获取指定应用的管理器
|
||||||
@@ -127,4 +395,466 @@ impl MultiAppConfig {
|
|||||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
.insert(app.as_str().to_string(), ProviderManager::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取指定客户端的 MCP 配置(不可变引用)
|
||||||
|
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => &self.mcp.claude,
|
||||||
|
AppType::Codex => &self.mcp.codex,
|
||||||
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定客户端的 MCP 配置(可变引用)
|
||||||
|
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
|
AppType::Gemini => &mut self.mcp.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建默认配置并自动导入已存在的提示词文件
|
||||||
|
fn default_with_auto_import() -> Result<Self, AppError> {
|
||||||
|
log::info!("首次启动,创建默认配置并检测提示词文件");
|
||||||
|
|
||||||
|
let mut config = Self::default();
|
||||||
|
|
||||||
|
// 为每个应用尝试自动导入提示词
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已存在配置文件时的 Prompt 自动导入逻辑
|
||||||
|
///
|
||||||
|
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
|
||||||
|
/// 判定规则:
|
||||||
|
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
|
||||||
|
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示至少有一个应用成功导入了提示词
|
||||||
|
/// - Ok(false) 表示无需导入或未导入任何内容
|
||||||
|
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
||||||
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
|
let mut imported = false;
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
// 复用已有的单应用导入逻辑
|
||||||
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
|
imported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查并自动导入单个应用的提示词文件
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示成功导入了非空文件
|
||||||
|
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
|
||||||
|
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if !file_path.exists() {
|
||||||
|
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
let content = match std::fs::read_to_string(&file_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
||||||
|
return Ok(false); // 失败时不中断,继续处理其他应用
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查内容是否为空
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
||||||
|
|
||||||
|
// 创建提示词对象
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let id = format!("auto-imported-{timestamp}");
|
||||||
|
let prompt = crate::prompt::Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"Auto-imported Prompt {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
description: Some("Automatically imported on first launch".to_string()),
|
||||||
|
enabled: true, // 自动启用
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插入到对应的应用配置中
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
prompts.insert(id, prompt);
|
||||||
|
|
||||||
|
log::info!("自动导入完成: {}", app.as_str());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
|
||||||
|
///
|
||||||
|
/// 迁移策略:
|
||||||
|
/// 1. 检查是否已经迁移(mcp.servers 是否存在)
|
||||||
|
/// 2. 收集所有应用的 MCP,按 ID 去重合并
|
||||||
|
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
|
||||||
|
/// 4. 清空旧的分应用配置
|
||||||
|
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 检查是否已经是新结构
|
||||||
|
if self.mcp.servers.is_some() {
|
||||||
|
log::debug!("MCP 配置已是统一结构,跳过迁移");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
|
||||||
|
|
||||||
|
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// 收集所有应用的 MCP
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
let old_servers = match app {
|
||||||
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, entry) in old_servers {
|
||||||
|
let enabled = entry
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if let Some(existing) = unified_servers.get_mut(id) {
|
||||||
|
// 该 ID 已存在,合并 apps 字段
|
||||||
|
existing.apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
// 检测配置冲突(同 ID 但配置不同)
|
||||||
|
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
|
||||||
|
conflicts.push(format!(
|
||||||
|
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
|
||||||
|
app.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 首次遇到该 MCP,创建新条目
|
||||||
|
let name = entry
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let server = entry
|
||||||
|
.get("server")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
|
let description = entry
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let homepage = entry
|
||||||
|
.get("homepage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let docs = entry
|
||||||
|
.get("docs")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let tags = entry
|
||||||
|
.get("tags")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut apps = McpApps::default();
|
||||||
|
apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
unified_servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
apps,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
docs,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录冲突警告
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
log::warn!("MCP 迁移过程中检测到配置冲突:");
|
||||||
|
for conflict in &conflicts {
|
||||||
|
log::warn!(" - {conflict}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"MCP 迁移完成,共迁移 {} 个服务器{}",
|
||||||
|
unified_servers.len(),
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
format!("(存在 {} 个冲突)", conflicts.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 替换为新结构
|
||||||
|
self.mcp.servers = Some(unified_servers);
|
||||||
|
|
||||||
|
// 清空旧的分应用配置
|
||||||
|
self.mcp.claude = McpConfig::default();
|
||||||
|
self.mcp.codex = McpConfig::default();
|
||||||
|
self.mcp.gemini = McpConfig::default();
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_prompt_file(app: AppType, content: &str) {
|
||||||
|
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create parent dir");
|
||||||
|
}
|
||||||
|
fs::write(path, content).expect("write prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_existing_prompt_when_config_missing() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# hello");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert!(prompt.enabled);
|
||||||
|
assert_eq!(prompt.content, "# hello");
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"auto import should persist config to disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn skips_empty_prompt_files_during_import() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, " \n ");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
assert!(
|
||||||
|
config.prompts.claude.prompts.is_empty(),
|
||||||
|
"empty files must be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_import_happens_only_once() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "first version");
|
||||||
|
|
||||||
|
let first = MultiAppConfig::load().expect("load config");
|
||||||
|
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
||||||
|
let claude_prompt = first
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists")
|
||||||
|
.content
|
||||||
|
.clone();
|
||||||
|
assert_eq!(claude_prompt, "first version");
|
||||||
|
|
||||||
|
// 覆盖文件内容,但保留 config.json
|
||||||
|
write_prompt_file(AppType::Claude, "second version");
|
||||||
|
let second = MultiAppConfig::load().expect("load config again");
|
||||||
|
|
||||||
|
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = second
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.content, "first version",
|
||||||
|
"should not re-import when config already exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_gemini_prompt_on_first_launch() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("gemini prompt exists");
|
||||||
|
assert!(prompt.enabled, "gemini prompt should be enabled");
|
||||||
|
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.description,
|
||||||
|
Some("Automatically imported on first launch".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_all_three_apps_prompts() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# Claude prompt");
|
||||||
|
write_prompt_file(AppType::Codex, "# Codex prompt");
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
// 验证所有三个应用的提示词都被导入
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
|
||||||
|
// 验证所有提示词都被启用
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.codex
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
src-tauri/src/app_store.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Store 中的键名
|
||||||
|
const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override";
|
||||||
|
|
||||||
|
/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle
|
||||||
|
static APP_CONFIG_DIR_OVERRIDE: OnceLock<RwLock<Option<PathBuf>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn override_cache() -> &'static RwLock<Option<PathBuf>> {
|
||||||
|
APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_cached_override(value: Option<PathBuf>) {
|
||||||
|
if let Ok(mut guard) = override_cache().write() {
|
||||||
|
*guard = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取缓存中的 app_config_dir 覆盖路径
|
||||||
|
pub fn get_app_config_dir_override() -> Option<PathBuf> {
|
||||||
|
override_cache().read().ok()?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
|
let store = match app.store_builder("app_paths.json").build() {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("无法创建 Store: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match store.get(STORE_KEY_APP_CONFIG_DIR) {
|
||||||
|
Some(Value::String(path_str)) => {
|
||||||
|
let path_str = path_str.trim();
|
||||||
|
if path_str.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = resolve_path(path_str);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
|
||||||
|
将使用默认路径。"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("使用 Store 中的 app_config_dir: {path:?}");
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存
|
||||||
|
pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
|
let value = read_override_from_store(app);
|
||||||
|
update_cached_override(value.clone());
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 app_config_dir 到 Tauri Store
|
||||||
|
pub fn set_app_config_dir_to_store(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
path: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let store = app
|
||||||
|
.store_builder("app_paths.json")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
|
||||||
|
|
||||||
|
match path {
|
||||||
|
Some(p) => {
|
||||||
|
let trimmed = p.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
||||||
|
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
|
||||||
|
} else {
|
||||||
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
|
||||||
|
|
||||||
|
refresh_app_config_dir_override(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析路径,支持 ~ 开头的相对路径
|
||||||
|
fn resolve_path(raw: &str) -> PathBuf {
|
||||||
|
if raw == "~" {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(stripped);
|
||||||
|
}
|
||||||
|
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从旧的 settings.json 迁移 app_config_dir 到 Store
|
||||||
|
pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {
|
||||||
|
// app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移
|
||||||
|
// 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置
|
||||||
|
log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置");
|
||||||
|
|
||||||
|
let _ = refresh_app_config_dir_override(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
305
src-tauri/src/claude_mcp.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpStatus {
|
||||||
|
pub user_config_path: String,
|
||||||
|
pub user_config_exists: bool,
|
||||||
|
pub server_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_config_path() -> PathBuf {
|
||||||
|
ensure_mcp_override_migrated();
|
||||||
|
get_claude_mcp_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_mcp_override_migrated() {
|
||||||
|
if crate::settings::get_claude_override_dir().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_path = get_claude_mcp_path();
|
||||||
|
if new_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy_path = get_default_claude_mcp_path();
|
||||||
|
if !legacy_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = new_path.parent() {
|
||||||
|
if let Err(err) = fs::create_dir_all(parent) {
|
||||||
|
log::warn!("创建 MCP 目录失败: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::copy(&legacy_path, &new_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
||||||
|
legacy_path.display(),
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"复制 MCP 配置失败: {} -> {}: {}",
|
||||||
|
legacy_path.display(),
|
||||||
|
new_path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
let json =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
atomic_write(path, json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mcp_status() -> Result<McpStatus, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let (exists, count) = if path.exists() {
|
||||||
|
let v = read_json_value(&path)?;
|
||||||
|
let servers = v.get("mcpServers").and_then(|x| x.as_object());
|
||||||
|
(true, servers.map(|m| m.len()).unwrap_or(0))
|
||||||
|
} else {
|
||||||
|
(false, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(McpStatus {
|
||||||
|
user_config_path: path.to_string_lossy().to_string(),
|
||||||
|
user_config_exists: exists,
|
||||||
|
server_count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
}
|
||||||
|
// 基础字段校验(尽量宽松)
|
||||||
|
if !spec.is_object() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器定义必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
|
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
||||||
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
|
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||||
|
if !(is_stdio || is_http || is_sse) {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdio 类型必须有 command
|
||||||
|
if is_stdio {
|
||||||
|
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// http/sse 类型必须有 url
|
||||||
|
if is_http || is_sse {
|
||||||
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
if url.is_empty() {
|
||||||
|
return Err(AppError::McpValidation(if is_http {
|
||||||
|
"http 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
|
} else {
|
||||||
|
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保 mcpServers 对象存在
|
||||||
|
{
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?;
|
||||||
|
if !obj.contains_key("mcpServers") {
|
||||||
|
obj.insert("mcpServers".into(), serde_json::json!({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = root.clone();
|
||||||
|
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
|
||||||
|
servers.insert(id.to_string(), spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if before == root && path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
}
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut root = read_json_value(&path)?;
|
||||||
|
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let existed = servers.remove(id).is_some();
|
||||||
|
if !existed {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
|
||||||
|
if cmd.trim().is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// 如果包含路径分隔符,直接判断是否存在可执行文件
|
||||||
|
if cmd.contains('/') || cmd.contains('\\') {
|
||||||
|
return Ok(Path::new(cmd).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_var = env::var_os("PATH").unwrap_or_default();
|
||||||
|
let paths = env::split_paths(&path_var);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
let exts: Vec<String> = env::var("PATHEXT")
|
||||||
|
.unwrap_or(".COM;.EXE;.BAT;.CMD".into())
|
||||||
|
.split(';')
|
||||||
|
.map(|s| s.trim().to_uppercase())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for p in paths {
|
||||||
|
let candidate = p.join(cmd);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
for ext in &exts {
|
||||||
|
let cand = p.join(format!("{}{}", cmd, ext));
|
||||||
|
if cand.is_file() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 ~/.claude.json 中的 mcpServers 映射
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
||||||
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
|
pub fn set_mcp_servers_map(
|
||||||
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
|
let mut out: Map<String, Value> = Map::new();
|
||||||
|
for (id, spec) in servers.iter() {
|
||||||
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(server_val) = obj.remove("server") {
|
||||||
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
|
})?;
|
||||||
|
obj = server_obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.remove("enabled");
|
||||||
|
obj.remove("source");
|
||||||
|
obj.remove("id");
|
||||||
|
obj.remove("name");
|
||||||
|
obj.remove("description");
|
||||||
|
obj.remove("tags");
|
||||||
|
obj.remove("homepage");
|
||||||
|
obj.remove("docs");
|
||||||
|
|
||||||
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
|
obj.insert("mcpServers".into(), Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
131
src-tauri/src/claude_plugin.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const CLAUDE_DIR: &str = ".claude";
|
||||||
|
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
||||||
|
|
||||||
|
fn claude_dir() -> Result<PathBuf, AppError> {
|
||||||
|
// 优先使用设置中的覆盖目录
|
||||||
|
if let Some(dir) = crate::settings::get_claude_override_dir() {
|
||||||
|
return Ok(dir);
|
||||||
|
}
|
||||||
|
let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
|
||||||
|
Ok(home.join(CLAUDE_DIR))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claude_config_path() -> Result<PathBuf, AppError> {
|
||||||
|
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_claude_dir_exists() -> Result<PathBuf, AppError> {
|
||||||
|
let dir = claude_dir()?;
|
||||||
|
if !dir.exists() {
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
|
||||||
|
}
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_claude_config() -> Result<Option<String>, AppError> {
|
||||||
|
let path = claude_config_path()?;
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_managed_config(content: &str) -> bool {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(content) {
|
||||||
|
Ok(value) => value
|
||||||
|
.get("primaryApiKey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|val| val == "any")
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_claude_config() -> Result<bool, AppError> {
|
||||||
|
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
|
||||||
|
let path = claude_config_path()?;
|
||||||
|
ensure_claude_dir_exists()?;
|
||||||
|
|
||||||
|
// 尝试读取并解析为对象
|
||||||
|
let mut obj = match read_claude_config()? {
|
||||||
|
Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {
|
||||||
|
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
|
||||||
|
_ => serde_json::json!({}),
|
||||||
|
},
|
||||||
|
None => serde_json::json!({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
if let Some(map) = obj.as_object_mut() {
|
||||||
|
let cur = map
|
||||||
|
.get("primaryApiKey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if cur != "any" {
|
||||||
|
map.insert(
|
||||||
|
"primaryApiKey".to_string(),
|
||||||
|
serde_json::Value::String("any".to_string()),
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed || !path.exists() {
|
||||||
|
let serialized = serde_json::to_string_pretty(&obj)
|
||||||
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_claude_config() -> Result<bool, AppError> {
|
||||||
|
let path = claude_config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match read_claude_config()? {
|
||||||
|
Some(content) => content,
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let obj = match value.as_object_mut() {
|
||||||
|
Some(obj) => obj,
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if obj.remove("primaryApiKey").is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialized =
|
||||||
|
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {
|
||||||
|
let path = claude_config_path()?;
|
||||||
|
Ok((path.exists(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_claude_config_applied() -> Result<bool, AppError> {
|
||||||
|
match read_claude_config()? {
|
||||||
|
Some(content) => Ok(is_managed_config(&content)),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
use serde_json::Value;
|
// unused imports removed
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file,
|
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
||||||
};
|
};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// 获取 Codex 配置目录路径
|
/// 获取 Codex 配置目录路径
|
||||||
pub fn get_codex_config_dir() -> PathBuf {
|
pub fn get_codex_config_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,68 +34,20 @@ pub fn get_codex_provider_paths(
|
|||||||
provider_name: Option<&str>,
|
provider_name: Option<&str>,
|
||||||
) -> (PathBuf, PathBuf) {
|
) -> (PathBuf, PathBuf) {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(|name| sanitize_provider_name(name))
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
|
||||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
|
||||||
|
|
||||||
(auth_path, config_path)
|
(auth_path, config_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 备份 Codex 当前配置
|
/// 删除 Codex 供应商配置文件
|
||||||
pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
pub fn delete_codex_provider_config(
|
||||||
let auth_path = get_codex_auth_path();
|
|
||||||
let config_path = get_codex_config_path();
|
|
||||||
let (backup_auth_path, backup_config_path) =
|
|
||||||
get_codex_provider_paths(provider_id, Some(provider_name));
|
|
||||||
|
|
||||||
// 备份 auth.json
|
|
||||||
if auth_path.exists() {
|
|
||||||
copy_file(&auth_path, &backup_auth_path)?;
|
|
||||||
log::info!("已备份 Codex auth.json: {}", backup_auth_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 备份 config.toml
|
|
||||||
if config_path.exists() {
|
|
||||||
copy_file(&config_path, &backup_config_path)?;
|
|
||||||
log::info!("已备份 Codex config.toml: {}", backup_config_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存 Codex 供应商配置副本
|
|
||||||
pub fn save_codex_provider_config(
|
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
settings_config: &Value,
|
) -> Result<(), AppError> {
|
||||||
) -> Result<(), String> {
|
|
||||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
|
||||||
|
|
||||||
// 保存 auth.json
|
|
||||||
if let Some(auth) = settings_config.get("auth") {
|
|
||||||
write_json_file(&auth_path, auth)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 config.toml
|
|
||||||
if let Some(config) = settings_config.get("config") {
|
|
||||||
if let Some(config_str) = config.as_str() {
|
|
||||||
// 若非空则进行 TOML 语法校验
|
|
||||||
if !config_str.trim().is_empty() {
|
|
||||||
toml::from_str::<toml::Table>(config_str)
|
|
||||||
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
|
||||||
}
|
|
||||||
fs::write(&config_path, config_str)
|
|
||||||
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 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));
|
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||||
|
|
||||||
delete_file(&auth_path).ok();
|
delete_file(&auth_path).ok();
|
||||||
@@ -97,76 +56,79 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Codex 供应商配置副本恢复到主配置
|
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
||||||
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
pub fn write_codex_live_atomic(
|
||||||
let (provider_auth_path, provider_config_path) =
|
auth: &Value,
|
||||||
get_codex_provider_paths(provider_id, Some(provider_name));
|
config_text_opt: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let auth_path = get_codex_auth_path();
|
let auth_path = get_codex_auth_path();
|
||||||
let config_path = get_codex_config_path();
|
let config_path = get_codex_config_path();
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
if let Some(parent) = auth_path.parent() {
|
if let Some(parent) = auth_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制 auth.json(必需)
|
// 读取旧内容用于回滚
|
||||||
if provider_auth_path.exists() {
|
let old_auth = if auth_path.exists() {
|
||||||
copy_file(&provider_auth_path, &auth_path)?;
|
Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
|
||||||
log::info!("已恢复 Codex auth.json");
|
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
None
|
||||||
"供应商 auth.json 不存在: {}",
|
};
|
||||||
provider_auth_path.display()
|
let _old_config = if config_path.exists() {
|
||||||
));
|
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, 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| AppError::toml(&config_path, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制 config.toml(可选,允许为空;不存在则创建空文件以保持一致性)
|
// 第一步:写 auth.json
|
||||||
if provider_config_path.exists() {
|
write_json_file(&auth_path, auth)?;
|
||||||
copy_file(&provider_config_path, &config_path)?;
|
|
||||||
log::info!("已恢复 Codex config.toml");
|
// 第二步:写 config.toml(失败则回滚 auth.json)
|
||||||
} else {
|
if let Err(e) = write_text_file(&config_path, &cfg_text) {
|
||||||
// 写入空文件
|
// 回滚 auth.json
|
||||||
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?;
|
if let Some(bytes) = old_auth {
|
||||||
log::info!("供应商 config.toml 缺失,已创建空文件");
|
let _ = atomic_write(&auth_path, &bytes);
|
||||||
|
} else {
|
||||||
|
let _ = delete_file(&auth_path);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入当前 Codex 配置为默认供应商
|
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
|
||||||
pub fn import_current_codex_config() -> Result<Value, String> {
|
pub fn read_codex_config_text() -> Result<String, AppError> {
|
||||||
let auth_path = get_codex_auth_path();
|
let path = get_codex_config_path();
|
||||||
let config_path = get_codex_config_path();
|
if path.exists() {
|
||||||
|
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
||||||
// 行为放宽:仅要求 auth.json 存在;config.toml 可缺失
|
|
||||||
if !auth_path.exists() {
|
|
||||||
return Err("Codex 配置文件不存在".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 auth.json
|
|
||||||
let auth = read_json_file::<Value>(&auth_path)?;
|
|
||||||
|
|
||||||
// 读取 config.toml(允许不存在或读取失败时为空)
|
|
||||||
let config_str = if config_path.exists() {
|
|
||||||
let s = fs::read_to_string(&config_path)
|
|
||||||
.map_err(|e| format!("读取 config.toml 失败: {}", e))?;
|
|
||||||
if !s.trim().is_empty() {
|
|
||||||
toml::from_str::<toml::Table>(&s)
|
|
||||||
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
|
|
||||||
}
|
|
||||||
s
|
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
Ok(String::new())
|
||||||
};
|
}
|
||||||
|
}
|
||||||
// 组合成完整配置
|
|
||||||
let settings_config = serde_json::json!({
|
/// 对非空的 TOML 文本进行语法校验
|
||||||
"auth": auth,
|
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
|
||||||
"config": config_str
|
if text.trim().is_empty() {
|
||||||
});
|
return Ok(());
|
||||||
|
}
|
||||||
// 保存为默认供应商副本
|
toml::from_str::<toml::Table>(text)
|
||||||
save_codex_provider_config("default", "default", &settings_config)?;
|
.map(|_| ())
|
||||||
|
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
|
||||||
Ok(settings_config)
|
}
|
||||||
|
|
||||||
|
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
|
||||||
|
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||||
|
let s = read_codex_config_text()?;
|
||||||
|
validate_config_toml(&s)?;
|
||||||
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
#![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::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 获取所有供应商
|
|
||||||
#[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);
|
|
||||||
|
|
||||||
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))?;
|
|
||||||
|
|
||||||
// 根据应用类型保存配置文件
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => {
|
|
||||||
// Codex: 保存两个文件
|
|
||||||
codex_config::save_codex_provider_config(
|
|
||||||
&provider.id,
|
|
||||||
&provider.name,
|
|
||||||
&provider.settings_config,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
AppType::Claude => {
|
|
||||||
// Claude: 使用原有逻辑
|
|
||||||
use crate::config::{get_provider_config_path, write_json_file};
|
|
||||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
|
||||||
write_json_file(&config_path, &provider.settings_config)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
drop(config); // 释放锁
|
|
||||||
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);
|
|
||||||
|
|
||||||
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.providers.contains_key(&provider.id) {
|
|
||||||
return Err(format!("供应商不存在: {}", provider.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果名称改变了,需要处理配置文件
|
|
||||||
if let Some(old_provider) = manager.providers.get(&provider.id) {
|
|
||||||
if old_provider.name != provider.name {
|
|
||||||
// 删除旧配置文件
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => {
|
|
||||||
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
AppType::Claude => {
|
|
||||||
use crate::config::{delete_file, get_provider_config_path};
|
|
||||||
let old_config_path =
|
|
||||||
get_provider_config_path(&provider.id, Some(&old_provider.name));
|
|
||||||
delete_file(&old_config_path).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存新配置文件
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => {
|
|
||||||
codex_config::save_codex_provider_config(
|
|
||||||
&provider.id,
|
|
||||||
&provider.name,
|
|
||||||
&provider.settings_config,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
AppType::Claude => {
|
|
||||||
use crate::config::{get_provider_config_path, write_json_file};
|
|
||||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
|
||||||
write_json_file(&config_path, &provider.settings_config)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
drop(config); // 释放锁
|
|
||||||
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};
|
|
||||||
let config_path = get_provider_config_path(&id, Some(&provider.name));
|
|
||||||
delete_file(&config_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从管理器删除
|
|
||||||
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();
|
|
||||||
|
|
||||||
// 根据应用类型执行切换
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => {
|
|
||||||
// 备份当前配置(如果存在)
|
|
||||||
if !manager.current.is_empty() {
|
|
||||||
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
|
||||||
codex_config::backup_codex_config(&manager.current, ¤t_provider.name)?;
|
|
||||||
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复目标供应商配置
|
|
||||||
codex_config::restore_codex_provider_config(&id, &provider.name)?;
|
|
||||||
}
|
|
||||||
AppType::Claude => {
|
|
||||||
// 使用原有的 Claude 切换逻辑
|
|
||||||
use crate::config::{
|
|
||||||
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
|
|
||||||
};
|
|
||||||
|
|
||||||
let settings_path = get_claude_settings_path();
|
|
||||||
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
|
|
||||||
|
|
||||||
// 检查供应商配置文件是否存在
|
|
||||||
if !provider_config_path.exists() {
|
|
||||||
return Err(format!(
|
|
||||||
"供应商配置文件不存在: {}",
|
|
||||||
provider_config_path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果当前有配置,先备份到当前供应商
|
|
||||||
if settings_path.exists() && !manager.current.is_empty() {
|
|
||||||
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
|
||||||
let current_provider_path =
|
|
||||||
get_provider_config_path(&manager.current, Some(¤t_provider.name));
|
|
||||||
backup_config(&settings_path, ¤t_provider_path)?;
|
|
||||||
log::info!("已备份当前供应商配置: {}", current_provider.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保主配置父目录存在
|
|
||||||
if let Some(parent) = settings_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制新供应商配置到主配置
|
|
||||||
copy_file(&provider_config_path, &settings_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新当前供应商
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 若已存在 default 供应商,则直接返回,避免重复导入
|
|
||||||
{
|
|
||||||
let config = state
|
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
|
||||||
|
|
||||||
if let Some(manager) = config.get_manager(&app_type) {
|
|
||||||
if manager.get_all_providers().contains_key("default") {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据应用类型导入配置
|
|
||||||
let settings_config = match app_type {
|
|
||||||
AppType::Codex => codex_config::import_current_codex_config()?,
|
|
||||||
AppType::Claude => import_current_config_as_default()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建默认供应商
|
|
||||||
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))?;
|
|
||||||
|
|
||||||
// 根据应用类型保存配置文件
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => {
|
|
||||||
codex_config::save_codex_provider_config(
|
|
||||||
&provider.id,
|
|
||||||
&provider.name,
|
|
||||||
&provider.settings_config,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
AppType::Claude => {
|
|
||||||
use crate::config::{get_provider_config_path, write_json_file};
|
|
||||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
|
||||||
write_json_file(&config_path, &provider.settings_config)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
|
|
||||||
// 如果没有当前供应商,设置为 default
|
|
||||||
if manager.current.is_empty() {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
240
src-tauri/src/commands/config.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config;
|
||||||
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
|
|
||||||
|
/// 获取 Claude Code 配置状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
|
Ok(config::get_claude_config_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||||
|
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => Ok(config::get_claude_config_status()),
|
||||||
|
AppType::Codex => {
|
||||||
|
let auth_path = codex_config::get_codex_auth_path();
|
||||||
|
let exists = auth_path.exists();
|
||||||
|
let path = codex_config::get_codex_config_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||||
|
let exists = env_path.exists();
|
||||||
|
let path = crate::gemini_config::get_gemini_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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前生效的配置目录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||||
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(dir.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开配置文件夹
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||||
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pick_directory(
|
||||||
|
app: AppHandle,
|
||||||
|
#[allow(non_snake_case)] defaultPath: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let initial = defaultPath
|
||||||
|
.map(|p| p.trim().to_string())
|
||||||
|
.filter(|p| !p.is_empty());
|
||||||
|
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut builder = app.dialog().file();
|
||||||
|
if let Some(path) = initial {
|
||||||
|
builder = builder.set_directory(path);
|
||||||
|
}
|
||||||
|
builder.blocking_pick_folder()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(file_path) => {
|
||||||
|
let resolved = file_path
|
||||||
|
.simplified()
|
||||||
|
.into_path()
|
||||||
|
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
|
||||||
|
Ok(Some(resolved.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用配置文件路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_path() -> Result<String, String> {
|
||||||
|
let config_path = config::get_app_config_path();
|
||||||
|
Ok(config_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开应用配置文件夹
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||||
|
let config_dir = config::get_app_config_dir();
|
||||||
|
|
||||||
|
if !config_dir.exists() {
|
||||||
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.opener()
|
||||||
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
|
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_common_config_snippet(
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|e| format!("读取配置锁失败: {e}"))?;
|
||||||
|
Ok(guard.common_config_snippets.claude.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_claude_common_config_snippet(
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| format!("写入配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
// 验证是否为有效的 JSON(如果不为空)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
};
|
||||||
|
|
||||||
|
guard.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|e| format!("读取配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(guard.common_config_snippets.get(&app).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| format!("写入配置锁失败: {e}"))?;
|
||||||
|
|
||||||
|
// 验证格式(根据应用类型)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
match app {
|
||||||
|
AppType::Claude | AppType::Gemini => {
|
||||||
|
// 验证 JSON 格式
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
// TOML 格式暂不验证(或可使用 toml crate)
|
||||||
|
// 注意:TOML 验证较为复杂,暂时跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard.common_config_snippets.set(
|
||||||
|
&app,
|
||||||
|
if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
guard.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
29
src-tauri/src/commands/deeplink.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Parse a deep link URL and return the parsed request for frontend confirmation
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
||||||
|
log::info!("Parsing deep link URL: {url}");
|
||||||
|
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request (after user confirmation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_from_deeplink(
|
||||||
|
state: State<AppState>,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
log::info!(
|
||||||
|
"Importing provider from deep link: {} for app {}",
|
||||||
|
request.name,
|
||||||
|
request.app
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
log::info!("Successfully imported provider with ID: {provider_id}");
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
22
src-tauri/src/commands/env.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
|
||||||
|
use crate::services::env_manager::{
|
||||||
|
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Check environment variable conflicts for a specific app
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
check_conflicts(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete environment variables with backup
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
||||||
|
delete_vars(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore environment variables from backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
|
||||||
|
restore_from_backup(backup_path)
|
||||||
|
}
|
||||||
106
src-tauri/src/commands/import_export.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::ConfigService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 导出配置文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_config_to_file(
|
||||||
|
#[allow(non_snake_case)] filePath: String,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let target_path = PathBuf::from(&filePath);
|
||||||
|
ConfigService::export_config_to_path(&target_path)?;
|
||||||
|
Ok::<_, AppError>(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Configuration exported successfully",
|
||||||
|
"filePath": filePath
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("导出配置失败: {e}"))?
|
||||||
|
.map_err(|e: AppError| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从文件导入配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_config_from_file(
|
||||||
|
#[allow(non_snake_case)] filePath: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let path_buf = PathBuf::from(&filePath);
|
||||||
|
ConfigService::load_config_for_import(&path_buf)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||||
|
.map_err(|e: AppError| e.to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| AppError::from(e).to_string())?;
|
||||||
|
*guard = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Configuration imported successfully",
|
||||||
|
"backupId": backup_id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步当前供应商配置到对应的 live 文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
||||||
|
{
|
||||||
|
let mut config_state = state
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|e| AppError::from(e).to_string())?;
|
||||||
|
ConfigService::sync_current_providers_to_live(&mut config_state)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Live configuration synchronized"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存文件对话框
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
#[allow(non_snake_case)] defaultName: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let dialog = app.dialog();
|
||||||
|
let result = dialog
|
||||||
|
.file()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.set_file_name(&defaultName)
|
||||||
|
.blocking_save_file();
|
||||||
|
|
||||||
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开文件对话框
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_file_dialog<R: tauri::Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let dialog = app.dialog();
|
||||||
|
let result = dialog
|
||||||
|
.file()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
}
|
||||||
197
src-tauri/src/commands/mcp.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::claude_mcp;
|
||||||
|
use crate::services::McpService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 获取 Claude MCP 状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_mcp_status() -> Result<claude_mcp::McpStatus, String> {
|
||||||
|
claude_mcp::get_mcp_status().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 mcp.json 文本内容
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
|
||||||
|
claude_mcp::read_mcp_json().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增或更新一个 MCP 服务器条目
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
|
||||||
|
claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除一个 MCP 服务器条目
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
|
||||||
|
claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验命令是否在 PATH 中可用(不执行)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
|
||||||
|
claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct McpConfigResponse {
|
||||||
|
pub config_path: String,
|
||||||
|
pub servers: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json)
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
|
pub async fn get_mcp_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
) -> Result<McpConfigResponse, String> {
|
||||||
|
let config_path = crate::config::get_app_config_path()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?;
|
||||||
|
Ok(McpConfigResponse {
|
||||||
|
config_path,
|
||||||
|
servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
||||||
|
/// [已废弃] 该命令仍然使用旧的分应用API,会转换为统一结构
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_mcp_server_in_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
spec: serde_json::Value,
|
||||||
|
sync_other_side: Option<bool>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 读取现有的服务器(如果存在)
|
||||||
|
let existing_server = {
|
||||||
|
let cfg = state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
if let Some(servers) = &cfg.mcp.servers {
|
||||||
|
servers.get(&id).cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建新的统一服务器结构
|
||||||
|
let mut new_server = if let Some(mut existing) = existing_server {
|
||||||
|
// 更新现有服务器
|
||||||
|
existing.server = spec.clone();
|
||||||
|
existing.apps.set_enabled_for(&app_ty, true);
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
// 创建新服务器
|
||||||
|
let mut apps = crate::app_config::McpApps::default();
|
||||||
|
apps.set_enabled_for(&app_ty, true);
|
||||||
|
|
||||||
|
// 尝试从 spec 中提取 name,否则使用 id
|
||||||
|
let name = spec
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server: spec,
|
||||||
|
apps,
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果 sync_other_side 为 true,也启用其他应用
|
||||||
|
if sync_other_side.unwrap_or(false) {
|
||||||
|
new_server.apps.claude = true;
|
||||||
|
new_server.apps.codex = true;
|
||||||
|
new_server.apps.gemini = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
McpService::upsert_server(&state, new_server)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 config.json 中删除一个 MCP 服务器定义
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_mcp_server_in_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置启用状态并同步到客户端配置
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
|
pub async fn set_mcp_enabled(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v3.7.0 新增:统一 MCP 管理命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
|
/// 获取所有 MCP 服务器(统一结构)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_mcp_servers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<HashMap<String, McpServer>, String> {
|
||||||
|
McpService::get_all_servers(&state).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加或更新 MCP 服务器
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_mcp_server(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
server: McpServer,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 MCP 服务器
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换 MCP 服务器在指定应用的启用状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn toggle_mcp_app(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
server_id: String,
|
||||||
|
app: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
53
src-tauri/src/commands/misc.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::init_status::InitErrorPayload;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
/// 打开外部链接
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
|
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
format!("https://{url}")
|
||||||
|
};
|
||||||
|
|
||||||
|
app.opener()
|
||||||
|
.open_url(&url, None::<String>)
|
||||||
|
.map_err(|e| format!("打开链接失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
||||||
|
handle
|
||||||
|
.opener()
|
||||||
|
.open_url(
|
||||||
|
"https://github.com/farion1231/cc-switch/releases/latest",
|
||||||
|
None::<String>,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开更新页面失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否为便携版(绿色版)运行
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_portable_mode() -> Result<bool, String> {
|
||||||
|
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
|
||||||
|
if let Some(dir) = exe_path.parent() {
|
||||||
|
Ok(dir.join("portable.ini").is_file())
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用启动阶段的初始化错误(若有)。
|
||||||
|
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||||
|
Ok(crate::init_status::get_init_error())
|
||||||
|
}
|
||||||
25
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
|
mod env;
|
||||||
|
mod import_export;
|
||||||
|
mod mcp;
|
||||||
|
mod misc;
|
||||||
|
mod plugin;
|
||||||
|
mod prompt;
|
||||||
|
mod provider;
|
||||||
|
mod settings;
|
||||||
|
pub mod skill;
|
||||||
|
|
||||||
|
pub use config::*;
|
||||||
|
pub use deeplink::*;
|
||||||
|
pub use env::*;
|
||||||
|
pub use import_export::*;
|
||||||
|
pub use mcp::*;
|
||||||
|
pub use misc::*;
|
||||||
|
pub use plugin::*;
|
||||||
|
pub use prompt::*;
|
||||||
|
pub use provider::*;
|
||||||
|
pub use settings::*;
|
||||||
|
pub use skill::*;
|
||||||
36
src-tauri/src/commands/plugin.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::config::ConfigStatus;
|
||||||
|
|
||||||
|
/// Claude 插件:获取 ~/.claude/config.json 状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
|
||||||
|
crate::claude_plugin::claude_config_status()
|
||||||
|
.map(|(exists, path)| ConfigStatus {
|
||||||
|
exists,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:读取配置内容(若不存在返回 Ok(None))
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
|
||||||
|
crate::claude_plugin::read_claude_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:写入/清除固定配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
|
||||||
|
if official {
|
||||||
|
crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
crate::claude_plugin::write_claude_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 插件:检测是否已写入目标配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||||
|
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
64
src-tauri/src/commands/prompt.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::services::PromptService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_prompts(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<HashMap<String, Prompt>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
prompt: Prompt,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn enable_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_prompt_from_file(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
233
src-tauri/src/commands/provider.rs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// 获取所有供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
) -> Result<HashMap<String, Provider>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前供应商ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::delete(state.inner(), app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换供应商
|
||||||
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
ProviderService::switch(state, app_type, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub fn switch_provider_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
switch_provider_internal(state, app_type, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn switch_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
switch_provider_internal(&state, app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||||
|
ProviderService::import_default_config(state, app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
|
pub fn import_default_config_test_hook(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
import_default_config_internal(state, app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导入当前配置为默认供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
import_default_config_internal(&state, app_type)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询供应商用量
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn queryProviderUsage(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||||
|
app: String,
|
||||||
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn testUsageScript(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] scriptCode: String,
|
||||||
|
timeout: Option<u64>,
|
||||||
|
#[allow(non_snake_case)] apiKey: Option<String>,
|
||||||
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||||
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::test_usage_script(
|
||||||
|
state.inner(),
|
||||||
|
app_type,
|
||||||
|
&providerId,
|
||||||
|
&scriptCode,
|
||||||
|
timeout.unwrap_or(10),
|
||||||
|
apiKey.as_deref(),
|
||||||
|
baseUrl.as_deref(),
|
||||||
|
accessToken.as_deref(),
|
||||||
|
userId.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取当前生效的配置内容
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试第三方/自定义供应商端点的网络延迟
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_api_endpoints(
|
||||||
|
urls: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
|
||||||
|
) -> Result<Vec<EndpointLatency>, String> {
|
||||||
|
SpeedtestService::test_endpoints(urls, timeoutSecs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取自定义端点列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_custom_endpoints(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加自定义端点
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_custom_endpoint(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除自定义端点
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_custom_endpoint(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新端点最后使用时间
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_endpoint_last_used(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新多个供应商的排序
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_providers_sort_order(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
updates: Vec<ProviderSortUpdate>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
39
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// 获取设置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||||
|
Ok(crate::settings::get_settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||||
|
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||||
|
app.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
Ok(crate::app_store::refresh_app_config_dir_override(&app)
|
||||||
|
.map(|p| p.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 app_config_dir 覆盖配置 (到 Store)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_app_config_dir_override(
|
||||||
|
app: AppHandle,
|
||||||
|
path: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
163
src-tauri/src/commands/skill.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::services::skill::SkillState;
|
||||||
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills(
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let skills = service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skill = skills
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||||
|
.ok_or_else(|| "技能不存在".to_string())?;
|
||||||
|
|
||||||
|
if !skill.installed {
|
||||||
|
let repo = SkillRepo {
|
||||||
|
owner: skill
|
||||||
|
.repo_owner
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
name: skill
|
||||||
|
.repo_name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
branch: skill
|
||||||
|
.repo_branch
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "main".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.install_skill(directory.clone(), repo)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.insert(
|
||||||
|
directory.clone(),
|
||||||
|
SkillState {
|
||||||
|
installed: true,
|
||||||
|
installed_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.uninstall_skill(directory.clone())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.remove(&directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_skill_repos(
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<SkillRepo>, String> {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(config.skills.repos.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_skill_repo(
|
||||||
|
repo: SkillRepo,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.add_repo(&mut config.skills, repo)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_skill_repo(
|
||||||
|
owner: String,
|
||||||
|
name: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.remove_repo(&mut config.skills, owner, name)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -1,15 +1,51 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 获取 Claude Code 配置目录路径
|
/// 获取 Claude Code 配置目录路径
|
||||||
pub fn get_claude_config_dir() -> PathBuf {
|
pub fn get_claude_config_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.expect("无法获取用户主目录")
|
.expect("无法获取用户主目录")
|
||||||
.join(".claude")
|
.join(".claude")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||||
|
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".claude.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||||
|
let file_name = dir
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if file_name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||||
|
Some(parent.join(format!("{file_name}.json")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||||
|
pub fn get_claude_mcp_path() -> PathBuf {
|
||||||
|
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
||||||
|
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get_default_claude_mcp_path()
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Claude Code 主配置文件路径
|
/// 获取 Claude Code 主配置文件路径
|
||||||
pub fn get_claude_settings_path() -> PathBuf {
|
pub fn get_claude_settings_path() -> PathBuf {
|
||||||
let dir = get_claude_config_dir();
|
let dir = get_claude_config_dir();
|
||||||
@@ -28,6 +64,10 @@ pub fn get_claude_settings_path() -> PathBuf {
|
|||||||
|
|
||||||
/// 获取应用配置目录路径 (~/.cc-switch)
|
/// 获取应用配置目录路径 (~/.cc-switch)
|
||||||
pub fn get_app_config_dir() -> PathBuf {
|
pub fn get_app_config_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.expect("无法获取用户主目录")
|
.expect("无法获取用户主目录")
|
||||||
.join(".cc-switch")
|
.join(".cc-switch")
|
||||||
@@ -52,46 +92,150 @@ pub fn sanitize_provider_name(name: &str) -> String {
|
|||||||
/// 获取供应商配置文件路径
|
/// 获取供应商配置文件路径
|
||||||
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(|name| sanitize_provider_name(name))
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
get_claude_config_dir().join(format!("settings-{base_name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
/// 读取 JSON 配置文件
|
||||||
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
|
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(format!("文件不存在: {}", path.display()));
|
return Err(AppError::Config(format!("文件不存在: {}", path.display())));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
|
||||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
|
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 JSON 配置文件
|
/// 写入 JSON 配置文件
|
||||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json =
|
let json =
|
||||||
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
|
||||||
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
|
atomic_write(path, json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原子写入文本文件(用于 TOML/纯文本)
|
||||||
|
pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
atomic_write(path, data.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||||
|
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
|
||||||
|
let mut tmp = parent.to_path_buf();
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| AppError::Config("无效的文件名".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!("{file_name}.tmp.{ts}"));
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
|
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
|
f.flush().map_err(|e| AppError::io(&tmp, 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| AppError::IoContext {
|
||||||
|
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||||
|
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_preserves_folder_name() {
|
||||||
|
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for nested dir");
|
||||||
|
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
||||||
|
let override_dir = PathBuf::from("/data/claude-config");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for standard dir");
|
||||||
|
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
||||||
|
let override_dir = PathBuf::from("claude");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir)
|
||||||
|
.expect("should derive path for single segment");
|
||||||
|
assert_eq!(derived, PathBuf::from("claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
||||||
|
let override_dir = PathBuf::from("/");
|
||||||
|
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 复制文件
|
/// 复制文件
|
||||||
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
|
pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
|
||||||
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
|
fs::copy(from, to).map_err(|e| AppError::IoContext {
|
||||||
|
context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除文件
|
/// 删除文件
|
||||||
pub fn delete_file(path: &Path) -> Result<(), String> {
|
pub fn delete_file(path: &Path) -> Result<(), AppError> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
|
fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -111,31 +255,3 @@ pub fn get_claude_config_status() -> ConfigStatus {
|
|||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 备份配置文件
|
|
||||||
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
|
|
||||||
if from.exists() {
|
|
||||||
copy_file(from, to)?;
|
|
||||||
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 导入当前 Claude Code 配置为默认供应商
|
|
||||||
pub fn import_current_config_as_default() -> Result<Value, String> {
|
|
||||||
let settings_path = get_claude_settings_path();
|
|
||||||
|
|
||||||
if !settings_path.exists() {
|
|
||||||
return Err("Claude Code 配置文件不存在".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取当前配置
|
|
||||||
let settings_config: Value = read_json_file(&settings_path)?;
|
|
||||||
|
|
||||||
// 保存为 default 供应商
|
|
||||||
let default_provider_path = get_provider_config_path("default", Some("default"));
|
|
||||||
write_json_file(&default_provider_path, &settings_config)?;
|
|
||||||
|
|
||||||
log::info!("已导入当前配置为默认供应商");
|
|
||||||
Ok(settings_config)
|
|
||||||
}
|
|
||||||
|
|||||||
457
src-tauri/src/deeplink.rs
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/// Deep link import functionality for CC Switch
|
||||||
|
///
|
||||||
|
/// This module implements the ccswitch:// protocol for importing provider configurations
|
||||||
|
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::services::ProviderService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use crate::AppType;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Deep link import request model
|
||||||
|
/// Represents a parsed ccswitch:// URL ready for processing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeepLinkImportRequest {
|
||||||
|
/// Protocol version (e.g., "v1")
|
||||||
|
pub version: String,
|
||||||
|
/// Resource type to import (e.g., "provider")
|
||||||
|
pub resource: String,
|
||||||
|
/// Target application (claude/codex/gemini)
|
||||||
|
pub app: String,
|
||||||
|
/// Provider name
|
||||||
|
pub name: String,
|
||||||
|
/// Provider homepage URL
|
||||||
|
pub homepage: String,
|
||||||
|
/// API endpoint/base URL
|
||||||
|
pub endpoint: String,
|
||||||
|
/// API key
|
||||||
|
pub api_key: String,
|
||||||
|
/// Optional model name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Optional notes/description
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
|
||||||
|
///
|
||||||
|
/// Expected format:
|
||||||
|
/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
|
||||||
|
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
// Parse URL
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
|
||||||
|
|
||||||
|
// Validate scheme
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "ccswitch" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract version from host
|
||||||
|
let version = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Validate version
|
||||||
|
if version != "v1" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported protocol version: {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract path (should be "/import")
|
||||||
|
let path = url.path();
|
||||||
|
if path != "/import" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid path: expected '/import', got '{path}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
||||||
|
|
||||||
|
// Extract and validate resource type
|
||||||
|
let resource = params
|
||||||
|
.get("resource")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if resource != "provider" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported resource type: {resource}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required fields
|
||||||
|
let app = params
|
||||||
|
.get("app")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate app type
|
||||||
|
if app != "claude" && app != "codex" && app != "gemini" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let homepage = params
|
||||||
|
.get("homepage")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let endpoint = params
|
||||||
|
.get("endpoint")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let api_key = params
|
||||||
|
.get("apiKey")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate URLs
|
||||||
|
validate_url(&homepage, "homepage")?;
|
||||||
|
validate_url(&endpoint, "endpoint")?;
|
||||||
|
|
||||||
|
// Extract optional fields
|
||||||
|
let model = params.get("model").cloned();
|
||||||
|
let notes = params.get("notes").cloned();
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
app,
|
||||||
|
name,
|
||||||
|
homepage,
|
||||||
|
endpoint,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string is a valid HTTP(S) URL
|
||||||
|
fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
|
||||||
|
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Validates the request
|
||||||
|
/// 2. Converts it to a Provider structure
|
||||||
|
/// 3. Delegates to ProviderService for actual import
|
||||||
|
pub fn import_provider_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
// Parse app type
|
||||||
|
let app_type = AppType::from_str(&request.app)
|
||||||
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
|
||||||
|
|
||||||
|
// Build provider configuration based on app type
|
||||||
|
let mut provider = build_provider_from_request(&app_type, &request)?;
|
||||||
|
|
||||||
|
// Generate a unique ID for the provider using timestamp + sanitized name
|
||||||
|
// This is similar to how frontend generates IDs
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let sanitized_name = request
|
||||||
|
.name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
provider.id = format!("{sanitized_name}-{timestamp}");
|
||||||
|
|
||||||
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
|
// Use ProviderService to add the provider
|
||||||
|
ProviderService::add(state, app_type, provider)?;
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Provider structure from a deep link request
|
||||||
|
fn build_provider_from_request(
|
||||||
|
app_type: &AppType,
|
||||||
|
request: &DeepLinkImportRequest,
|
||||||
|
) -> Result<Provider, AppError> {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let settings_config = match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
// Claude configuration structure
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
|
||||||
|
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
|
||||||
|
|
||||||
|
// Add model if provided (use as default model)
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
// Codex configuration structure
|
||||||
|
// For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。
|
||||||
|
//
|
||||||
|
// 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致,
|
||||||
|
// 再根据深链接参数注入 base_url / model,避免出现“只有 base_url 行”的极简配置,
|
||||||
|
// 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。
|
||||||
|
|
||||||
|
// 1. 生成一个适合作为 model_provider 名的安全标识
|
||||||
|
// 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致:
|
||||||
|
// - 转小写
|
||||||
|
// - 非 [a-z0-9_] 统一替换为下划线
|
||||||
|
// - 去掉首尾下划线
|
||||||
|
// - 若结果为空,则使用 "custom"
|
||||||
|
let clean_provider_name = {
|
||||||
|
let raw: String = request.name.chars().filter(|c| !c.is_control()).collect();
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
let mut key: String = lower
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'a'..='z' | '0'..='9' | '_' => c,
|
||||||
|
_ => '_',
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 去掉首尾下划线
|
||||||
|
while key.starts_with('_') {
|
||||||
|
key.remove(0);
|
||||||
|
}
|
||||||
|
while key.ends_with('_') {
|
||||||
|
key.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
"custom".to_string()
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 模型名称:优先使用 deeplink 中的 model,否则退回到 Codex 默认模型
|
||||||
|
let model_name = request
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("gpt-5-codex")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠
|
||||||
|
let endpoint = request.endpoint.trim().trim_end_matches('/').to_string();
|
||||||
|
|
||||||
|
// 4. 组装 config.toml 内容
|
||||||
|
// 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告
|
||||||
|
let config_toml = format!(
|
||||||
|
r#"model_provider = "{clean_provider_name}"
|
||||||
|
model = "{model_name}"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
disable_response_storage = true
|
||||||
|
|
||||||
|
[model_providers.{clean_provider_name}]
|
||||||
|
name = "{clean_provider_name}"
|
||||||
|
base_url = "{endpoint}"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": request.api_key,
|
||||||
|
},
|
||||||
|
"config": config_toml
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// Gemini configuration structure (.env format)
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||||
|
env.insert(
|
||||||
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||||
|
json!(request.endpoint),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add model if provided
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = Provider {
|
||||||
|
id: String::new(), // Will be generated by ProviderService
|
||||||
|
name: request.name.clone(),
|
||||||
|
settings_config,
|
||||||
|
website_url: Some(request.homepage.clone()),
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: request.notes.clone(),
|
||||||
|
meta: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_valid_claude_deeplink() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.version, "v1");
|
||||||
|
assert_eq!(request.resource, "provider");
|
||||||
|
assert_eq!(request.app, "claude");
|
||||||
|
assert_eq!(request.name, "Test Provider");
|
||||||
|
assert_eq!(request.homepage, "https://example.com");
|
||||||
|
assert_eq!(request.endpoint, "https://api.example.com");
|
||||||
|
assert_eq!(request.api_key, "sk-test-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_deeplink_with_notes() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_invalid_scheme() {
|
||||||
|
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unsupported_version() {
|
||||||
|
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Unsupported protocol version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_missing_required_field() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Missing 'homepage' parameter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_url() {
|
||||||
|
let result = validate_url("not-a-url", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_scheme() {
|
||||||
|
let result = validate_url("ftp://example.com", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("must be http or https"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_with_model() {
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: "gemini".to_string(),
|
||||||
|
name: "Test Gemini".to_string(),
|
||||||
|
homepage: "https://example.com".to_string(),
|
||||||
|
endpoint: "https://api.example.com".to_string(),
|
||||||
|
api_key: "test-api-key".to_string(),
|
||||||
|
model: Some("gemini-2.0-flash".to_string()),
|
||||||
|
notes: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
// Verify provider basic info
|
||||||
|
assert_eq!(provider.name, "Test Gemini");
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url,
|
||||||
|
Some("https://example.com".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify settings_config structure
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_without_model() {
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: "gemini".to_string(),
|
||||||
|
name: "Test Gemini".to_string(),
|
||||||
|
homepage: "https://example.com".to_string(),
|
||||||
|
endpoint: "https://api.example.com".to_string(),
|
||||||
|
api_key: "test-api-key".to_string(),
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
// Verify settings_config structure
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
// Model should not be present
|
||||||
|
assert!(env.get("GEMINI_MODEL").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::PoisonError;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("配置错误: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error("无效输入: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("IO 错误: {path}: {source}")]
|
||||||
|
Io {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("{context}: {source}")]
|
||||||
|
IoContext {
|
||||||
|
context: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("JSON 解析错误: {path}: {source}")]
|
||||||
|
Json {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
#[error("JSON 序列化失败: {source}")]
|
||||||
|
JsonSerialize {
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
#[error("TOML 解析错误: {path}: {source}")]
|
||||||
|
Toml {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: toml::de::Error,
|
||||||
|
},
|
||||||
|
#[error("锁获取失败: {0}")]
|
||||||
|
Lock(String),
|
||||||
|
#[error("MCP 校验失败: {0}")]
|
||||||
|
McpValidation(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Message(String),
|
||||||
|
#[error("{zh} ({en})")]
|
||||||
|
Localized {
|
||||||
|
key: &'static str,
|
||||||
|
zh: String,
|
||||||
|
en: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn io(path: impl AsRef<Path>, source: std::io::Error) -> Self {
|
||||||
|
Self::Io {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {
|
||||||
|
Self::Json {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toml(path: impl AsRef<Path>, source: toml::de::Error) -> Self {
|
||||||
|
Self::Toml {
|
||||||
|
path: path.as_ref().display().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> Self {
|
||||||
|
Self::Localized {
|
||||||
|
key,
|
||||||
|
zh: zh.into(),
|
||||||
|
en: en.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<PoisonError<T>> for AppError {
|
||||||
|
fn from(err: PoisonError<T>) -> Self {
|
||||||
|
Self::Lock(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for String {
|
||||||
|
fn from(err: AppError) -> Self {
|
||||||
|
err.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
656
src-tauri/src/gemini_config.rs
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
use crate::config::write_text_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||||
|
pub fn get_gemini_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini .env 文件路径
|
||||||
|
pub fn get_gemini_env_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 .env 文件内容为键值对
|
||||||
|
///
|
||||||
|
/// 此函数宽松地解析 .env 文件,跳过无效行。
|
||||||
|
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
|
||||||
|
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim().to_string();
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
|
||||||
|
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
|
||||||
|
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
map.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格解析 .env 文件内容,返回详细的错误信息
|
||||||
|
///
|
||||||
|
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
|
||||||
|
/// 包含行号和详细的错误信息。
|
||||||
|
///
|
||||||
|
/// # 错误
|
||||||
|
///
|
||||||
|
/// 返回 `AppError` 如果遇到以下情况:
|
||||||
|
/// - 行不包含 `=` 分隔符
|
||||||
|
/// - Key 为空或包含无效字符
|
||||||
|
/// - Key 不符合环境变量命名规范
|
||||||
|
///
|
||||||
|
/// # 使用场景
|
||||||
|
///
|
||||||
|
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
|
||||||
|
/// 可用于:
|
||||||
|
/// - 配置导入验证
|
||||||
|
/// - CLI 工具的严格模式
|
||||||
|
/// - 配置文件错误诊断
|
||||||
|
///
|
||||||
|
/// 已有完整的测试覆盖,可直接使用。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let line = line.trim();
|
||||||
|
let line_number = line_num + 1; // 行号从 1 开始
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含 =
|
||||||
|
if !line.contains('=') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.no_equals",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
// 验证 key 不为空
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.empty_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 key 只包含字母、数字和下划线
|
||||||
|
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.invalid_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将键值对序列化为 .env 格式
|
||||||
|
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// 按键排序以保证输出稳定
|
||||||
|
let mut keys: Vec<_> = map.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if let Some(value) = map.get(key) {
|
||||||
|
lines.push(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini .env 文件
|
||||||
|
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
|
||||||
|
Ok(parse_env_file(&content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 Gemini .env 文件(原子操作)
|
||||||
|
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
|
||||||
|
// 设置目录权限为 700(仅所有者可读写执行)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(parent)
|
||||||
|
.map_err(|e| AppError::io(parent, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serialize_env_file(map);
|
||||||
|
write_text_file(&path, &content)?;
|
||||||
|
|
||||||
|
// 设置文件权限为 600(仅所有者可读写)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(&path)
|
||||||
|
.map_err(|e| AppError::io(&path, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
|
||||||
|
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
|
||||||
|
let mut json_map = serde_json::Map::new();
|
||||||
|
|
||||||
|
for (key, value) in env_map {
|
||||||
|
json_map.insert(key.clone(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::json!({ "env": json_map })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
|
||||||
|
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
|
||||||
|
for (key, value) in env_obj {
|
||||||
|
if let Some(val_str) = value.as_str() {
|
||||||
|
env_map.insert(key.clone(), val_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(env_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 Gemini 配置的基本结构
|
||||||
|
///
|
||||||
|
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
|
||||||
|
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
|
||||||
|
///
|
||||||
|
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
|
||||||
|
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
|
||||||
|
// 只验证基本结构,不强制要求 GEMINI_API_KEY
|
||||||
|
// 如果有 env 字段,验证它是一个对象
|
||||||
|
if let Some(env) = settings.get("env") {
|
||||||
|
if !env.is_object() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_env",
|
||||||
|
"Gemini 配置格式错误: env 必须是对象",
|
||||||
|
"Gemini config invalid: env must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 config 字段,验证它是对象或 null
|
||||||
|
if let Some(config) = settings.get("config") {
|
||||||
|
if !(config.is_object() || config.is_null()) {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_config",
|
||||||
|
"Gemini 配置格式错误: config 必须是对象",
|
||||||
|
"Gemini config invalid: config must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格验证 Gemini 配置(要求必需字段)
|
||||||
|
///
|
||||||
|
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
|
||||||
|
/// 对于需要 API Key 的供应商(如 PackyCode),会验证 GEMINI_API_KEY 字段。
|
||||||
|
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
|
||||||
|
// 先做基础格式验证(包含 env/config 类型)
|
||||||
|
validate_gemini_settings(settings)?;
|
||||||
|
|
||||||
|
let env_map = json_to_env(settings)?;
|
||||||
|
|
||||||
|
// 如果 env 为空,表示使用 OAuth(如 Google 官方),跳过验证
|
||||||
|
if env_map.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
|
||||||
|
if !env_map.contains_key("GEMINI_API_KEY") {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.missing_api_key",
|
||||||
|
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
|
||||||
|
"Gemini config missing required field: GEMINI_API_KEY",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini settings.json 文件路径
|
||||||
|
///
|
||||||
|
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
|
||||||
|
pub fn get_gemini_settings_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
|
||||||
|
///
|
||||||
|
/// 此函数会:
|
||||||
|
/// 1. 读取现有的 settings.json(如果存在)
|
||||||
|
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
|
||||||
|
/// 3. 原子性写入文件
|
||||||
|
///
|
||||||
|
/// # 参数
|
||||||
|
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal")
|
||||||
|
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取现有的 settings.json(如果存在)
|
||||||
|
let mut settings_content = if settings_path.exists() {
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||||||
|
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只更新 security.auth.selectedType 字段
|
||||||
|
if let Some(obj) = settings_content.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String(selected_type.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
crate::config::write_json_file(&settings_path, &settings_content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Packycode Gemini 供应商写入 settings.json
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "gemini-api-key"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_packycode_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("gemini-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式)
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "oauth-personal"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_google_oauth_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("oauth-personal")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file() {
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-3-pro-preview
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let map = parse_env_file(content);
|
||||||
|
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GEMINI_MODEL"),
|
||||||
|
Some(&"gemini-3-pro-preview".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_env_file() {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
|
||||||
|
map.insert(
|
||||||
|
"GEMINI_MODEL".to_string(),
|
||||||
|
"gemini-3-pro-preview".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = serialize_env_file(&map);
|
||||||
|
|
||||||
|
assert!(content.contains("GEMINI_API_KEY=sk-test"));
|
||||||
|
assert!(content.contains("GEMINI_MODEL=gemini-3-pro-preview"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_json_conversion() {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
|
||||||
|
|
||||||
|
let json = env_to_json(&env_map);
|
||||||
|
let converted = json_to_env(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted.get("GEMINI_API_KEY"),
|
||||||
|
Some(&"test-key".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_success() {
|
||||||
|
// 测试严格模式下正常解析
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-3-pro-preview
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let map = result.unwrap();
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GEMINI_MODEL"),
|
||||||
|
Some(&"gemini-3-pro-preview".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_missing_equals() {
|
||||||
|
// 测试严格模式下检测缺少 = 的行
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID_LINE_WITHOUT_EQUALS
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_empty_key() {
|
||||||
|
// 测试严格模式下检测空 key
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
=value_without_key
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("empty") || err_msg.contains("空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_invalid_key_characters() {
|
||||||
|
// 测试严格模式下检测无效字符(如空格、特殊符号)
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID KEY WITH SPACES=value
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_lax_vs_strict() {
|
||||||
|
// 测试宽松模式和严格模式的差异
|
||||||
|
let content = "VALID_KEY=value
|
||||||
|
INVALID LINE
|
||||||
|
KEY_WITH-DASH=value";
|
||||||
|
|
||||||
|
// 宽松模式:跳过无效行,继续解析
|
||||||
|
let lax_result = parse_env_file(content);
|
||||||
|
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
|
||||||
|
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
|
||||||
|
|
||||||
|
// 严格模式:遇到无效行立即返回错误
|
||||||
|
let strict_result = parse_env_file_strict(content);
|
||||||
|
assert!(strict_result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_structure() {
|
||||||
|
// 验证 Packycode settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "gemini-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_merge() {
|
||||||
|
// 测试合并逻辑:应该保留其他字段
|
||||||
|
let mut existing_settings = serde_json::json!({
|
||||||
|
"otherField": "should-be-kept",
|
||||||
|
"security": {
|
||||||
|
"otherSetting": "also-kept",
|
||||||
|
"auth": {
|
||||||
|
"otherAuth": "preserved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模拟更新 selectedType
|
||||||
|
if let Some(obj) = existing_settings.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String("gemini-api-key".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有字段都被保留
|
||||||
|
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||||||
|
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["otherAuth"],
|
||||||
|
"preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_google_oauth_settings_structure() {
|
||||||
|
// 验证 Google OAuth settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "oauth-personal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_env_for_oauth() {
|
||||||
|
// 测试空 env(Google 官方 OAuth)可以通过基本验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证也应该通过(空 env 表示 OAuth)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_with_api_key() {
|
||||||
|
// 测试有 API Key 的配置可以通过验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "sk-test123",
|
||||||
|
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_without_api_key_relaxed() {
|
||||||
|
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基本验证应该通过(允许稍后填写 API Key)
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证应该失败(切换时要求完整配置)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_env_type() {
|
||||||
|
// 测试 env 不是对象时会失败
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": "invalid_string"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src-tauri/src/gemini_mcp.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::atomic_write;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpStatus {
|
||||||
|
pub user_config_path: String,
|
||||||
|
pub user_config_exists: bool,
|
||||||
|
pub server_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||||
|
fn user_config_path() -> PathBuf {
|
||||||
|
get_gemini_settings_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
let json =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
atomic_write(path, json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||||
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段
|
||||||
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
|
pub fn set_mcp_servers_map(
|
||||||
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
|
let mut out: Map<String, Value> = Map::new();
|
||||||
|
for (id, spec) in servers.iter() {
|
||||||
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取 server 字段(如果存在)
|
||||||
|
if let Some(server_val) = obj.remove("server") {
|
||||||
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
|
})?;
|
||||||
|
obj = server_obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 UI 辅助字段
|
||||||
|
obj.remove("enabled");
|
||||||
|
obj.remove("source");
|
||||||
|
obj.remove("id");
|
||||||
|
obj.remove("name");
|
||||||
|
obj.remove("description");
|
||||||
|
obj.remove("tags");
|
||||||
|
obj.remove("homepage");
|
||||||
|
obj.remove("docs");
|
||||||
|
|
||||||
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.gemini/settings.json 根必须是对象".into()))?;
|
||||||
|
obj.insert("mcpServers".into(), Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
41
src-tauri/src/init_status.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct InitErrorPayload {
|
||||||
|
pub path: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
||||||
|
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_init_error(payload: InitErrorPayload) {
|
||||||
|
if let Ok(mut guard) = cell().write() {
|
||||||
|
*guard = Some(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_init_error() -> Option<InitErrorPayload> {
|
||||||
|
cell().read().ok()?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_error_roundtrip() {
|
||||||
|
let payload = InitErrorPayload {
|
||||||
|
path: "/tmp/config.json".into(),
|
||||||
|
error: "broken json".into(),
|
||||||
|
};
|
||||||
|
set_init_error(payload.clone());
|
||||||
|
let got = get_init_error().expect("should get payload back");
|
||||||
|
assert_eq!(got.path, payload.path);
|
||||||
|
assert_eq!(got.error, payload.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,484 @@
|
|||||||
mod app_config;
|
mod app_config;
|
||||||
|
mod app_store;
|
||||||
|
mod claude_mcp;
|
||||||
|
mod claude_plugin;
|
||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
|
mod error;
|
||||||
|
mod gemini_config; // 新增
|
||||||
|
mod gemini_mcp;
|
||||||
|
mod init_status;
|
||||||
|
mod mcp;
|
||||||
|
mod prompt;
|
||||||
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod services;
|
||||||
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod usage_script;
|
||||||
|
|
||||||
use store::AppState;
|
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||||
use tauri::Manager;
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
|
pub use commands::*;
|
||||||
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
|
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||||
|
pub use error::AppError;
|
||||||
|
pub use mcp::{
|
||||||
|
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||||
|
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||||||
|
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||||
|
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||||
|
};
|
||||||
|
pub use provider::{Provider, ProviderMeta};
|
||||||
|
pub use services::{
|
||||||
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||||
|
SpeedtestService,
|
||||||
|
};
|
||||||
|
pub use settings::{update_settings, AppSettings};
|
||||||
|
pub use store::AppState;
|
||||||
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{
|
||||||
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::{ActivationPolicy, RunEvent};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct TrayTexts {
|
||||||
|
show_main: &'static str,
|
||||||
|
no_provider_hint: &'static str,
|
||||||
|
quit: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrayTexts {
|
||||||
|
fn from_language(language: &str) -> Self {
|
||||||
|
match language {
|
||||||
|
"en" => Self {
|
||||||
|
show_main: "Open main window",
|
||||||
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
|
quit: "Quit",
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
show_main: "打开主界面",
|
||||||
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
|
quit: "退出",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrayAppSection {
|
||||||
|
app_type: AppType,
|
||||||
|
prefix: &'static str,
|
||||||
|
header_id: &'static str,
|
||||||
|
empty_id: &'static str,
|
||||||
|
header_label: &'static str,
|
||||||
|
log_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Claude,
|
||||||
|
prefix: "claude_",
|
||||||
|
header_id: "claude_header",
|
||||||
|
empty_id: "claude_empty",
|
||||||
|
header_label: "─── Claude ───",
|
||||||
|
log_name: "Claude",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
prefix: "codex_",
|
||||||
|
header_id: "codex_header",
|
||||||
|
empty_id: "codex_empty",
|
||||||
|
header_label: "─── Codex ───",
|
||||||
|
log_name: "Codex",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Gemini,
|
||||||
|
prefix: "gemini_",
|
||||||
|
header_id: "gemini_header",
|
||||||
|
empty_id: "gemini_empty",
|
||||||
|
header_label: "─── Gemini ───",
|
||||||
|
log_name: "Gemini",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn append_provider_section<'a>(
|
||||||
|
app: &'a tauri::AppHandle,
|
||||||
|
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||||
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
|
section: &TrayAppSection,
|
||||||
|
tray_texts: &TrayTexts,
|
||||||
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
|
let Some(manager) = manager else {
|
||||||
|
return Ok(menu_builder);
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.header_id,
|
||||||
|
section.header_label,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&header);
|
||||||
|
|
||||||
|
if manager.providers.is_empty() {
|
||||||
|
let empty_hint = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.empty_id,
|
||||||
|
tray_texts.no_provider_hint,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||||
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
|
match (a.sort_index, b.sort_index) {
|
||||||
|
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (a.created_at, b.created_at) {
|
||||||
|
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, provider) in sorted_providers {
|
||||||
|
let is_current = manager.current == *id;
|
||||||
|
let item = CheckMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("{}{}", section.prefix, id),
|
||||||
|
&provider.name,
|
||||||
|
true,
|
||||||
|
is_current,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(menu_builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||||
|
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let provider_id = provider_id.to_string();
|
||||||
|
let app_type = section.app_type.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||||
|
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建动态托盘菜单
|
||||||
|
fn create_tray_menu(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
app_state: &AppState,
|
||||||
|
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||||
|
let app_settings = crate::settings::get_settings();
|
||||||
|
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||||
|
|
||||||
|
let config = app_state.config.read().map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let mut menu_builder = MenuBuilder::new(app);
|
||||||
|
|
||||||
|
// 顶部:打开主界面
|
||||||
|
let show_main_item =
|
||||||
|
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||||
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
menu_builder = append_provider_section(
|
||||||
|
app,
|
||||||
|
menu_builder,
|
||||||
|
config.get_manager(§ion.app_type),
|
||||||
|
section,
|
||||||
|
&tray_texts,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔符和退出菜单
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||||
|
|
||||||
|
menu_builder = menu_builder.separator().item(&quit_item);
|
||||||
|
|
||||||
|
menu_builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||||
|
let desired_policy = if dock_visible {
|
||||||
|
ActivationPolicy::Regular
|
||||||
|
} else {
|
||||||
|
ActivationPolicy::Accessory
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||||
|
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||||
|
log::warn!("设置激活策略失败: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理托盘菜单事件
|
||||||
|
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") {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = window.set_skip_taskbar(false);
|
||||||
|
}
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
apply_tray_policy(app, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
log::info!("退出应用");
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if handle_provider_tray_event(app, event_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::warn!("未处理的菜单事件: {event_id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一处理 ccswitch:// 深链接 URL
|
||||||
|
///
|
||||||
|
/// - 解析 URL
|
||||||
|
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
|
||||||
|
/// - 可选:在成功时聚焦主窗口
|
||||||
|
fn handle_deeplink_url(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
url_str: &str,
|
||||||
|
focus_main_window: bool,
|
||||||
|
source: &str,
|
||||||
|
) -> bool {
|
||||||
|
if !url_str.starts_with("ccswitch://") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||||||
|
|
||||||
|
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||||
|
Ok(request) => {
|
||||||
|
log::info!(
|
||||||
|
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
|
||||||
|
request.resource,
|
||||||
|
request.app,
|
||||||
|
request.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = app.emit("deeplink-import", &request) {
|
||||||
|
log::error!("✗ Failed to emit deeplink-import event: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("✓ Emitted deeplink-import event to frontend");
|
||||||
|
}
|
||||||
|
|
||||||
|
if focus_main_window {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
log::info!("✓ Window shown and focused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("✗ Failed to parse deep link URL: {e}");
|
||||||
|
|
||||||
|
if let Err(emit_err) = app.emit(
|
||||||
|
"deeplink-error",
|
||||||
|
serde_json::json!({
|
||||||
|
"url": url_str,
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
log::error!("✗ Failed to emit deeplink-error event: {emit_err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 内部切换供应商函数
|
||||||
|
fn switch_provider_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
app_type: crate::app_config::AppType,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
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(), app_type_str.clone(), provider_id)
|
||||||
|
.map_err(AppError::Message)?;
|
||||||
|
|
||||||
|
// 切换成功后重新创建托盘菜单
|
||||||
|
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> {
|
||||||
|
match create_tray_menu(&app, state.inner()) {
|
||||||
|
Ok(new_menu) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("创建托盘菜单失败: {err}");
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
let mut builder = tauri::Builder::default();
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
|
{
|
||||||
|
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
|
log::info!("=== Single Instance Callback Triggered ===");
|
||||||
|
log::info!("Args count: {}", args.len());
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
log::info!(" arg[{i}]: {arg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||||
|
let mut found_deeplink = false;
|
||||||
|
for arg in &args {
|
||||||
|
if handle_deeplink_url(app, arg, false, "single_instance args") {
|
||||||
|
found_deeplink = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_deeplink {
|
||||||
|
log::info!("ℹ No deep link URL found in args (this is expected on macOS when launched via system)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show and focus window regardless
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = builder
|
||||||
|
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
|
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
let settings = crate::settings::get_settings();
|
||||||
|
|
||||||
|
if settings.minimize_to_tray_on_close {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = window.set_skip_taskbar(true);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
apply_tray_policy(window.app_handle(), false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.app_handle().exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.setup(|app| {
|
.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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
// 设置 macOS 标题栏背景色为主界面蓝色
|
||||||
@@ -52,58 +518,113 @@ pub fn run() {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||||||
|
app_store::refresh_app_config_dir_override(app.handle());
|
||||||
|
|
||||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||||
let app_state = AppState::new();
|
// 如果配置解析失败,则向前端发送错误事件并提前结束 setup(不落盘、不覆盖配置)。
|
||||||
|
let app_state = match AppState::try_new() {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
let path = crate::config::get_app_config_path();
|
||||||
|
let payload_json = serde_json::json!({
|
||||||
|
"path": path.display().to_string(),
|
||||||
|
"error": err.to_string(),
|
||||||
|
});
|
||||||
|
// 事件通知(可能早于前端订阅,不保证送达)
|
||||||
|
if let Err(e) = app.emit("configLoadError", payload_json) {
|
||||||
|
log::error!("发射配置加载错误事件失败: {e}");
|
||||||
|
}
|
||||||
|
// 同时缓存错误,供前端启动阶段主动拉取
|
||||||
|
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
error: err.to_string(),
|
||||||
|
});
|
||||||
|
// 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 如果没有供应商且存在 Claude Code 配置,自动导入
|
// 迁移旧的 app_config_dir 配置到 Store
|
||||||
|
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||||
|
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
|
||||||
{
|
{
|
||||||
let mut config = app_state.config.lock().unwrap();
|
let mut config_guard = app_state.config.write().unwrap();
|
||||||
|
config_guard.ensure_app(&app_config::AppType::Claude);
|
||||||
|
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 Claude 供应商
|
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||||||
let need_import = if let Some(claude_manager) =
|
|
||||||
config.get_manager(&app_config::AppType::Claude)
|
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API)
|
||||||
{
|
log::info!("=== Registering deep-link URL handler ===");
|
||||||
claude_manager.providers.is_empty()
|
|
||||||
|
// Linux 和 Windows 调试模式需要显式注册
|
||||||
|
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||||
|
{
|
||||||
|
if let Err(e) = app.deep_link().register_all() {
|
||||||
|
log::error!("✗ Failed to register deep link schemes: {}", e);
|
||||||
} else {
|
} else {
|
||||||
// 确保 Claude 应用存在
|
log::info!("✓ Deep link schemes registered (Linux/Windows)");
|
||||||
config.ensure_app(&app_config::AppType::Claude);
|
}
|
||||||
true
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if need_import {
|
// 注册 URL 处理回调(所有平台通用)
|
||||||
let settings_path = config::get_claude_settings_path();
|
app.deep_link().on_open_url({
|
||||||
if settings_path.exists() {
|
let app_handle = app.handle().clone();
|
||||||
log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
|
move |event| {
|
||||||
|
log::info!("=== Deep Link Event Received (on_open_url) ===");
|
||||||
|
let urls = event.urls();
|
||||||
|
log::info!("Received {} URL(s)", urls.len());
|
||||||
|
|
||||||
if let Ok(settings_config) = config::import_current_config_as_default() {
|
for (i, url) in urls.iter().enumerate() {
|
||||||
if let Some(manager) =
|
let url_str = url.as_str();
|
||||||
config.get_manager_mut(&app_config::AppType::Claude)
|
log::info!(" URL[{i}]: {url_str}");
|
||||||
{
|
|
||||||
let provider = provider::Provider::with_id(
|
|
||||||
"default".to_string(),
|
|
||||||
"default".to_string(),
|
|
||||||
settings_config,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
if manager.add_provider(provider).is_ok() {
|
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||||
manager.current = "default".to_string();
|
break; // Process only first ccswitch:// URL
|
||||||
log::info!("成功导入默认供应商");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
log::info!("✓ Deep-link URL handler registered");
|
||||||
|
|
||||||
// 确保 Codex 应用存在
|
// 创建动态托盘菜单
|
||||||
config.ensure_app(&app_config::AppType::Codex);
|
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
// 构建托盘
|
||||||
let _ = app_state.save();
|
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);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// 初始化 SkillService
|
||||||
|
match SkillService::new() {
|
||||||
|
Ok(skill_service) => {
|
||||||
|
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("初始化 SkillService 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -117,9 +638,167 @@ pub fn run() {
|
|||||||
commands::get_claude_config_status,
|
commands::get_claude_config_status,
|
||||||
commands::get_config_status,
|
commands::get_config_status,
|
||||||
commands::get_claude_code_config_path,
|
commands::get_claude_code_config_path,
|
||||||
|
commands::get_config_dir,
|
||||||
commands::open_config_folder,
|
commands::open_config_folder,
|
||||||
|
commands::pick_directory,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
])
|
commands::get_init_error,
|
||||||
.run(tauri::generate_context!())
|
commands::get_app_config_path,
|
||||||
|
commands::open_app_config_folder,
|
||||||
|
commands::get_claude_common_config_snippet,
|
||||||
|
commands::set_claude_common_config_snippet,
|
||||||
|
commands::get_common_config_snippet,
|
||||||
|
commands::set_common_config_snippet,
|
||||||
|
commands::read_live_provider_settings,
|
||||||
|
commands::get_settings,
|
||||||
|
commands::save_settings,
|
||||||
|
commands::restart_app,
|
||||||
|
commands::check_for_updates,
|
||||||
|
commands::is_portable_mode,
|
||||||
|
commands::get_claude_plugin_status,
|
||||||
|
commands::read_claude_plugin_config,
|
||||||
|
commands::apply_claude_plugin_config,
|
||||||
|
commands::is_claude_plugin_applied,
|
||||||
|
// Claude MCP management
|
||||||
|
commands::get_claude_mcp_status,
|
||||||
|
commands::read_claude_mcp_config,
|
||||||
|
commands::upsert_claude_mcp_server,
|
||||||
|
commands::delete_claude_mcp_server,
|
||||||
|
commands::validate_mcp_command,
|
||||||
|
// usage query
|
||||||
|
commands::queryProviderUsage,
|
||||||
|
commands::testUsageScript,
|
||||||
|
// New MCP via config.json (SSOT)
|
||||||
|
commands::get_mcp_config,
|
||||||
|
commands::upsert_mcp_server_in_config,
|
||||||
|
commands::delete_mcp_server_in_config,
|
||||||
|
commands::set_mcp_enabled,
|
||||||
|
// v3.7.0: Unified MCP management
|
||||||
|
commands::get_mcp_servers,
|
||||||
|
commands::upsert_mcp_server,
|
||||||
|
commands::delete_mcp_server,
|
||||||
|
commands::toggle_mcp_app,
|
||||||
|
// Prompt management
|
||||||
|
commands::get_prompts,
|
||||||
|
commands::upsert_prompt,
|
||||||
|
commands::delete_prompt,
|
||||||
|
commands::enable_prompt,
|
||||||
|
commands::import_prompt_from_file,
|
||||||
|
commands::get_current_prompt_file_content,
|
||||||
|
// ours: endpoint speed test + custom endpoint management
|
||||||
|
commands::test_api_endpoints,
|
||||||
|
commands::get_custom_endpoints,
|
||||||
|
commands::add_custom_endpoint,
|
||||||
|
commands::remove_custom_endpoint,
|
||||||
|
commands::update_endpoint_last_used,
|
||||||
|
// app_config_dir override via Store
|
||||||
|
commands::get_app_config_dir_override,
|
||||||
|
commands::set_app_config_dir_override,
|
||||||
|
// provider sort order management
|
||||||
|
commands::update_providers_sort_order,
|
||||||
|
// theirs: config import/export and dialogs
|
||||||
|
commands::export_config_to_file,
|
||||||
|
commands::import_config_from_file,
|
||||||
|
commands::save_file_dialog,
|
||||||
|
commands::open_file_dialog,
|
||||||
|
commands::sync_current_providers_live,
|
||||||
|
// Deep link import
|
||||||
|
commands::parse_deeplink,
|
||||||
|
commands::import_from_deeplink,
|
||||||
|
update_tray_menu,
|
||||||
|
// Environment variable management
|
||||||
|
commands::check_env_conflicts,
|
||||||
|
commands::delete_env_vars,
|
||||||
|
commands::restore_env_backup,
|
||||||
|
// Skill management
|
||||||
|
commands::get_skills,
|
||||||
|
commands::install_skill,
|
||||||
|
commands::uninstall_skill,
|
||||||
|
commands::get_skill_repos,
|
||||||
|
commands::add_skill_repo,
|
||||||
|
commands::remove_skill_repo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let app = builder
|
||||||
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
app.run(|app_handle, event| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match event {
|
||||||
|
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||||
|
RunEvent::Reopen { .. } => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = window.set_skip_taskbar(false);
|
||||||
|
}
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
apply_tray_policy(app_handle, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||||
|
RunEvent::Opened { urls } => {
|
||||||
|
if let Some(url) = urls.first() {
|
||||||
|
let url_str = url.to_string();
|
||||||
|
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||||
|
|
||||||
|
if url_str.starts_with("ccswitch://") {
|
||||||
|
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||||
|
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||||
|
Ok(request) => {
|
||||||
|
log::info!(
|
||||||
|
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={}",
|
||||||
|
request.resource,
|
||||||
|
request.app
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
app_handle.emit("deeplink-import", &request)
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to emit deep link event from RunEvent::Opened: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to parse deep link URL from RunEvent::Opened: {e}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(emit_err) = app_handle.emit(
|
||||||
|
"deeplink-error",
|
||||||
|
serde_json::json!({
|
||||||
|
"url": url_str,
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
log::error!(
|
||||||
|
"Failed to emit deep link error event from RunEvent::Opened: {emit_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保主窗口可见
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
1135
src-tauri/src/mcp.rs
Normal file
16
src-tauri/src/prompt.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Prompt {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
}
|
||||||
41
src-tauri/src/prompt_files.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config::get_codex_auth_path;
|
||||||
|
use crate::config::get_claude_settings_path;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::gemini_config::get_gemini_dir;
|
||||||
|
|
||||||
|
/// 返回指定应用所使用的提示词文件路径。
|
||||||
|
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||||
|
let base_dir: PathBuf = match app {
|
||||||
|
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||||
|
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||||
|
AppType::Gemini => get_gemini_dir(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = match app {
|
||||||
|
AppType::Claude => "CLAUDE.md",
|
||||||
|
AppType::Codex => "AGENTS.md",
|
||||||
|
AppType::Gemini => "GEMINI.md",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(base_dir.join(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_base_dir_with_fallback(
|
||||||
|
primary_path: PathBuf,
|
||||||
|
fallback_dir: &str,
|
||||||
|
) -> Result<PathBuf, AppError> {
|
||||||
|
primary_path
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"home_dir_not_found",
|
||||||
|
format!("无法确定 {fallback_dir} 配置目录:用户主目录不存在"),
|
||||||
|
format!("Cannot determine {fallback_dir} config directory: user home not found"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::config::{get_provider_config_path, write_json_file};
|
// SSOT 模式:不再写供应商副本文件
|
||||||
|
|
||||||
/// 供应商结构体
|
/// 供应商结构体
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -14,6 +14,20 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "websiteUrl")]
|
#[serde(rename = "websiteUrl")]
|
||||||
pub website_url: Option<String>,
|
pub website_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "sortIndex")]
|
||||||
|
pub sort_index: Option<usize>,
|
||||||
|
/// 备注信息
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub meta: Option<ProviderMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -29,38 +43,107 @@ impl Provider {
|
|||||||
name,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url,
|
website_url,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 供应商管理器
|
/// 供应商管理器
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderManager {
|
pub struct ProviderManager {
|
||||||
pub providers: HashMap<String, Provider>,
|
pub providers: HashMap<String, Provider>,
|
||||||
pub current: String,
|
pub current: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProviderManager {
|
/// 用量查询脚本配置
|
||||||
fn default() -> Self {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
Self {
|
pub struct UsageScript {
|
||||||
providers: HashMap::new(),
|
pub enabled: bool,
|
||||||
current: String::new(),
|
pub language: String,
|
||||||
}
|
pub code: String,
|
||||||
}
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout: Option<u64>,
|
||||||
|
/// 用量查询专用的 API Key(通用模板使用)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "apiKey")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "baseUrl")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
/// 访问令牌(用于需要登录的接口,NewAPI 模板使用)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "accessToken")]
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
/// 用户ID(用于需要用户标识的接口,NewAPI 模板使用)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "autoQueryInterval")]
|
||||||
|
pub auto_query_interval: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用量数据
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageData {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "planName")]
|
||||||
|
pub plan_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extra: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "isValid")]
|
||||||
|
pub is_valid: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "invalidMessage")]
|
||||||
|
pub invalid_message: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub used: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub remaining: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用量查询结果(支持多套餐)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageResult {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<Vec<UsageData>>, // 支持返回多个套餐
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供应商元数据
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProviderMeta {
|
||||||
|
/// 自定义端点列表(按 URL 去重存储)
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||||
|
/// 用量查询脚本配置
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_script: Option<UsageScript>,
|
||||||
|
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||||
|
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub is_partner: Option<bool>,
|
||||||
|
/// 合作伙伴促销 key,用于识别 PackyCode 等特殊供应商
|
||||||
|
#[serde(
|
||||||
|
rename = "partnerPromotionKey",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub partner_promotion_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
/// 添加供应商
|
|
||||||
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
|
|
||||||
// 保存供应商配置到独立文件
|
|
||||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
|
||||||
write_json_file(&config_path, &provider.settings_config)?;
|
|
||||||
|
|
||||||
// 添加到管理器
|
|
||||||
self.providers.insert(provider.id.clone(), provider);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有供应商
|
/// 获取所有供应商
|
||||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||||
&self.providers
|
&self.providers
|
||||||
|
|||||||
257
src-tauri/src/services/config.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use super::provider::ProviderService;
|
||||||
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const MAX_BACKUPS: usize = 10;
|
||||||
|
|
||||||
|
/// 配置导入导出相关业务逻辑
|
||||||
|
pub struct ConfigService;
|
||||||
|
|
||||||
|
impl ConfigService {
|
||||||
|
/// 为当前 config.json 创建备份,返回备份 ID(若文件不存在则返回空字符串)。
|
||||||
|
pub fn create_backup(config_path: &Path) -> Result<String, AppError> {
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||||
|
let backup_id = format!("backup_{timestamp}");
|
||||||
|
|
||||||
|
let backup_dir = config_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError::Config("Invalid config path".into()))?
|
||||||
|
.join("backups");
|
||||||
|
|
||||||
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||||
|
|
||||||
|
let backup_path = backup_dir.join(format!("{backup_id}.json"));
|
||||||
|
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
|
||||||
|
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
|
||||||
|
|
||||||
|
Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> {
|
||||||
|
if retain == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(backup_dir) {
|
||||||
|
Ok(iter) => iter
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map(|ext| ext == "json")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if entries.len() <= retain {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let remove_count = entries.len().saturating_sub(retain);
|
||||||
|
let mut sorted = entries;
|
||||||
|
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
let a_time = a.metadata().and_then(|m| m.modified()).ok();
|
||||||
|
let b_time = b.metadata().and_then(|m| m.modified()).ok();
|
||||||
|
a_time.cmp(&b_time)
|
||||||
|
});
|
||||||
|
|
||||||
|
for entry in sorted.into_iter().take(remove_count) {
|
||||||
|
if let Err(err) = fs::remove_file(entry.path()) {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to remove old backup {}: {}",
|
||||||
|
entry.path().display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将当前 config.json 拷贝到目标路径。
|
||||||
|
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
let config_content =
|
||||||
|
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。
|
||||||
|
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
|
||||||
|
let import_content =
|
||||||
|
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
|
||||||
|
|
||||||
|
let new_config: MultiAppConfig =
|
||||||
|
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
let backup_id = Self::create_backup(&config_path)?;
|
||||||
|
|
||||||
|
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
Ok((new_config, backup_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将外部配置文件内容加载并写入应用状态。
|
||||||
|
pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result<String, AppError> {
|
||||||
|
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state.config.write().map_err(AppError::from)?;
|
||||||
|
*guard = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步当前供应商到对应的 live 配置。
|
||||||
|
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||||
|
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_current_provider_for_app(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let (current_id, provider) = {
|
||||||
|
let manager = match config.get_manager(app_type) {
|
||||||
|
Some(manager) => manager,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if manager.current.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_id = manager.current.clone();
|
||||||
|
let provider = match manager.providers.get(¤t_id) {
|
||||||
|
Some(provider) => provider.clone(),
|
||||||
|
None => {
|
||||||
|
log::warn!(
|
||||||
|
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(current_id, provider)
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_codex_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||||
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||||
|
})?;
|
||||||
|
let auth = settings.get("auth").ok_or_else(|| {
|
||||||
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||||
|
})?;
|
||||||
|
if !auth.is_object() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||||
|
crate::mcp::sync_enabled_to_codex(config)?;
|
||||||
|
|
||||||
|
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"config".to_string(),
|
||||||
|
serde_json::Value::String(cfg_text_after),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_claude_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use crate::config::{read_json_file, write_json_file};
|
||||||
|
|
||||||
|
let settings_path = crate::config::get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_file(&settings_path, &provider.settings_config)?;
|
||||||
|
|
||||||
|
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_gemini_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{env_to_json, read_gemini_env};
|
||||||
|
|
||||||
|
ProviderService::write_gemini_live(provider)?;
|
||||||
|
|
||||||
|
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
||||||
|
let live_after_env = read_gemini_env()?;
|
||||||
|
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
||||||
|
let live_after_config = if settings_path.exists() {
|
||||||
|
crate::config::read_json_file(&settings_path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
let mut live_after = env_to_json(&live_after_env);
|
||||||
|
if let Some(obj) = live_after.as_object_mut() {
|
||||||
|
obj.insert("config".to_string(), live_after_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src-tauri/src/services/env_checker.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EnvConflict {
|
||||||
|
pub var_name: String,
|
||||||
|
pub var_value: String,
|
||||||
|
pub source_type: String, // "system" | "file"
|
||||||
|
pub source_path: String, // Registry path or file path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::enums::*;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
/// Check environment variables for conflicts
|
||||||
|
pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let keywords = get_keywords_for_app(app);
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check system environment variables
|
||||||
|
conflicts.extend(check_system_env(&keywords)?);
|
||||||
|
|
||||||
|
// Check shell configuration files (Unix only)
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
conflicts.extend(check_shell_configs(&keywords)?);
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get relevant keywords for each app
|
||||||
|
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||||
|
match app.to_lowercase().as_str() {
|
||||||
|
"claude" => vec!["ANTHROPIC"],
|
||||||
|
"codex" => vec!["OPENAI"],
|
||||||
|
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check system environment variables (Windows Registry or Unix env)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check HKEY_CURRENT_USER\Environment
|
||||||
|
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||||
|
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||||
|
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: name.clone(),
|
||||||
|
var_value: value.to_string(),
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "HKEY_CURRENT_USER\\Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
|
||||||
|
if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||||
|
{
|
||||||
|
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||||
|
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: name.clone(),
|
||||||
|
var_value: value.to_string(),
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// Check current process environment
|
||||||
|
for (key, value) in std::env::vars() {
|
||||||
|
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: key,
|
||||||
|
var_value: value,
|
||||||
|
source_type: "system".to_string(),
|
||||||
|
source_path: "Process Environment".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check shell configuration files for environment variable exports (Unix only)
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
let config_files = vec![
|
||||||
|
format!("{}/.bashrc", home),
|
||||||
|
format!("{}/.bash_profile", home),
|
||||||
|
format!("{}/.zshrc", home),
|
||||||
|
format!("{}/.zprofile", home),
|
||||||
|
format!("{}/.profile", home),
|
||||||
|
"/etc/profile".to_string(),
|
||||||
|
"/etc/bashrc".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for file_path in config_files {
|
||||||
|
if let Ok(content) = fs::read_to_string(&file_path) {
|
||||||
|
// Parse lines for export statements
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Match patterns like: export VAR=value or VAR=value
|
||||||
|
if trimmed.starts_with("export ")
|
||||||
|
|| (!trimmed.starts_with('#') && trimmed.contains('='))
|
||||||
|
{
|
||||||
|
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
|
||||||
|
|
||||||
|
if let Some(eq_pos) = export_line.find('=') {
|
||||||
|
let var_name = export_line[..eq_pos].trim();
|
||||||
|
let var_value = export_line[eq_pos + 1..].trim();
|
||||||
|
|
||||||
|
// Check if variable name contains any keyword
|
||||||
|
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||||
|
conflicts.push(EnvConflict {
|
||||||
|
var_name: var_name.to_string(),
|
||||||
|
var_value: var_value
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.to_string(),
|
||||||
|
source_type: "file".to_string(),
|
||||||
|
source_path: format!("{}:{}", file_path, line_num + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_keywords() {
|
||||||
|
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||||
|
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||||
|
assert_eq!(
|
||||||
|
get_keywords_for_app("gemini"),
|
||||||
|
vec!["GEMINI", "GOOGLE_GEMINI"]
|
||||||
|
);
|
||||||
|
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src-tauri/src/services/env_manager.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
use super::env_checker::EnvConflict;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::enums::*;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackupInfo {
|
||||||
|
pub backup_path: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub conflicts: Vec<EnvConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete environment variables with automatic backup
|
||||||
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
||||||
|
// Step 1: Create backup
|
||||||
|
let backup_info = create_backup(&conflicts)?;
|
||||||
|
|
||||||
|
// Step 2: Delete variables
|
||||||
|
for conflict in &conflicts {
|
||||||
|
match delete_single_env(conflict) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// If deletion fails, we keep the backup but return error
|
||||||
|
return Err(format!(
|
||||||
|
"删除环境变量失败: {}. 备份已保存到: {}",
|
||||||
|
e, backup_info.backup_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(backup_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create backup file before deletion
|
||||||
|
fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
|
||||||
|
// Get backup directory
|
||||||
|
let backup_dir = get_backup_dir()?;
|
||||||
|
fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
|
||||||
|
|
||||||
|
// Generate backup file name with timestamp
|
||||||
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||||
|
let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
|
||||||
|
|
||||||
|
// Create backup data
|
||||||
|
let backup_info = BackupInfo {
|
||||||
|
backup_path: backup_file.to_string_lossy().to_string(),
|
||||||
|
timestamp: timestamp.clone(),
|
||||||
|
conflicts: conflicts.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write backup file
|
||||||
|
let json = serde_json::to_string_pretty(&backup_info)
|
||||||
|
.map_err(|e| format!("序列化备份数据失败: {e}"))?;
|
||||||
|
|
||||||
|
fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(backup_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get backup directory path
|
||||||
|
fn get_backup_dir() -> Result<PathBuf, String> {
|
||||||
|
let home = dirs::home_dir().ok_or("无法获取用户主目录")?;
|
||||||
|
Ok(home.join(".cc-switch").join("backups"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a single environment variable
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"system" => {
|
||||||
|
if conflict.source_path.contains("HKEY_CURRENT_USER") {
|
||||||
|
let hkcu = RegKey::predef(HKEY_CURRENT_USER)
|
||||||
|
.open_subkey_with_flags("Environment", KEY_ALL_ACCESS)
|
||||||
|
.map_err(|e| format!("打开注册表失败: {}", e))?;
|
||||||
|
|
||||||
|
hkcu.delete_value(&conflict.var_name)
|
||||||
|
.map_err(|e| format!("删除注册表项失败: {}", e))?;
|
||||||
|
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.open_subkey_with_flags(
|
||||||
|
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||||
|
KEY_ALL_ACCESS,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
|
||||||
|
|
||||||
|
hklm.delete_value(&conflict.var_name)
|
||||||
|
.map_err(|e| format!("删除系统注册表项失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"file" => Err("Windows 系统不应该有文件类型的环境变量".to_string()),
|
||||||
|
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"file" => {
|
||||||
|
// Parse file path and line number from source_path (format: "path:line")
|
||||||
|
let parts: Vec<&str> = conflict.source_path.split(':').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return Err("无效的文件路径格式".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = parts[0];
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let content = fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
// Filter out the line containing the environment variable
|
||||||
|
let new_content: Vec<String> = content
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
|
||||||
|
|
||||||
|
// Check if this line sets the target variable
|
||||||
|
if let Some(eq_pos) = export_line.find('=') {
|
||||||
|
let var_name = export_line[..eq_pos].trim();
|
||||||
|
var_name != conflict.var_name
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
fs::write(file_path, new_content.join("\n"))
|
||||||
|
.map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"system" => {
|
||||||
|
// On Unix, we can't directly delete process environment variables
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore environment variables from backup
|
||||||
|
pub fn restore_from_backup(backup_path: String) -> Result<(), String> {
|
||||||
|
// Read backup file
|
||||||
|
let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
let backup_info: BackupInfo =
|
||||||
|
serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
|
||||||
|
|
||||||
|
// Restore each variable
|
||||||
|
for conflict in &backup_info.conflicts {
|
||||||
|
restore_single_env(conflict)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a single environment variable
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"system" => {
|
||||||
|
if conflict.source_path.contains("HKEY_CURRENT_USER") {
|
||||||
|
let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER)
|
||||||
|
.create_subkey("Environment")
|
||||||
|
.map_err(|e| format!("打开注册表失败: {}", e))?;
|
||||||
|
|
||||||
|
hkcu.set_value(&conflict.var_name, &conflict.var_value)
|
||||||
|
.map_err(|e| format!("恢复注册表项失败: {}", e))?;
|
||||||
|
} else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") {
|
||||||
|
let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||||
|
.create_subkey(
|
||||||
|
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?;
|
||||||
|
|
||||||
|
hklm.set_value(&conflict.var_name, &conflict.var_value)
|
||||||
|
.map_err(|e| format!("恢复系统注册表项失败: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"无法恢复类型为 {} 的环境变量",
|
||||||
|
conflict.source_type
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
|
||||||
|
match conflict.source_type.as_str() {
|
||||||
|
"file" => {
|
||||||
|
// Parse file path from source_path
|
||||||
|
let parts: Vec<&str> = conflict.source_path.split(':').collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err("无效的文件路径格式".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = parts[0];
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let mut content = fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
// Append the environment variable line
|
||||||
|
let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value);
|
||||||
|
content.push_str(&export_line);
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"无法恢复类型为 {} 的环境变量",
|
||||||
|
conflict.source_type
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backup_dir_creation() {
|
||||||
|
let backup_dir = get_backup_dir();
|
||||||
|
assert!(backup_dir.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
260
src-tauri/src/services/mcp.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::{AppType, McpServer, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::mcp;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// MCP 相关业务逻辑(v3.7.0 统一结构)
|
||||||
|
pub struct McpService;
|
||||||
|
|
||||||
|
impl McpService {
|
||||||
|
/// 获取所有 MCP 服务器(统一结构)
|
||||||
|
pub fn get_all_servers(state: &AppState) -> Result<HashMap<String, McpServer>, AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
|
||||||
|
// 如果是新结构,直接返回
|
||||||
|
if let Some(servers) = &cfg.mcp.servers {
|
||||||
|
return Ok(servers.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 理论上不应该走到这里,因为 load 时会自动迁移
|
||||||
|
Err(AppError::localized(
|
||||||
|
"mcp.old_structure",
|
||||||
|
"检测到旧版 MCP 结构,请重启应用完成迁移",
|
||||||
|
"Old MCP structure detected, please restart app to complete migration",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加或更新 MCP 服务器
|
||||||
|
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
|
||||||
|
{
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
|
||||||
|
// 确保 servers 字段存在
|
||||||
|
if cfg.mcp.servers.is_none() {
|
||||||
|
cfg.mcp.servers = Some(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let servers = cfg.mcp.servers.as_mut().unwrap();
|
||||||
|
let id = server.id.clone();
|
||||||
|
|
||||||
|
// 插入或更新
|
||||||
|
servers.insert(id, server.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
// 同步到各个启用的应用
|
||||||
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 MCP 服务器
|
||||||
|
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||||||
|
let server = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
|
||||||
|
if let Some(servers) = &mut cfg.mcp.servers {
|
||||||
|
servers.remove(id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(server) = server {
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
// 从所有应用的 live 配置中移除
|
||||||
|
Self::remove_server_from_all_apps(state, id, &server)?;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换指定应用的启用状态
|
||||||
|
pub fn toggle_app(
|
||||||
|
state: &AppState,
|
||||||
|
server_id: &str,
|
||||||
|
app: AppType,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let server = {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
|
||||||
|
if let Some(servers) = &mut cfg.mcp.servers {
|
||||||
|
if let Some(server) = servers.get_mut(server_id) {
|
||||||
|
server.apps.set_enabled_for(&app, enabled);
|
||||||
|
Some(server.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(server) = server {
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
// 同步到对应应用
|
||||||
|
if enabled {
|
||||||
|
Self::sync_server_to_app(state, &server, &app)?;
|
||||||
|
} else {
|
||||||
|
Self::remove_server_from_app(state, server_id, &app)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 MCP 服务器同步到所有启用的应用
|
||||||
|
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
|
||||||
|
for app in server.apps.enabled_apps() {
|
||||||
|
Self::sync_server_to_app_internal(&cfg, server, &app)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 MCP 服务器同步到指定应用
|
||||||
|
fn sync_server_to_app(
|
||||||
|
state: &AppState,
|
||||||
|
server: &McpServer,
|
||||||
|
app: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
Self::sync_server_to_app_internal(&cfg, server, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_server_to_app_internal(
|
||||||
|
cfg: &MultiAppConfig,
|
||||||
|
server: &McpServer,
|
||||||
|
app: &AppType,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => {
|
||||||
|
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从所有曾启用过该服务器的应用中移除
|
||||||
|
fn remove_server_from_all_apps(
|
||||||
|
state: &AppState,
|
||||||
|
id: &str,
|
||||||
|
server: &McpServer,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 从所有曾启用的应用中移除
|
||||||
|
for app in server.apps.enabled_apps() {
|
||||||
|
Self::remove_server_from_app(state, id, &app)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
|
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动同步所有启用的 MCP 服务器到对应的应用
|
||||||
|
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
|
||||||
|
for server in servers.values() {
|
||||||
|
Self::sync_server_to_apps(state, server)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 兼容层:支持旧的 v3.6.x 命令(已废弃,将在 v4.0 移除)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// [已废弃] 获取指定应用的 MCP 服务器(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use get_all_servers instead")]
|
||||||
|
pub fn get_servers(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
) -> Result<HashMap<String, serde_json::Value>, AppError> {
|
||||||
|
let all_servers = Self::get_all_servers(state)?;
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
|
||||||
|
for (id, server) in all_servers {
|
||||||
|
if server.apps.is_enabled_for(&app) {
|
||||||
|
result.insert(id, server.server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [已废弃] 设置 MCP 服务器在指定应用的启用状态(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use toggle_app instead")]
|
||||||
|
pub fn set_enabled(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
Self::toggle_app(state, id, app, enabled)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [已废弃] 同步启用的 MCP 到指定应用(兼容旧 API)
|
||||||
|
#[deprecated(since = "3.7.0", note = "Use sync_all_enabled instead")]
|
||||||
|
pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {
|
||||||
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
|
||||||
|
for server in servers.values() {
|
||||||
|
if server.apps.is_enabled_for(&app) {
|
||||||
|
Self::sync_server_to_app(state, server, &app)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Claude 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
|
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let count = mcp::import_from_claude(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
|
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let count = mcp::import_from_codex(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构)
|
||||||
|
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let count = mcp::import_from_gemini(&mut cfg)?;
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src-tauri/src/services/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod env_checker;
|
||||||
|
pub mod env_manager;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod prompt;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod skill;
|
||||||
|
pub mod speedtest;
|
||||||
|
|
||||||
|
pub use config::ConfigService;
|
||||||
|
pub use mcp::McpService;
|
||||||
|
pub use prompt::PromptService;
|
||||||
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
|
pub use skill::{Skill, SkillRepo, SkillService};
|
||||||
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
203
src-tauri/src/services/prompt.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::config::write_text_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::prompt_files::prompt_file_path;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
pub struct PromptService;
|
||||||
|
|
||||||
|
impl PromptService {
|
||||||
|
pub fn get_prompts(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
) -> Result<HashMap<String, Prompt>, AppError> {
|
||||||
|
let cfg = state.config.read()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
Ok(prompts.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_prompt(
|
||||||
|
state: &AppState,
|
||||||
|
app: AppType,
|
||||||
|
id: &str,
|
||||||
|
prompt: Prompt,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 检查是否为已启用的提示词
|
||||||
|
let is_enabled = prompt.enabled;
|
||||||
|
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
prompts.insert(id.to_string(), prompt.clone());
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
|
// 如果是已启用的提示词,同步更新到对应的文件
|
||||||
|
if is_enabled {
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
write_text_file(&target_path, &prompt.content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(prompt) = prompts.get(id) {
|
||||||
|
if prompt.enabled {
|
||||||
|
return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.remove(id);
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
// 回填当前 live 文件内容到已启用的提示词,或创建备份
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
if target_path.exists() {
|
||||||
|
if let Ok(live_content) = std::fs::read_to_string(&target_path) {
|
||||||
|
if !live_content.trim().is_empty() {
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 尝试回填到当前已启用的提示词
|
||||||
|
if let Some((enabled_id, enabled_prompt)) = prompts
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(_, p)| p.enabled)
|
||||||
|
.map(|(id, p)| (id.clone(), p))
|
||||||
|
{
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
enabled_prompt.content = live_content.clone();
|
||||||
|
enabled_prompt.updated_at = Some(timestamp);
|
||||||
|
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
|
||||||
|
drop(cfg); // 释放锁后保存,避免死锁
|
||||||
|
state.save()?; // 第一次保存:回填后立即持久化
|
||||||
|
} else {
|
||||||
|
// 没有已启用的提示词,则创建一次备份(避免重复备份)
|
||||||
|
let content_exists = prompts
|
||||||
|
.values()
|
||||||
|
.any(|p| p.content.trim() == live_content.trim());
|
||||||
|
if !content_exists {
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let backup_id = format!("backup-{timestamp}");
|
||||||
|
let backup_prompt = Prompt {
|
||||||
|
id: backup_id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"原始提示词 {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content: live_content,
|
||||||
|
description: Some("自动备份的原始提示词".to_string()),
|
||||||
|
enabled: false,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
prompts.insert(backup_id.clone(), backup_prompt);
|
||||||
|
log::info!("回填 live 提示词内容,创建备份: {backup_id}");
|
||||||
|
drop(cfg); // 释放锁后保存
|
||||||
|
state.save()?; // 第一次保存:回填后立即持久化
|
||||||
|
} else {
|
||||||
|
// 即使内容已存在,也无需重复备份;但不需要保存任何更改
|
||||||
|
drop(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用目标提示词并写入文件
|
||||||
|
let mut cfg = state.config.write()?;
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut cfg.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut cfg.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
for prompt in prompts.values_mut() {
|
||||||
|
prompt.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prompt) = prompts.get_mut(id) {
|
||||||
|
prompt.enabled = true;
|
||||||
|
write_text_file(&target_path, &prompt.content)?; // 原子写入
|
||||||
|
} else {
|
||||||
|
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
state.save()?; // 第二次保存:启用目标提示词并写入文件后
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err(AppError::Message("提示词文件不存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let id = format!("imported-{timestamp}");
|
||||||
|
let prompt = Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"导入的提示词 {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
description: Some("从现有配置文件导入".to_string()),
|
||||||
|
enabled: false,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::upsert_prompt(state, app, &id, prompt)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
1926
src-tauri/src/services/provider.rs
Normal file
540
src-tauri/src/services/skill.rs
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// 技能对象
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Skill {
|
||||||
|
/// 唯一标识: "owner/name:directory" 或 "local:directory"
|
||||||
|
pub key: String,
|
||||||
|
/// 显示名称 (从 SKILL.md 解析)
|
||||||
|
pub name: String,
|
||||||
|
/// 技能描述
|
||||||
|
pub description: String,
|
||||||
|
/// 目录名称 (安装路径的最后一段)
|
||||||
|
pub directory: String,
|
||||||
|
/// GitHub README URL
|
||||||
|
#[serde(rename = "readmeUrl")]
|
||||||
|
pub readme_url: Option<String>,
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 仓库所有者
|
||||||
|
#[serde(rename = "repoOwner")]
|
||||||
|
pub repo_owner: Option<String>,
|
||||||
|
/// 仓库名称
|
||||||
|
#[serde(rename = "repoName")]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
/// 分支名称
|
||||||
|
#[serde(rename = "repoBranch")]
|
||||||
|
pub repo_branch: Option<String>,
|
||||||
|
/// 技能所在的子目录路径 (可选, 如 "skills")
|
||||||
|
#[serde(rename = "skillsPath")]
|
||||||
|
pub skills_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillRepo {
|
||||||
|
/// GitHub 用户/组织名
|
||||||
|
pub owner: String,
|
||||||
|
/// 仓库名称
|
||||||
|
pub name: String,
|
||||||
|
/// 分支 (默认 "main")
|
||||||
|
pub branch: String,
|
||||||
|
/// 是否启用
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||||
|
#[serde(rename = "skillsPath")]
|
||||||
|
pub skills_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能安装状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillState {
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 安装时间
|
||||||
|
#[serde(rename = "installedAt")]
|
||||||
|
pub installed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化存储结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillStore {
|
||||||
|
/// directory -> 安装状态
|
||||||
|
pub skills: HashMap<String, SkillState>,
|
||||||
|
/// 仓库列表
|
||||||
|
pub repos: Vec<SkillRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
SkillStore {
|
||||||
|
skills: HashMap::new(),
|
||||||
|
repos: vec![
|
||||||
|
SkillRepo {
|
||||||
|
owner: "ComposioHQ".to_string(),
|
||||||
|
name: "awesome-claude-skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "anthropics".to_string(),
|
||||||
|
name: "skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "cexll".to_string(),
|
||||||
|
name: "myclaude".to_string(),
|
||||||
|
branch: "master".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能元数据 (从 SKILL.md 解析)
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SkillMetadata {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillService {
|
||||||
|
http_client: Client,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillService {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let install_dir = Self::get_install_dir()?;
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
fs::create_dir_all(&install_dir)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http_client: Client::builder()
|
||||||
|
.user_agent("cc-switch")
|
||||||
|
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?,
|
||||||
|
install_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_install_dir() -> Result<PathBuf> {
|
||||||
|
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||||
|
Ok(home.join(".claude").join("skills"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心方法实现
|
||||||
|
impl SkillService {
|
||||||
|
/// 列出所有技能
|
||||||
|
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
|
||||||
|
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
|
||||||
|
|
||||||
|
let fetch_tasks = enabled_repos
|
||||||
|
.iter()
|
||||||
|
.map(|repo| self.fetch_repo_skills(repo));
|
||||||
|
|
||||||
|
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
|
||||||
|
|
||||||
|
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||||
|
match result {
|
||||||
|
Ok(repo_skills) => skills.extend(repo_skills),
|
||||||
|
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并本地技能
|
||||||
|
self.merge_local_skills(&mut skills)?;
|
||||||
|
|
||||||
|
// 去重并排序
|
||||||
|
Self::deduplicate_skills(&mut skills);
|
||||||
|
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从仓库获取技能列表
|
||||||
|
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||||
|
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
|
||||||
|
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 确定要扫描的目录路径
|
||||||
|
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 skillsPath,则扫描该子目录
|
||||||
|
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||||
|
if !subdir.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||||
|
repo.owner,
|
||||||
|
repo.name,
|
||||||
|
skills_path
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Ok(skills);
|
||||||
|
}
|
||||||
|
subdir
|
||||||
|
} else {
|
||||||
|
// 否则扫描仓库根目录
|
||||||
|
temp_dir.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 遍历目标目录
|
||||||
|
for entry in fs::read_dir(&scan_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if !skill_md.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析技能元数据
|
||||||
|
match self.parse_skill_metadata(&skill_md) {
|
||||||
|
Ok(meta) => {
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 构建 README URL(考虑 skillsPath)
|
||||||
|
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||||
|
} else {
|
||||||
|
directory.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory,
|
||||||
|
readme_url: Some(format!(
|
||||||
|
"https://github.com/{}/{}/tree/{}/{}",
|
||||||
|
repo.owner, repo.name, repo.branch, readme_path
|
||||||
|
)),
|
||||||
|
installed: false,
|
||||||
|
repo_owner: Some(repo.owner.clone()),
|
||||||
|
repo_name: Some(repo.name.clone()),
|
||||||
|
repo_branch: Some(repo.branch.clone()),
|
||||||
|
skills_path: repo.skills_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析技能元数据
|
||||||
|
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
// 移除 BOM
|
||||||
|
let content = content.trim_start_matches('\u{feff}');
|
||||||
|
|
||||||
|
// 提取 YAML front matter
|
||||||
|
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return Ok(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let front_matter = parts[1].trim();
|
||||||
|
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并本地技能
|
||||||
|
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
|
||||||
|
if !self.install_dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.install_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 更新已安装状态
|
||||||
|
let mut found = false;
|
||||||
|
for skill in skills.iter_mut() {
|
||||||
|
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||||
|
skill.installed = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||||
|
if !found {
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if skill_md.exists() {
|
||||||
|
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("local:{directory}"),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory: directory.clone(),
|
||||||
|
readme_url: None,
|
||||||
|
installed: true,
|
||||||
|
repo_owner: None,
|
||||||
|
repo_name: None,
|
||||||
|
repo_branch: None,
|
||||||
|
skills_path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去重技能列表
|
||||||
|
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
skills.retain(|skill| {
|
||||||
|
let key = skill.directory.to_lowercase();
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||||
|
e.insert(true);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载仓库
|
||||||
|
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||||
|
let temp_dir = tempfile::tempdir()?;
|
||||||
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
|
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
|
||||||
|
|
||||||
|
// 尝试多个分支
|
||||||
|
let branches = if repo.branch.is_empty() {
|
||||||
|
vec!["main", "master"]
|
||||||
|
} else {
|
||||||
|
vec![repo.branch.as_str(), "main", "master"]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
for branch in branches {
|
||||||
|
let url = format!(
|
||||||
|
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||||
|
repo.owner, repo.name, branch
|
||||||
|
);
|
||||||
|
|
||||||
|
match self.download_and_extract(&url, &temp_path).await {
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(temp_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_error = Some(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并解压 ZIP
|
||||||
|
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||||
|
// 下载 ZIP
|
||||||
|
let response = self.http_client.get(url).send().await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||||
|
|
||||||
|
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
|
||||||
|
let root_name = if !archive.is_empty() {
|
||||||
|
let first_file = archive.by_index(0)?;
|
||||||
|
let name = first_file.name();
|
||||||
|
name.split('/').next().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!("空的压缩包"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解压所有文件
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)?;
|
||||||
|
let file_path = file.name();
|
||||||
|
|
||||||
|
// 跳过根目录,直接提取内容
|
||||||
|
let relative_path =
|
||||||
|
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if relative_path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outpath = dest.join(relative_path);
|
||||||
|
|
||||||
|
if file.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath)?;
|
||||||
|
std::io::copy(&mut file, &mut outfile)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||||
|
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||||
|
if dest.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
|
||||||
|
let temp_dir = timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
self.download_repo(&repo),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
|
||||||
|
// 根据 skills_path 确定源目录路径
|
||||||
|
let source = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
|
||||||
|
temp_dir.join(skills_path.trim_matches('/')).join(&directory)
|
||||||
|
} else {
|
||||||
|
// 否则源路径为: temp_dir/directory
|
||||||
|
temp_dir.join(&directory)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !source.exists() {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"技能目录不存在: {}",
|
||||||
|
source.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧版本
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归复制
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归复制目录
|
||||||
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let dest_path = dest.join(entry.file_name());
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
Self::copy_dir_recursive(&path, &dest_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&path, &dest_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||||
|
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出仓库
|
||||||
|
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
|
||||||
|
store.repos.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加仓库
|
||||||
|
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
|
||||||
|
// 检查重复
|
||||||
|
if let Some(pos) = store
|
||||||
|
.repos
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.owner == repo.owner && r.name == repo.name)
|
||||||
|
{
|
||||||
|
store.repos[pos] = repo;
|
||||||
|
} else {
|
||||||
|
store.repos.push(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除仓库
|
||||||
|
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
|
||||||
|
store
|
||||||
|
.repos
|
||||||
|
.retain(|r| !(r.owner == owner && r.name == name));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src-tauri/src/services/speedtest.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
use futures::future::join_all;
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_SECS: u64 = 8;
|
||||||
|
const MAX_TIMEOUT_SECS: u64 = 30;
|
||||||
|
const MIN_TIMEOUT_SECS: u64 = 2;
|
||||||
|
|
||||||
|
/// 端点测速结果
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct EndpointLatency {
|
||||||
|
pub url: String,
|
||||||
|
pub latency: Option<u128>,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络测速相关业务
|
||||||
|
pub struct SpeedtestService;
|
||||||
|
|
||||||
|
impl SpeedtestService {
|
||||||
|
/// 测试一组端点的响应延迟。
|
||||||
|
pub async fn test_endpoints(
|
||||||
|
urls: Vec<String>,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
) -> Result<Vec<EndpointLatency>, AppError> {
|
||||||
|
if urls.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||||
|
let client = Self::build_client(timeout)?;
|
||||||
|
|
||||||
|
let tasks = urls.into_iter().map(|raw_url| {
|
||||||
|
let client = client.clone();
|
||||||
|
async move {
|
||||||
|
let trimmed = raw_url.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: raw_url,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some("URL 不能为空".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed_url = match Url::parse(&trimmed) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(err) => {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some(format!("URL 无效: {err}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||||
|
let _ = client.get(parsed_url.clone()).send().await;
|
||||||
|
|
||||||
|
// 第二次请求开始计时,并将其作为结果返回。
|
||||||
|
let start = Instant::now();
|
||||||
|
match client.get(parsed_url).send().await {
|
||||||
|
Ok(resp) => EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: Some(start.elapsed().as_millis()),
|
||||||
|
status: Some(resp.status().as_u16()),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
let status = err.status().map(|s| s.as_u16());
|
||||||
|
let error_message = if err.is_timeout() {
|
||||||
|
"请求超时".to_string()
|
||||||
|
} else if err.is_connect() {
|
||||||
|
"连接失败".to_string()
|
||||||
|
} else {
|
||||||
|
err.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status,
|
||||||
|
error: Some(error_message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(join_all(tasks).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
|
.user_agent("cc-switch-speedtest/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"speedtest.client_create_failed",
|
||||||
|
format!("创建 HTTP 客户端失败: {e}"),
|
||||||
|
format!("Failed to create HTTP client: {e}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||||
|
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||||
|
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_timeout_clamps_values() {
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(1)),
|
||||||
|
MIN_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(999)),
|
||||||
|
MAX_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(Some(10)),
|
||||||
|
10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SpeedtestService::sanitize_timeout(None),
|
||||||
|
DEFAULT_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endpoints_handles_empty_list() {
|
||||||
|
let result =
|
||||||
|
tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5)))
|
||||||
|
.expect("empty list should succeed");
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endpoints_reports_invalid_url() {
|
||||||
|
let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints(
|
||||||
|
vec!["not a url".into(), "".into()],
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.expect("invalid inputs should still succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert!(
|
||||||
|
result[0]
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.starts_with("URL 无效"),
|
||||||
|
"invalid url should yield parse error"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result[1].error.as_deref(),
|
||||||
|
Some("URL 不能为空"),
|
||||||
|
"empty url should report validation error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src-tauri/src/settings.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// 自定义端点配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomEndpoint {
|
||||||
|
pub url: String,
|
||||||
|
pub added_at: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_used: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SecurityAuthSettings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub selected_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SecuritySettings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth: Option<SecurityAuthSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用设置结构,允许覆盖默认配置目录
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppSettings {
|
||||||
|
#[serde(default = "default_show_in_tray")]
|
||||||
|
pub show_in_tray: bool,
|
||||||
|
#[serde(default = "default_minimize_to_tray_on_close")]
|
||||||
|
pub minimize_to_tray_on_close: bool,
|
||||||
|
/// 是否启用 Claude 插件联动
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_claude_plugin_integration: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codex_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub security: Option<SecuritySettings>,
|
||||||
|
/// Claude 自定义端点列表
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
||||||
|
/// Codex 自定义端点列表
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_show_in_tray() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_minimize_to_tray_on_close() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
show_in_tray: true,
|
||||||
|
minimize_to_tray_on_close: true,
|
||||||
|
enable_claude_plugin_integration: false,
|
||||||
|
claude_config_dir: None,
|
||||||
|
codex_config_dir: None,
|
||||||
|
gemini_config_dir: None,
|
||||||
|
language: None,
|
||||||
|
security: None,
|
||||||
|
custom_endpoints_claude: HashMap::new(),
|
||||||
|
custom_endpoints_codex: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppSettings {
|
||||||
|
fn settings_path() -> PathBuf {
|
||||||
|
// settings.json 必须使用固定路径,不能被 app_config_dir 覆盖
|
||||||
|
// 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".cc-switch")
|
||||||
|
.join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_paths(&mut self) {
|
||||||
|
self.claude_config_dir = self
|
||||||
|
.claude_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
self.codex_config_dir = self
|
||||||
|
.codex_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
self.gemini_config_dir = self
|
||||||
|
.gemini_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
self.language = self
|
||||||
|
.language
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| matches!(*s, "en" | "zh"))
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = Self::settings_path();
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
match serde_json::from_str::<AppSettings>(&content) {
|
||||||
|
Ok(mut settings) => {
|
||||||
|
settings.normalize_paths();
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
|
let mut normalized = self.clone();
|
||||||
|
normalized.normalize_paths();
|
||||||
|
let path = Self::settings_path();
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&normalized)
|
||||||
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_store() -> &'static RwLock<AppSettings> {
|
||||||
|
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
|
||||||
|
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_override_path(raw: &str) -> PathBuf {
|
||||||
|
if raw == "~" {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(stripped);
|
||||||
|
}
|
||||||
|
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
return home.join(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_settings() -> AppSettings {
|
||||||
|
settings_store().read().expect("读取设置锁失败").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||||
|
new_settings.normalize_paths();
|
||||||
|
new_settings.save()?;
|
||||||
|
|
||||||
|
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||||
|
*guard = new_settings;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||||
|
let mut settings = get_settings();
|
||||||
|
let current = settings
|
||||||
|
.security
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sec| sec.auth.as_ref())
|
||||||
|
.and_then(|auth| auth.selected_type.as_deref());
|
||||||
|
|
||||||
|
if current == Some(selected_type) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut security = settings.security.unwrap_or_default();
|
||||||
|
let mut auth = security.auth.unwrap_or_default();
|
||||||
|
auth.selected_type = Some(selected_type.to_string());
|
||||||
|
security.auth = Some(auth);
|
||||||
|
settings.security = Some(security);
|
||||||
|
|
||||||
|
update_settings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_claude_override_dir() -> Option<PathBuf> {
|
||||||
|
let settings = settings_store().read().ok()?;
|
||||||
|
settings
|
||||||
|
.claude_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| resolve_override_path(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_codex_override_dir() -> Option<PathBuf> {
|
||||||
|
let settings = settings_store().read().ok()?;
|
||||||
|
settings
|
||||||
|
.codex_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| resolve_override_path(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||||
|
let settings = settings_store().read().ok()?;
|
||||||
|
settings
|
||||||
|
.gemini_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| resolve_override_path(p))
|
||||||
|
}
|
||||||
@@ -1,30 +1,25 @@
|
|||||||
use crate::app_config::MultiAppConfig;
|
use crate::app_config::MultiAppConfig;
|
||||||
use std::sync::Mutex;
|
use crate::error::AppError;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
/// 全局应用状态
|
/// 全局应用状态
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<MultiAppConfig>,
|
pub config: RwLock<MultiAppConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// 创建新的应用状态
|
/// 创建新的应用状态
|
||||||
pub fn new() -> Self {
|
/// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。
|
||||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
pub fn try_new() -> Result<Self, AppError> {
|
||||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
let config = MultiAppConfig::load()?;
|
||||||
MultiAppConfig::default()
|
Ok(Self {
|
||||||
});
|
config: RwLock::new(config),
|
||||||
|
})
|
||||||
Self {
|
|
||||||
config: Mutex::new(config),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), AppError> {
|
||||||
let config = self
|
let config = self.config.read().map_err(AppError::from)?;
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
|
||||||
|
|
||||||
config.save()
|
config.save()
|
||||||
}
|
}
|
||||||
|
|||||||
396
src-tauri/src/usage_script.rs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use rquickjs::{Context, Function, Runtime};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// 执行用量查询脚本
|
||||||
|
pub async fn execute_usage_script(
|
||||||
|
script_code: &str,
|
||||||
|
api_key: &str,
|
||||||
|
base_url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
) -> Result<Value, AppError> {
|
||||||
|
// 1. 替换变量
|
||||||
|
let mut replaced = script_code
|
||||||
|
.replace("{{apiKey}}", api_key)
|
||||||
|
.replace("{{baseUrl}}", base_url);
|
||||||
|
|
||||||
|
// 替换 accessToken 和 userId
|
||||||
|
if let Some(token) = access_token {
|
||||||
|
replaced = replaced.replace("{{accessToken}}", token);
|
||||||
|
}
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
replaced = replaced.replace("{{userId}}", uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
|
let request_config = {
|
||||||
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.runtime_create_failed",
|
||||||
|
format!("创建 JS 运行时失败: {e}"),
|
||||||
|
format!("Failed to create JS runtime: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let context = Context::full(&runtime).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.context_create_failed",
|
||||||
|
format!("创建 JS 上下文失败: {e}"),
|
||||||
|
format!("Failed to create JS context: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
context.with(|ctx| {
|
||||||
|
// 执行用户代码,获取配置对象
|
||||||
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.config_parse_failed",
|
||||||
|
format!("解析配置失败: {e}"),
|
||||||
|
format!("Failed to parse config: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 提取 request 配置
|
||||||
|
let request: rquickjs::Object = config.get("request").map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.request_missing",
|
||||||
|
format!("缺少 request 配置: {e}"),
|
||||||
|
format!("Missing request config: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 将 request 转换为 JSON 字符串
|
||||||
|
let request_json: String = ctx
|
||||||
|
.json_stringify(request)
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.request_serialize_failed",
|
||||||
|
format!("序列化 request 失败: {e}"),
|
||||||
|
format!("Failed to serialize request: {e}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.serialize_none",
|
||||||
|
"序列化返回 None",
|
||||||
|
"Serialization returned None",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.get()
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.get_string_failed",
|
||||||
|
format!("获取字符串失败: {e}"),
|
||||||
|
format!("Failed to get string: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok::<_, AppError>(request_json)
|
||||||
|
})?
|
||||||
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
|
// 3. 解析 request 配置
|
||||||
|
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.request_format_invalid",
|
||||||
|
format!("request 配置格式错误: {e}"),
|
||||||
|
format!("Invalid request config format: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 4. 发送 HTTP 请求
|
||||||
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
|
|
||||||
|
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||||
|
let result: Value = {
|
||||||
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.runtime_create_failed",
|
||||||
|
format!("创建 JS 运行时失败: {e}"),
|
||||||
|
format!("Failed to create JS runtime: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let context = Context::full(&runtime).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.context_create_failed",
|
||||||
|
format!("创建 JS 上下文失败: {e}"),
|
||||||
|
format!("Failed to create JS context: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
context.with(|ctx| {
|
||||||
|
// 重新 eval 获取配置对象
|
||||||
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.config_reparse_failed",
|
||||||
|
format!("重新解析配置失败: {e}"),
|
||||||
|
format!("Failed to re-parse config: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 提取 extractor 函数
|
||||||
|
let extractor: Function = config.get("extractor").map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.extractor_missing",
|
||||||
|
format!("缺少 extractor 函数: {e}"),
|
||||||
|
format!("Missing extractor function: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 将响应数据转换为 JS 值
|
||||||
|
let response_js: rquickjs::Value =
|
||||||
|
ctx.json_parse(response_data.as_str()).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.response_parse_failed",
|
||||||
|
format!("解析响应 JSON 失败: {e}"),
|
||||||
|
format!("Failed to parse response JSON: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 调用 extractor(response)
|
||||||
|
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.extractor_exec_failed",
|
||||||
|
format!("执行 extractor 失败: {e}"),
|
||||||
|
format!("Failed to execute extractor: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 转换为 JSON 字符串
|
||||||
|
let result_json: String = ctx
|
||||||
|
.json_stringify(result_js)
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.result_serialize_failed",
|
||||||
|
format!("序列化结果失败: {e}"),
|
||||||
|
format!("Failed to serialize result: {e}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.serialize_none",
|
||||||
|
"序列化返回 None",
|
||||||
|
"Serialization returned None",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.get()
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.get_string_failed",
|
||||||
|
format!("获取字符串失败: {e}"),
|
||||||
|
format!("Failed to get string: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 解析为 serde_json::Value
|
||||||
|
serde_json::from_str(&result_json).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.json_parse_failed",
|
||||||
|
format!("JSON 解析失败: {e}"),
|
||||||
|
format!("JSON parse failed: {e}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
|
// 6. 验证返回值格式
|
||||||
|
validate_result(&result)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求配置结构
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct RequestConfig {
|
||||||
|
url: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送 HTTP 请求
|
||||||
|
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||||
|
// 约束超时范围,防止异常配置导致长时间阻塞
|
||||||
|
let timeout = timeout_secs.clamp(2, 30);
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.client_create_failed",
|
||||||
|
format!("创建客户端失败: {e}"),
|
||||||
|
format!("Failed to create client: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||||
|
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.invalid_http_method",
|
||||||
|
format!("不支持的 HTTP 方法: {}", config.method),
|
||||||
|
format!("Unsupported HTTP method: {}", config.method),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut req = client.request(method.clone(), &config.url);
|
||||||
|
|
||||||
|
// 添加请求头
|
||||||
|
for (k, v) in &config.headers {
|
||||||
|
req = req.header(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加请求体
|
||||||
|
if let Some(body) = &config.body {
|
||||||
|
req = req.body(body.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
let resp = req.send().await.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.request_failed",
|
||||||
|
format!("请求失败: {e}"),
|
||||||
|
format!("Request failed: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.read_response_failed",
|
||||||
|
format!("读取响应失败: {e}"),
|
||||||
|
format!("Failed to read response: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let preview = if text.len() > 200 {
|
||||||
|
format!("{}...", &text[..200])
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.http_error",
|
||||||
|
format!("HTTP {status} : {preview}"),
|
||||||
|
format!("HTTP {status} : {preview}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证脚本返回值(支持单对象或数组)
|
||||||
|
fn validate_result(result: &Value) -> Result<(), AppError> {
|
||||||
|
// 如果是数组,验证每个元素
|
||||||
|
if let Some(arr) = result.as_array() {
|
||||||
|
if arr.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.empty_array",
|
||||||
|
"脚本返回的数组不能为空",
|
||||||
|
"Script returned empty array",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (idx, item) in arr.iter().enumerate() {
|
||||||
|
validate_single_usage(item).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.array_validation_failed",
|
||||||
|
format!("数组索引[{idx}]验证失败: {e}"),
|
||||||
|
format!("Validation failed at index [{idx}]: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是单对象,直接验证(向后兼容)
|
||||||
|
validate_single_usage(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证单个用量数据对象
|
||||||
|
fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
||||||
|
let obj = result.as_object().ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.must_return_object",
|
||||||
|
"脚本必须返回对象或对象数组",
|
||||||
|
"Script must return object or array of objects",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 所有字段均为可选,只进行类型检查
|
||||||
|
if obj.contains_key("isValid")
|
||||||
|
&& !result["isValid"].is_null()
|
||||||
|
&& !result["isValid"].is_boolean()
|
||||||
|
{
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.isvalid_type_error",
|
||||||
|
"isValid 必须是布尔值或 null",
|
||||||
|
"isValid must be boolean or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("invalidMessage")
|
||||||
|
&& !result["invalidMessage"].is_null()
|
||||||
|
&& !result["invalidMessage"].is_string()
|
||||||
|
{
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.invalidmessage_type_error",
|
||||||
|
"invalidMessage 必须是字符串或 null",
|
||||||
|
"invalidMessage must be string or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("remaining")
|
||||||
|
&& !result["remaining"].is_null()
|
||||||
|
&& !result["remaining"].is_number()
|
||||||
|
{
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.remaining_type_error",
|
||||||
|
"remaining 必须是数字或 null",
|
||||||
|
"remaining must be number or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.unit_type_error",
|
||||||
|
"unit 必须是字符串或 null",
|
||||||
|
"unit must be string or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.total_type_error",
|
||||||
|
"total 必须是数字或 null",
|
||||||
|
"total must be number or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.used_type_error",
|
||||||
|
"used 必须是数字或 null",
|
||||||
|
"used must be number or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("planName")
|
||||||
|
&& !result["planName"].is_null()
|
||||||
|
&& !result["planName"].is_string()
|
||||||
|
{
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.planname_type_error",
|
||||||
|
"planName 必须是字符串或 null",
|
||||||
|
"planName must be string or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.extra_type_error",
|
||||||
|
"extra 必须是字符串或 null",
|
||||||
|
"extra must be string or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.1.1",
|
"version": "3.7.0",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -12,29 +12,56 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"label": "main",
|
||||||
"title": "",
|
"title": "",
|
||||||
"width": 900,
|
"width": 1000,
|
||||||
"height": 650,
|
"height": 650,
|
||||||
"minWidth": 800,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"titleBarStyle": "Transparent"
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:"
|
"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:",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"windows": {
|
||||||
|
"wix": {
|
||||||
|
"template": "wix/per-user-main.wxs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["ccswitch"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src-tauri/tests/app_config_load.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use cc_switch_lib::{AppError, MultiAppConfig};
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
fn cfg_path() -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||||||
|
PathBuf::from(home).join(".cc-switch").join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_v1_config_returns_error_and_does_not_write() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let path = cfg_path();
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||||
|
|
||||||
|
// 最小 v1 形状:providers + current,且不含 version/apps/mcp
|
||||||
|
let v1_json = r#"{"providers":{},"current":""}"#;
|
||||||
|
fs::write(&path, v1_json).expect("seed v1 json");
|
||||||
|
let before = fs::read_to_string(&path).expect("read before");
|
||||||
|
|
||||||
|
let err = MultiAppConfig::load().expect_err("v1 should not be auto-migrated");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
|
||||||
|
other => panic!("expected Localized v1 error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件不应有任何变化,且不应生成 .bak
|
||||||
|
let after = fs::read_to_string(&path).expect("read after");
|
||||||
|
assert_eq!(before, after, "config.json should not be modified");
|
||||||
|
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||||
|
assert!(!bak.exists(), ".bak should not be created on load error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_v1_with_extra_version_still_treated_as_v1() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let path = cfg_path();
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||||
|
|
||||||
|
// 畸形:包含 providers + current + version,但没有 apps,应按 v1 处理
|
||||||
|
let v1_like = r#"{"providers":{},"current":"","version":2}"#;
|
||||||
|
std::fs::write(&path, v1_like).expect("seed v1-like json");
|
||||||
|
let before = std::fs::read_to_string(&path).expect("read before");
|
||||||
|
|
||||||
|
let err = MultiAppConfig::load().expect_err("v1-like should not be parsed as v2");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
|
||||||
|
other => panic!("expected Localized v1 error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = std::fs::read_to_string(&path).expect("read after");
|
||||||
|
assert_eq!(before, after, "config.json should not be modified");
|
||||||
|
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||||
|
assert!(!bak.exists(), ".bak should not be created on v1-like error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_invalid_json_returns_parse_error_and_does_not_write() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let path = cfg_path();
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||||
|
|
||||||
|
fs::write(&path, "{not json").expect("seed invalid json");
|
||||||
|
let before = fs::read_to_string(&path).expect("read before");
|
||||||
|
|
||||||
|
let err = MultiAppConfig::load().expect_err("invalid json should error");
|
||||||
|
match err {
|
||||||
|
AppError::Json { .. } => {}
|
||||||
|
other => panic!("expected Json error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = fs::read_to_string(&path).expect("read after");
|
||||||
|
assert_eq!(before, after, "config.json should remain unchanged");
|
||||||
|
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||||
|
assert!(!bak.exists(), ".bak should not be created on parse error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_valid_v2_config_succeeds() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
let path = cfg_path();
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||||
|
|
||||||
|
// 使用默认结构序列化为 v2
|
||||||
|
let default_cfg = MultiAppConfig::default();
|
||||||
|
let json = serde_json::to_string_pretty(&default_cfg).expect("serialize default cfg");
|
||||||
|
fs::write(&path, json).expect("write v2 json");
|
||||||
|
|
||||||
|
let loaded = MultiAppConfig::load().expect("v2 should load successfully");
|
||||||
|
assert_eq!(loaded.version, 2);
|
||||||
|
assert!(loaded
|
||||||
|
.get_manager(&cc_switch_lib::AppType::Claude)
|
||||||
|
.is_some());
|
||||||
|
assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some());
|
||||||
|
}
|
||||||
22
src-tauri/tests/app_type_parse.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use cc_switch_lib::AppType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_known_apps_case_insensitive_and_trim() {
|
||||||
|
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
|
||||||
|
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
|
||||||
|
assert!(matches!(
|
||||||
|
AppType::from_str(" ClAuDe \n"),
|
||||||
|
Ok(AppType::Claude)
|
||||||
|
));
|
||||||
|
assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_app_returns_localized_error_message() {
|
||||||
|
let err = AppType::from_str("unknown").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("可选值") || msg.contains("Allowed"));
|
||||||
|
assert!(msg.contains("unknown"));
|
||||||
|
}
|
||||||
121
src-tauri/tests/deeplink_import.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeplink_import_claude_provider_persists_to_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4";
|
||||||
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Claude);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
|
.expect("import provider from deeplink");
|
||||||
|
|
||||||
|
// 验证内存状态
|
||||||
|
let guard = state.config.read().expect("read config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager should exist");
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.expect("provider created via deeplink");
|
||||||
|
assert_eq!(provider.name, request.name);
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url.as_deref(),
|
||||||
|
Some(request.homepage.as_str())
|
||||||
|
);
|
||||||
|
let auth_token = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_AUTH_TOKEN")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
let base_url = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
assert_eq!(auth_token, Some(request.api_key.as_str()));
|
||||||
|
assert_eq!(base_url, Some(request.endpoint.as_str()));
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// 验证配置已持久化
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing provider from deeplink should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeplink_import_codex_provider_builds_auth_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o";
|
||||||
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Codex);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
|
.expect("import provider from deeplink");
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("read config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager should exist");
|
||||||
|
let provider = manager
|
||||||
|
.providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.expect("provider created via deeplink");
|
||||||
|
assert_eq!(provider.name, request.name);
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url.as_deref(),
|
||||||
|
Some(request.homepage.as_str())
|
||||||
|
);
|
||||||
|
let auth_value = provider
|
||||||
|
.settings_config
|
||||||
|
.pointer("/auth/OPENAI_API_KEY")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
let config_text = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(auth_value, Some(request.api_key.as_str()));
|
||||||
|
assert!(
|
||||||
|
config_text.contains(request.endpoint.as_str()),
|
||||||
|
"config.toml content should contain endpoint"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config_text.contains("model = \"gpt-4o\""),
|
||||||
|
"config.toml content should contain model setting"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing provider from deeplink should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
1107
src-tauri/tests/import_export_sync.rs
Normal file
256
src-tauri/tests/mcp_commands.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use std::{collections::HashMap, fs, sync::RwLock};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||||
|
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_default_config_claude_persists_provider() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let settings = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&settings).expect("serialize settings"),
|
||||||
|
)
|
||||||
|
.expect("seed claude settings.json");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Claude);
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
import_default_config_test_hook(&state, AppType::Claude)
|
||||||
|
.expect("import default config succeeds");
|
||||||
|
|
||||||
|
// 验证内存状态
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager present");
|
||||||
|
assert_eq!(manager.current, "default");
|
||||||
|
let default_provider = manager.providers.get("default").expect("default provider");
|
||||||
|
assert_eq!(
|
||||||
|
default_provider.settings_config, settings,
|
||||||
|
"default provider should capture live settings"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// 验证配置已持久化
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"importing default config should persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_default_config_without_live_file_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = import_default_config_test_hook(&state, AppType::Claude)
|
||||||
|
.expect_err("missing live file should error");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { zh, .. } => assert!(
|
||||||
|
zh.contains("Claude Code 配置文件不存在"),
|
||||||
|
"unexpected error message: {zh}"
|
||||||
|
),
|
||||||
|
AppError::Message(msg) => assert!(
|
||||||
|
msg.contains("Claude Code 配置文件不存在"),
|
||||||
|
"unexpected error message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
!config_path.exists(),
|
||||||
|
"failed import should not create config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mcp_path = get_claude_mcp_path();
|
||||||
|
let claude_json = json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"echo": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
&mcp_path,
|
||||||
|
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
|
||||||
|
)
|
||||||
|
.expect("seed ~/.claude.json");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
|
||||||
|
assert!(
|
||||||
|
changed > 0,
|
||||||
|
"import should report inserted or normalized entries"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
// v3.7.0: 检查统一结构
|
||||||
|
let servers = guard
|
||||||
|
.mcp
|
||||||
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.expect("unified servers should exist");
|
||||||
|
let entry = servers
|
||||||
|
.get("echo")
|
||||||
|
.expect("server imported into unified structure");
|
||||||
|
assert!(
|
||||||
|
entry.apps.claude,
|
||||||
|
"imported server should have Claude app enabled"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"state.save should persist config.json when changes detected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_mcp_from_claude_invalid_json_preserves_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mcp_path = get_claude_mcp_path();
|
||||||
|
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
|
||||||
|
.expect("seed invalid ~/.claude.json");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err =
|
||||||
|
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
|
||||||
|
match err {
|
||||||
|
AppError::McpValidation(msg) => assert!(
|
||||||
|
msg.contains("解析 ~/.claude.json 失败"),
|
||||||
|
"unexpected error message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("unexpected error variant: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = home.join(".cc-switch").join("config.json");
|
||||||
|
assert!(
|
||||||
|
!config_path.exists(),
|
||||||
|
"failed import should not persist config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
// 创建 Codex 配置目录和文件
|
||||||
|
let codex_dir = home.join(".codex");
|
||||||
|
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||||
|
fs::write(
|
||||||
|
codex_dir.join("auth.json"),
|
||||||
|
r#"{"OPENAI_API_KEY":"test-key"}"#,
|
||||||
|
)
|
||||||
|
.expect("create auth.json");
|
||||||
|
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.ensure_app(&AppType::Codex);
|
||||||
|
|
||||||
|
// v3.7.0: 使用统一结构
|
||||||
|
config.mcp.servers = Some(HashMap::new());
|
||||||
|
config.mcp.servers.as_mut().unwrap().insert(
|
||||||
|
"codex-server".into(),
|
||||||
|
McpServer {
|
||||||
|
id: "codex-server".to_string(),
|
||||||
|
name: "Codex Server".to_string(),
|
||||||
|
server: json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false, // 初始未启用
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
// v3.7.0: 使用 toggle_app 替代 set_enabled
|
||||||
|
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
|
||||||
|
.expect("toggle_app should succeed");
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("lock config");
|
||||||
|
let entry = guard
|
||||||
|
.mcp
|
||||||
|
.servers
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get("codex-server")
|
||||||
|
.expect("codex server exists");
|
||||||
|
assert!(
|
||||||
|
entry.apps.codex,
|
||||||
|
"server should have Codex app enabled after toggle"
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let toml_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
assert!(
|
||||||
|
toml_path.exists(),
|
||||||
|
"enabling server should trigger sync to ~/.codex/config.toml"
|
||||||
|
);
|
||||||
|
let toml_text = fs::read_to_string(&toml_path).expect("read codex config");
|
||||||
|
assert!(
|
||||||
|
toml_text.contains("codex-server"),
|
||||||
|
"codex config should include the enabled server definition"
|
||||||
|
);
|
||||||
|
}
|
||||||
327
src-tauri/tests/provider_commands.rs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
|
||||||
|
write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_codex_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"});
|
||||||
|
let legacy_config = r#"[mcp_servers.legacy]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#;
|
||||||
|
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||||||
|
.expect("seed existing codex live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "stale"},
|
||||||
|
"config": "stale-config"
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Latest".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||||||
|
"config": r#"[mcp_servers.latest]
|
||||||
|
type = "stdio"
|
||||||
|
command = "say"
|
||||||
|
"#
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value =
|
||||||
|
read_json_file(&get_codex_auth_path()).expect("read auth.json");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value
|
||||||
|
.get("OPENAI_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(""),
|
||||||
|
"fresh-key",
|
||||||
|
"live auth.json should reflect new provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
config_text.contains("mcp_servers.echo-server"),
|
||||||
|
"config.toml should contain synced MCP servers"
|
||||||
|
);
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after switch");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
let new_config_text = new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
new_config_text, config_text,
|
||||||
|
"provider config snapshot should match live file"
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
let legacy_auth_value = legacy
|
||||||
|
.settings_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_auth_value, "legacy-key",
|
||||||
|
"previous provider should be backfilled with live auth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_missing_provider_returns_error() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager")
|
||||||
|
.current = "does-not-exist".to_string();
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
|
||||||
|
.expect_err("switching to a missing provider should fail");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("供应商不存在"),
|
||||||
|
"error message should mention missing provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_updates_claude_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = cc_switch_lib::get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let legacy_live = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "legacy-key"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"path": "/tmp/workspace"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
|
||||||
|
)
|
||||||
|
.expect("seed claude live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "stale-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Fresh Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
|
||||||
|
"workspace": { "path": "/tmp/new-workspace" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch_provider_test_hook(&app_state, AppType::Claude, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let live_after: serde_json::Value =
|
||||||
|
read_json_file(&settings_path).expect("read claude live settings");
|
||||||
|
assert_eq!(
|
||||||
|
live_after
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live settings.json should reflect new provider auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after switch");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let legacy_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_provider.settings_config, legacy_live,
|
||||||
|
"previous provider should receive backfilled live config"
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"new provider snapshot should retain fresh auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(locked);
|
||||||
|
|
||||||
|
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||||||
|
let config_path = std::path::Path::new(&home_dir)
|
||||||
|
.join(".cc-switch")
|
||||||
|
.join("config.json");
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"switching provider should persist config.json"
|
||||||
|
);
|
||||||
|
let persisted: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config"))
|
||||||
|
.expect("parse saved config");
|
||||||
|
assert_eq!(
|
||||||
|
persisted
|
||||||
|
.get("claude")
|
||||||
|
.and_then(|claude| claude.get("current"))
|
||||||
|
.and_then(|current| current.as_str()),
|
||||||
|
Some("new-provider"),
|
||||||
|
"saved config.json should record the new current provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert(
|
||||||
|
"invalid".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"invalid".to_string(),
|
||||||
|
"Broken Codex".to_string(),
|
||||||
|
json!({
|
||||||
|
"config": "[mcp_servers.test]\ncommand = \"noop\""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
|
||||||
|
.expect_err("switching should fail when auth missing");
|
||||||
|
match err {
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("auth"),
|
||||||
|
"expected auth missing error message, got {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after failure");
|
||||||
|
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
|
||||||
|
assert!(
|
||||||
|
manager.current.is_empty(),
|
||||||
|
"current provider should remain empty on failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
632
src-tauri/tests/provider_service.rs
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
|
||||||
|
MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
fn sanitize_provider_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_codex_updates_live_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
|
||||||
|
let legacy_config = r#"[mcp_servers.legacy]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#;
|
||||||
|
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||||||
|
.expect("seed existing codex live config");
|
||||||
|
|
||||||
|
let mut initial_config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = initial_config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "stale"},
|
||||||
|
"config": "stale-config"
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Latest".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||||||
|
"config": r#"[mcp_servers.latest]
|
||||||
|
type = "stdio"
|
||||||
|
command = "say"
|
||||||
|
"#
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(initial_config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Codex, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value =
|
||||||
|
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live auth.json should reflect new provider"
|
||||||
|
);
|
||||||
|
|
||||||
|
let config_text =
|
||||||
|
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
config_text.contains("mcp_servers.echo-server"),
|
||||||
|
"config.toml should contain synced MCP servers"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state.config.read().expect("read config after switch");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let new_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("new-provider")
|
||||||
|
.expect("new provider exists");
|
||||||
|
let new_config_text = new_provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
new_config_text, config_text,
|
||||||
|
"provider config snapshot should match live file"
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
let legacy_auth_value = legacy
|
||||||
|
.settings_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_auth_value, "legacy-key",
|
||||||
|
"previous provider should be backfilled with live auth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_packycode_gemini_updates_security_selected_type() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "packy-gemini".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"packy-gemini".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"packy-gemini".to_string(),
|
||||||
|
"PackyCode".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "pk-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some("https://www.packyapi.com".to_string()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
|
||||||
|
.expect("switching to PackyCode Gemini should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("gemini-api-key"),
|
||||||
|
"PackyCode Gemini should set security.auth.selectedType"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "packy-meta".to_string();
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"packy-meta".to_string(),
|
||||||
|
"Generic Gemini".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "pk-meta",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
partner_promotion_key: Some("packycode".to_string()),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
});
|
||||||
|
manager.providers.insert("packy-meta".to_string(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
|
||||||
|
.expect("switching to partner meta provider should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("gemini-api-key"),
|
||||||
|
"Partner meta should set security.auth.selectedType even without packy keywords"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_google_official_gemini_sets_oauth_security() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Gemini)
|
||||||
|
.expect("gemini manager");
|
||||||
|
manager.current = "google-official".to_string();
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"google-official".to_string(),
|
||||||
|
"Google".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {}
|
||||||
|
}),
|
||||||
|
Some("https://ai.google.dev".to_string()),
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
partner_promotion_key: Some("google-official".to_string()),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
});
|
||||||
|
manager
|
||||||
|
.providers
|
||||||
|
.insert("google-official".to_string(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||||||
|
.expect("switching to Google official Gemini should succeed");
|
||||||
|
|
||||||
|
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
settings_path.exists(),
|
||||||
|
"settings.json should exist at {}",
|
||||||
|
settings_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
|
||||||
|
assert_eq!(
|
||||||
|
value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"Google official Gemini should set oauth-personal selectedType in app settings"
|
||||||
|
);
|
||||||
|
|
||||||
|
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||||
|
assert!(
|
||||||
|
gemini_settings.exists(),
|
||||||
|
"Gemini settings.json should exist at {}",
|
||||||
|
gemini_settings.display()
|
||||||
|
);
|
||||||
|
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||||
|
let gemini_value: serde_json::Value =
|
||||||
|
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
gemini_value
|
||||||
|
.pointer("/security/auth/selectedType")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("oauth-personal"),
|
||||||
|
"Gemini settings json should also reflect oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_claude_updates_live_and_state() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||||||
|
}
|
||||||
|
let legacy_live = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "legacy-key"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"path": "/tmp/workspace"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
&settings_path,
|
||||||
|
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
|
||||||
|
)
|
||||||
|
.expect("seed claude live config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "old-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"old-provider".to_string(),
|
||||||
|
"Legacy Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "stale-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"new-provider".to_string(),
|
||||||
|
"Fresh Claude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
|
||||||
|
"workspace": { "path": "/tmp/new-workspace" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::switch(&state, AppType::Claude, "new-provider")
|
||||||
|
.expect("switch provider should succeed");
|
||||||
|
|
||||||
|
let live_after: serde_json::Value =
|
||||||
|
read_json_file(&settings_path).expect("read claude live settings");
|
||||||
|
assert_eq!(
|
||||||
|
live_after
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("fresh-key"),
|
||||||
|
"live settings.json should reflect new provider auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
let guard = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.expect("read claude config after switch");
|
||||||
|
let manager = guard
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager after switch");
|
||||||
|
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||||
|
|
||||||
|
let legacy_provider = manager
|
||||||
|
.providers
|
||||||
|
.get("old-provider")
|
||||||
|
.expect("legacy provider still exists");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_provider.settings_config, legacy_live,
|
||||||
|
"previous provider should receive backfilled live config"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_missing_provider_returns_error() {
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(MultiAppConfig::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::switch(&state, AppType::Claude, "missing")
|
||||||
|
.expect_err("switching missing provider should fail");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"),
|
||||||
|
other => panic!("expected Localized error for provider not found, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_switch_codex_missing_auth_returns_error() {
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert(
|
||||||
|
"invalid".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"invalid".to_string(),
|
||||||
|
"Broken Codex".to_string(),
|
||||||
|
json!({
|
||||||
|
"config": "[mcp_servers.test]\ncommand = \"noop\""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
|
||||||
|
.expect_err("switching should fail without auth");
|
||||||
|
match err {
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("auth"),
|
||||||
|
"expected auth related message, got {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_codex_removes_provider_and_files() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "keep-key"},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"to-delete".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"to-delete".to_string(),
|
||||||
|
"DeleteCodex".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {"OPENAI_API_KEY": "delete-key"},
|
||||||
|
"config": ""
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = sanitize_provider_name("DeleteCodex");
|
||||||
|
let codex_dir = home.join(".codex");
|
||||||
|
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||||
|
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
|
||||||
|
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
|
||||||
|
std::fs::write(&auth_path, "{}").expect("seed auth file");
|
||||||
|
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
|
||||||
|
.expect("delete provider should succeed");
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after delete");
|
||||||
|
let manager = locked.get_manager(&AppType::Codex).expect("codex manager");
|
||||||
|
assert!(
|
||||||
|
!manager.providers.contains_key("to-delete"),
|
||||||
|
"provider entry should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!auth_path.exists() && !cfg_path.exists(),
|
||||||
|
"provider-specific files should be deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_claude_removes_provider_files() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manager.providers.insert(
|
||||||
|
"delete".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"delete".to_string(),
|
||||||
|
"DeleteClaude".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "delete-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = sanitize_provider_name("DeleteClaude");
|
||||||
|
let claude_dir = home.join(".claude");
|
||||||
|
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
|
||||||
|
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
|
||||||
|
let by_id = claude_dir.join("settings-delete.json");
|
||||||
|
std::fs::write(&by_name, "{}").expect("seed settings by name");
|
||||||
|
std::fs::write(&by_id, "{}").expect("seed settings by id");
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
|
||||||
|
|
||||||
|
let locked = app_state.config.read().expect("lock config after delete");
|
||||||
|
let manager = locked
|
||||||
|
.get_manager(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
assert!(
|
||||||
|
!manager.providers.contains_key("delete"),
|
||||||
|
"claude provider should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!by_name.exists() && !by_id.exists(),
|
||||||
|
"provider config files should be deleted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_service_delete_current_provider_returns_error() {
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "keep".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"keep".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"keep".to_string(),
|
||||||
|
"Keep".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
|
||||||
|
.expect_err("deleting current provider should fail");
|
||||||
|
match err {
|
||||||
|
AppError::Localized { zh, .. } => assert!(
|
||||||
|
zh.contains("不能删除当前正在使用的供应商"),
|
||||||
|
"unexpected message: {zh}"
|
||||||
|
),
|
||||||
|
AppError::Config(msg) => assert!(
|
||||||
|
msg.contains("不能删除当前正在使用的供应商"),
|
||||||
|
"unexpected message: {msg}"
|
||||||
|
),
|
||||||
|
other => panic!("expected Config error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src-tauri/tests/support.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use cc_switch_lib::{update_settings, AppSettings};
|
||||||
|
|
||||||
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
|
static HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
HOME.get_or_init(|| {
|
||||||
|
let base = std::env::temp_dir().join("cc-switch-test-home");
|
||||||
|
if base.exists() {
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&base).expect("create test home");
|
||||||
|
std::env::set_var("HOME", &base);
|
||||||
|
#[cfg(windows)]
|
||||||
|
std::env::set_var("USERPROFILE", &base);
|
||||||
|
base
|
||||||
|
})
|
||||||
|
.as_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理测试目录中生成的配置文件与缓存。
|
||||||
|
pub fn reset_test_fs() {
|
||||||
|
let home = ensure_test_home();
|
||||||
|
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
|
||||||
|
let path = home.join(sub);
|
||||||
|
if path.exists() {
|
||||||
|
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||||
|
eprintln!("failed to clean {}: {}", path.display(), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let claude_json = home.join(".claude.json");
|
||||||
|
if claude_json.exists() {
|
||||||
|
let _ = std::fs::remove_file(&claude_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
|
||||||
|
let _ = update_settings(AppSettings::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。
|
||||||
|
pub fn test_mutex() -> &'static Mutex<()> {
|
||||||
|
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
MUTEX.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
360
src-tauri/wix/per-user-main.wxs
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<?if $(sys.BUILDARCH)="x86"?>
|
||||||
|
<?define Win64 = "no" ?>
|
||||||
|
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
|
||||||
|
<?elseif $(sys.BUILDARCH)="x64"?>
|
||||||
|
<?define Win64 = "yes" ?>
|
||||||
|
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||||
|
<?elseif $(sys.BUILDARCH)="arm64"?>
|
||||||
|
<?define Win64 = "yes" ?>
|
||||||
|
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||||
|
<?else?>
|
||||||
|
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
|
||||||
|
<?endif?>
|
||||||
|
|
||||||
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||||
|
<Product
|
||||||
|
Id="*"
|
||||||
|
Name="{{product_name}}"
|
||||||
|
UpgradeCode="{{upgrade_code}}"
|
||||||
|
Language="!(loc.TauriLanguage)"
|
||||||
|
Manufacturer="{{manufacturer}}"
|
||||||
|
Version="{{version}}">
|
||||||
|
|
||||||
|
<Package Id="*"
|
||||||
|
Keywords="Installer"
|
||||||
|
InstallerVersion="450"
|
||||||
|
Languages="0"
|
||||||
|
Compressed="yes"
|
||||||
|
InstallScope="perUser"
|
||||||
|
InstallPrivileges="limited"
|
||||||
|
SummaryCodepage="!(loc.TauriCodepage)"/>
|
||||||
|
|
||||||
|
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
|
||||||
|
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
|
||||||
|
<Property Id="REINSTALLMODE" Value="amus" />
|
||||||
|
|
||||||
|
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
|
||||||
|
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
|
||||||
|
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
|
||||||
|
<Property Id="LAUNCHAPPARGS" Secure="yes" />
|
||||||
|
|
||||||
|
{{#if allow_downgrades}}
|
||||||
|
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
|
||||||
|
{{else}}
|
||||||
|
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
|
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
|
||||||
|
|
||||||
|
{{#if banner_path}}
|
||||||
|
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
|
||||||
|
{{/if}}
|
||||||
|
{{#if dialog_image_path}}
|
||||||
|
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
|
||||||
|
{{/if}}
|
||||||
|
{{#if license}}
|
||||||
|
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
|
||||||
|
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
|
||||||
|
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
|
||||||
|
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
|
||||||
|
|
||||||
|
{{#if homepage}}
|
||||||
|
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
|
||||||
|
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
|
||||||
|
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<Property Id="INSTALLDIR">
|
||||||
|
<!-- First attempt: Search for "InstallDir" -->
|
||||||
|
<RegistrySearch Id="PrevInstallDirWithName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
|
||||||
|
|
||||||
|
<!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->
|
||||||
|
<RegistrySearch Id="PrevInstallDirNoName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
<!-- launch app checkbox -->
|
||||||
|
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
|
||||||
|
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
|
||||||
|
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
|
||||||
|
|
||||||
|
<UI>
|
||||||
|
<!-- launch app checkbox -->
|
||||||
|
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
|
||||||
|
|
||||||
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
|
||||||
|
|
||||||
|
{{#unless license}}
|
||||||
|
<!-- Skip license dialog -->
|
||||||
|
<Publish Dialog="WelcomeDlg"
|
||||||
|
Control="Next"
|
||||||
|
Event="NewDialog"
|
||||||
|
Value="InstallDirDlg"
|
||||||
|
Order="2">1</Publish>
|
||||||
|
<Publish Dialog="InstallDirDlg"
|
||||||
|
Control="Back"
|
||||||
|
Event="NewDialog"
|
||||||
|
Value="WelcomeDlg"
|
||||||
|
Order="2">1</Publish>
|
||||||
|
{{/unless}}
|
||||||
|
</UI>
|
||||||
|
|
||||||
|
<UIRef Id="WixUI_InstallDir" />
|
||||||
|
|
||||||
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
|
<Directory Id="DesktopFolder" Name="Desktop">
|
||||||
|
<Component Id="ApplicationShortcutDesktop" Guid="*">
|
||||||
|
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
|
||||||
|
<RemoveFolder Id="DesktopFolder" On="uninstall" />
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
<Directory Id="LocalAppDataFolder">
|
||||||
|
<Directory Id="TauriLocalAppDataPrograms" Name="Programs">
|
||||||
|
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
<Directory Id="ProgramMenuFolder">
|
||||||
|
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<DirectoryRef Id="INSTALLDIR">
|
||||||
|
<Component Id="RegistryEntries" Guid="*">
|
||||||
|
<RegistryKey Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}">
|
||||||
|
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
|
||||||
|
</RegistryKey>
|
||||||
|
<!-- Change the Root to HKCU for perUser installations -->
|
||||||
|
{{#each deep_link_protocols as |protocol| ~}}
|
||||||
|
<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">
|
||||||
|
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||||
|
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
|
||||||
|
<RegistryKey Key="DefaultIcon">
|
||||||
|
<RegistryValue Type="string" Value=""[!Path]",0" />
|
||||||
|
</RegistryKey>
|
||||||
|
<RegistryKey Key="shell\open\command">
|
||||||
|
<RegistryValue Type="string" Value=""[!Path]" "%1"" />
|
||||||
|
</RegistryKey>
|
||||||
|
</RegistryKey>
|
||||||
|
{{/each~}}
|
||||||
|
</Component>
|
||||||
|
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
|
||||||
|
<File Id="Path" Source="{{main_binary_path}}" KeyPath="no" Checksum="yes"/>
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\{{manufacturer}}\{{product_name}}" Name="PathComponent" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
{{#each file_associations as |association| ~}}
|
||||||
|
{{#each association.ext as |ext| ~}}
|
||||||
|
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
|
||||||
|
<Extension Id="{{ext}}" Advertise="yes">
|
||||||
|
<Verb Id="open" Command="Open with {{../../product_name}}" Argument=""%1"" />
|
||||||
|
</Extension>
|
||||||
|
</ProgId>
|
||||||
|
{{/each~}}
|
||||||
|
{{/each~}}
|
||||||
|
</Component>
|
||||||
|
{{#each binaries as |bin| ~}}
|
||||||
|
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
|
||||||
|
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
|
||||||
|
</Component>
|
||||||
|
{{/each~}}
|
||||||
|
{{#if enable_elevated_update_task}}
|
||||||
|
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
|
||||||
|
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
|
||||||
|
</Component>
|
||||||
|
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
|
||||||
|
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
|
||||||
|
</Component>
|
||||||
|
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
|
||||||
|
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
|
||||||
|
</Component>
|
||||||
|
{{/if}}
|
||||||
|
{{resources}}
|
||||||
|
<Component Id="CMP_UninstallShortcut" Guid="*">
|
||||||
|
|
||||||
|
<Shortcut Id="UninstallShortcut"
|
||||||
|
Name="Uninstall {{product_name}}"
|
||||||
|
Description="Uninstalls {{product_name}}"
|
||||||
|
Target="[System64Folder]msiexec.exe"
|
||||||
|
Arguments="/x [ProductCode]" />
|
||||||
|
|
||||||
|
<RemoveFile Id="RemoveUserProgramsFiles" Directory="TauriLocalAppDataPrograms" Name="*" On="uninstall" />
|
||||||
|
<RemoveFolder Id="RemoveUserProgramsFolder" Directory="TauriLocalAppDataPrograms" On="uninstall" />
|
||||||
|
|
||||||
|
<RemoveFolder Id="INSTALLDIR"
|
||||||
|
On="uninstall" />
|
||||||
|
|
||||||
|
<RegistryValue Root="HKCU"
|
||||||
|
Key="Software\\{{manufacturer}}\\{{product_name}}"
|
||||||
|
Name="Uninstaller Shortcut"
|
||||||
|
Type="integer"
|
||||||
|
Value="1"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<DirectoryRef Id="ApplicationProgramsFolder">
|
||||||
|
<Component Id="ApplicationShortcut" Guid="*">
|
||||||
|
<Shortcut Id="ApplicationStartMenuShortcut"
|
||||||
|
Name="{{product_name}}"
|
||||||
|
Description="Runs {{product_name}}"
|
||||||
|
Target="[!Path]"
|
||||||
|
Icon="ProductIcon"
|
||||||
|
WorkingDirectory="INSTALLDIR">
|
||||||
|
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
|
||||||
|
</Shortcut>
|
||||||
|
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
{{#each merge_modules as |msm| ~}}
|
||||||
|
<DirectoryRef Id="TARGETDIR">
|
||||||
|
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
|
||||||
|
<MergeRef Id="{{ msm.name }}"/>
|
||||||
|
</Feature>
|
||||||
|
{{/each~}}
|
||||||
|
|
||||||
|
<Feature
|
||||||
|
Id="MainProgram"
|
||||||
|
Title="Application"
|
||||||
|
Description="!(loc.InstallAppFeature)"
|
||||||
|
Level="1"
|
||||||
|
ConfigurableDirectory="INSTALLDIR"
|
||||||
|
AllowAdvertise="no"
|
||||||
|
Display="expand"
|
||||||
|
Absent="disallow">
|
||||||
|
|
||||||
|
<ComponentRef Id="RegistryEntries"/>
|
||||||
|
|
||||||
|
{{#each resource_file_ids as |resource_file_id| ~}}
|
||||||
|
<ComponentRef Id="{{ resource_file_id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
|
||||||
|
{{#if enable_elevated_update_task}}
|
||||||
|
<ComponentRef Id="UpdateTask" />
|
||||||
|
<ComponentRef Id="UpdateTaskInstaller" />
|
||||||
|
<ComponentRef Id="UpdateTaskUninstaller" />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<Feature Id="ShortcutsFeature"
|
||||||
|
Title="Shortcuts"
|
||||||
|
Level="1">
|
||||||
|
<ComponentRef Id="Path"/>
|
||||||
|
<ComponentRef Id="CMP_UninstallShortcut" />
|
||||||
|
<ComponentRef Id="ApplicationShortcut" />
|
||||||
|
<ComponentRef Id="ApplicationShortcutDesktop" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<Feature
|
||||||
|
Id="Environment"
|
||||||
|
Title="PATH Environment Variable"
|
||||||
|
Description="!(loc.PathEnvVarFeature)"
|
||||||
|
Level="1"
|
||||||
|
Absent="allow">
|
||||||
|
<ComponentRef Id="Path"/>
|
||||||
|
{{#each binaries as |bin| ~}}
|
||||||
|
<ComponentRef Id="{{ bin.id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
</Feature>
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
|
||||||
|
{{#each component_group_refs as |id| ~}}
|
||||||
|
<ComponentGroupRef Id="{{ id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
{{#each component_refs as |id| ~}}
|
||||||
|
<ComponentRef Id="{{ id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
{{#each feature_group_refs as |id| ~}}
|
||||||
|
<FeatureGroupRef Id="{{ id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
{{#each feature_refs as |id| ~}}
|
||||||
|
<FeatureRef Id="{{ id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
{{#each merge_refs as |id| ~}}
|
||||||
|
<MergeRef Id="{{ id }}"/>
|
||||||
|
{{/each~}}
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
{{#if install_webview}}
|
||||||
|
<!-- WebView2 -->
|
||||||
|
<Property Id="WVRTINSTALLED">
|
||||||
|
<RegistrySearch Id="WVRTInstalledSystem" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" Win64="no" />
|
||||||
|
<RegistrySearch Id="WVRTInstalledUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
{{#if download_bootstrapper}}
|
||||||
|
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} '/install') -Wait' Return='check'/>
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
|
||||||
|
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||||
|
</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Embedded webview bootstrapper mode -->
|
||||||
|
{{#if webview2_bootstrapper_path}}
|
||||||
|
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
|
||||||
|
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
|
||||||
|
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||||
|
</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Embedded offline installer -->
|
||||||
|
{{#if webview2_installer_path}}
|
||||||
|
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
|
||||||
|
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
|
||||||
|
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||||
|
</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if enable_elevated_update_task}}
|
||||||
|
<!-- Install an elevated update task within Windows Task Scheduler -->
|
||||||
|
<CustomAction
|
||||||
|
Id="CreateUpdateTask"
|
||||||
|
Return="check"
|
||||||
|
Directory="INSTALLDIR"
|
||||||
|
Execute="commit"
|
||||||
|
Impersonate="yes"
|
||||||
|
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
|
||||||
|
NOT(REMOVE)
|
||||||
|
</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
<!-- Remove elevated update task during uninstall -->
|
||||||
|
<CustomAction
|
||||||
|
Id="DeleteUpdateTask"
|
||||||
|
Return="check"
|
||||||
|
Directory="INSTALLDIR"
|
||||||
|
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
|
||||||
|
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
|
||||||
|
</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND NOT Installed</Custom>
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
|
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
|
||||||
|
</Product>
|
||||||
|
</Wix>
|
||||||
242
src/App.css
@@ -1,242 +0,0 @@
|
|||||||
.app {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 0.35rem 2rem 0.45rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
grid-template-areas:
|
|
||||||
". title ."
|
|
||||||
"tabs . actions";
|
|
||||||
align-items: center;
|
|
||||||
row-gap: 0.6rem;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-tabs {
|
|
||||||
grid-area: tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Segmented control */
|
|
||||||
.segmented {
|
|
||||||
--seg-bg: rgba(255, 255, 255, 0.16);
|
|
||||||
--seg-thumb: #ffffff;
|
|
||||||
--seg-color: rgba(255, 255, 255, 0.85);
|
|
||||||
--seg-active: #2d89c7;
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
width: 280px;
|
|
||||||
background: var(--seg-bg);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
|
|
||||||
backdrop-filter: saturate(140%) blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
left: 4px;
|
|
||||||
width: calc(50% - 4px);
|
|
||||||
height: calc(100% - 8px);
|
|
||||||
background: var(--seg-thumb);
|
|
||||||
border-radius: 999px;
|
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
|
||||||
transition:
|
|
||||||
transform 220ms ease,
|
|
||||||
width 220ms ease;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-item {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 6px 16px; /* 更紧凑的高度 */
|
|
||||||
color: var(--seg-color);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-item.active {
|
|
||||||
color: var(--seg-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-item:focus-visible {
|
|
||||||
outline: 2px solid rgba(255, 255, 255, 0.8);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
grid-area: title;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-area: actions;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn,
|
|
||||||
.add-btn {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn {
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:hover:not(:disabled) {
|
|
||||||
background: #2980b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-btn {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-btn:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
background: #27ae60;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn:hover {
|
|
||||||
background: #229954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-path {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #ecf0f1;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #7f8c8d;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browse-btn {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browse-btn:hover {
|
|
||||||
background: #2980b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 供应商列表区域 - 相对定位容器 */
|
|
||||||
.provider-section {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 浮动通知 - 绝对定位,不占据空间 */
|
|
||||||
.notification-floating {
|
|
||||||
position: absolute;
|
|
||||||
top: -10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 100;
|
|
||||||
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
width: fit-content;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-out {
|
|
||||||
animation: fadeOut 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-success {
|
|
||||||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-error {
|
|
||||||
background: linear-gradient(135deg, #e74c3c 0%, #ec7063 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeOut {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
605
src/App.tsx
@@ -1,267 +1,418 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Provider } from "./types";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AppType } from "./lib/tauri-api";
|
import { toast } from "sonner";
|
||||||
import ProviderList from "./components/ProviderList";
|
import { Plus, Settings, Edit3 } from "lucide-react";
|
||||||
import AddProviderModal from "./components/AddProviderModal";
|
import type { Provider } from "@/types";
|
||||||
import EditProviderModal from "./components/EditProviderModal";
|
import type { EnvConflict } from "@/types/env";
|
||||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
import { useProvidersQuery } from "@/lib/query";
|
||||||
import { AppSwitcher } from "./components/AppSwitcher";
|
import {
|
||||||
import "./App.css";
|
providersApi,
|
||||||
|
settingsApi,
|
||||||
|
type AppId,
|
||||||
|
type ProviderSwitchEvent,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
|
import { ProviderList } from "@/components/providers/ProviderList";
|
||||||
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||||
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||||
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||||
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||||
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
const { t } = useTranslation();
|
||||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
|
||||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
const [configStatus, setConfigStatus] = useState<{
|
|
||||||
exists: boolean;
|
|
||||||
path: string;
|
|
||||||
} | null>(null);
|
|
||||||
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 timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// 设置通知的辅助函数
|
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||||
const showNotification = (
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
message: string,
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
type: "success" | "error",
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
duration = 3000,
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
) => {
|
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||||
// 清除之前的定时器
|
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
|
||||||
if (timeoutRef.current) {
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
clearTimeout(timeoutRef.current);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
}
|
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||||
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
// 立即显示通知
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
setNotification({ message, type });
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
setIsNotificationVisible(true);
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
|
|
||||||
// 设置淡出定时器
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
timeoutRef.current = setTimeout(() => {
|
const {
|
||||||
setIsNotificationVisible(false);
|
addProvider,
|
||||||
// 等待淡出动画完成后清除通知
|
updateProvider,
|
||||||
setTimeout(() => {
|
switchProvider,
|
||||||
setNotification(null);
|
deleteProvider,
|
||||||
timeoutRef.current = null;
|
saveUsageScript,
|
||||||
}, 300); // 与CSS动画时间匹配
|
} = useProviderActions(activeApp);
|
||||||
}, duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载供应商列表
|
// 监听来自托盘菜单的切换事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProviders();
|
let unsubscribe: (() => void) | undefined;
|
||||||
loadConfigStatus();
|
|
||||||
}, [activeApp]); // 当切换应用时重新加载
|
|
||||||
|
|
||||||
// 清理定时器
|
const setupListener = async () => {
|
||||||
useEffect(() => {
|
try {
|
||||||
return () => {
|
unsubscribe = await providersApi.onSwitched(
|
||||||
if (timeoutRef.current) {
|
async (event: ProviderSwitchEvent) => {
|
||||||
clearTimeout(timeoutRef.current);
|
if (event.appType === activeApp) {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to subscribe provider switch event", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadProviders = async () => {
|
setupListener();
|
||||||
const loadedProviders = await window.api.getProviders(activeApp);
|
return () => {
|
||||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
unsubscribe?.();
|
||||||
setProviders(loadedProviders);
|
|
||||||
setCurrentProviderId(currentId);
|
|
||||||
|
|
||||||
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商
|
|
||||||
if (Object.keys(loadedProviders).length === 0) {
|
|
||||||
await handleAutoImportDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConfigStatus = async () => {
|
|
||||||
const status = await window.api.getConfigStatus(activeApp);
|
|
||||||
setConfigStatus({
|
|
||||||
exists: Boolean(status?.exists),
|
|
||||||
path: String(status?.path || ""),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成唯一ID
|
|
||||||
const generateId = () => {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddProvider = async (provider: Omit<Provider, "id">) => {
|
|
||||||
const newProvider: Provider = {
|
|
||||||
...provider,
|
|
||||||
id: generateId(),
|
|
||||||
};
|
};
|
||||||
await window.api.addProvider(newProvider, activeApp);
|
}, [activeApp, refetch]);
|
||||||
await loadProviders();
|
|
||||||
setIsAddModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditProvider = async (provider: Provider) => {
|
// 应用启动时检测所有应用的环境变量冲突
|
||||||
try {
|
useEffect(() => {
|
||||||
await window.api.updateProvider(provider, activeApp);
|
const checkEnvOnStartup = async () => {
|
||||||
await loadProviders();
|
try {
|
||||||
setEditingProviderId(null);
|
const allConflicts = await checkAllEnvConflicts();
|
||||||
// 显示编辑成功提示
|
const flatConflicts = Object.values(allConflicts).flat();
|
||||||
showNotification("供应商配置已保存", "success", 2000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("更新供应商失败:", error);
|
|
||||||
setEditingProviderId(null);
|
|
||||||
showNotification("保存失败,请重试", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProvider = async (id: string) => {
|
if (flatConflicts.length > 0) {
|
||||||
const provider = providers[id];
|
setEnvConflicts(flatConflicts);
|
||||||
setConfirmDialog({
|
setShowEnvBanner(true);
|
||||||
isOpen: true,
|
}
|
||||||
title: "删除供应商",
|
} catch (error) {
|
||||||
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
|
console.error(
|
||||||
onConfirm: async () => {
|
"[App] Failed to check environment conflicts on startup:",
|
||||||
await window.api.deleteProvider(id, activeApp);
|
error,
|
||||||
await loadProviders();
|
|
||||||
setConfirmDialog(null);
|
|
||||||
showNotification("供应商删除成功", "success");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showNotification("切换失败,请检查配置", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自动导入现有配置为"default"供应商
|
|
||||||
const handleAutoImportDefault = async () => {
|
|
||||||
try {
|
|
||||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await loadProviders();
|
|
||||||
showNotification(
|
|
||||||
"已自动导入现有配置为 default 供应商",
|
|
||||||
"success",
|
|
||||||
3000,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
};
|
||||||
|
|
||||||
|
checkEnvOnStartup();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换应用时检测当前应用的环境变量冲突
|
||||||
|
useEffect(() => {
|
||||||
|
const checkEnvOnSwitch = async () => {
|
||||||
|
try {
|
||||||
|
const conflicts = await checkEnvConflicts(activeApp);
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
// 合并新检测到的冲突
|
||||||
|
setEnvConflicts((prev) => {
|
||||||
|
const existingKeys = new Set(
|
||||||
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||||
|
);
|
||||||
|
const newConflicts = conflicts.filter(
|
||||||
|
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
||||||
|
);
|
||||||
|
return [...prev, ...newConflicts];
|
||||||
|
});
|
||||||
|
setShowEnvBanner(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to check environment conflicts on app switch:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkEnvOnSwitch();
|
||||||
|
}, [activeApp]);
|
||||||
|
|
||||||
|
// 打开网站链接
|
||||||
|
const handleOpenWebsite = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("自动导入默认配置失败:", error);
|
const detail =
|
||||||
// 静默处理,不影响用户体验
|
extractErrorMessage(error) ||
|
||||||
|
t("notifications.openLinkFailed", {
|
||||||
|
defaultValue: "链接打开失败",
|
||||||
|
});
|
||||||
|
toast.error(detail);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenConfigFolder = async () => {
|
// 编辑供应商
|
||||||
await window.api.openConfigFolder(activeApp);
|
const handleEditProvider = async (provider: Provider) => {
|
||||||
|
await updateProvider(provider);
|
||||||
|
setEditingProvider(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认删除供应商
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
await deleteProvider(confirmDelete.id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制供应商
|
||||||
|
const handleDuplicateProvider = async (provider: Provider) => {
|
||||||
|
// 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它
|
||||||
|
const newSortIndex =
|
||||||
|
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
||||||
|
|
||||||
|
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
|
||||||
|
name: `${provider.name} copy`,
|
||||||
|
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||||
|
websiteUrl: provider.websiteUrl,
|
||||||
|
category: provider.category,
|
||||||
|
sortIndex: newSortIndex, // 复制原 sortIndex + 1
|
||||||
|
meta: provider.meta
|
||||||
|
? JSON.parse(JSON.stringify(provider.meta))
|
||||||
|
: undefined, // 深拷贝
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
||||||
|
if (provider.sortIndex !== undefined) {
|
||||||
|
const updates = Object.values(providers)
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.sortIndex !== undefined &&
|
||||||
|
p.sortIndex >= newSortIndex! &&
|
||||||
|
p.id !== provider.id,
|
||||||
|
)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
sortIndex: p.sortIndex! + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 先更新现有供应商的 sortIndex,为新供应商腾出位置
|
||||||
|
if (updates.length > 0) {
|
||||||
|
try {
|
||||||
|
await providersApi.updateSortOrder(updates, activeApp);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to update sort order", error);
|
||||||
|
toast.error(
|
||||||
|
t("provider.sortUpdateFailed", {
|
||||||
|
defaultValue: "排序更新失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return; // 如果排序更新失败,不继续添加
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 添加复制的供应商
|
||||||
|
await addProvider(duplicatedProvider);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导入配置成功后刷新
|
||||||
|
const handleImportSuccess = async () => {
|
||||||
|
await refetch();
|
||||||
|
try {
|
||||||
|
await providersApi.updateTrayMenu();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to refresh tray menu", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
<header className="app-header">
|
{/* 环境变量警告横幅 */}
|
||||||
<h1>CC Switch</h1>
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
<div className="app-tabs">
|
<EnvWarningBanner
|
||||||
<AppSwitcher
|
conflicts={envConflicts}
|
||||||
activeApp={activeApp}
|
onDismiss={() => setShowEnvBanner(false)}
|
||||||
onSwitch={setActiveApp}
|
onDeleted={async () => {
|
||||||
/>
|
// 删除后重新检测
|
||||||
</div>
|
try {
|
||||||
<div className="header-actions">
|
const allConflicts = await checkAllEnvConflicts();
|
||||||
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
|
const flatConflicts = Object.values(allConflicts).flat();
|
||||||
添加供应商
|
setEnvConflicts(flatConflicts);
|
||||||
</button>
|
if (flatConflicts.length === 0) {
|
||||||
|
setShowEnvBanner(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to re-check conflicts after deletion:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href="https://github.com/farion1231/cc-switch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
CC Switch
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
|
title={t("common.settings")}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
title={t(
|
||||||
|
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
|
||||||
|
)}
|
||||||
|
className={
|
||||||
|
isEditMode
|
||||||
|
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsPromptOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{t("prompts.manage")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsMcpOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
MCP
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsSkillsOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{t("skills.manage")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsAddOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("header.addProvider")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="flex-1 overflow-y-scroll">
|
||||||
<div className="provider-section">
|
<div className="mx-auto max-w-4xl px-6 py-6">
|
||||||
{/* 浮动通知组件 */}
|
|
||||||
{notification && (
|
|
||||||
<div
|
|
||||||
className={`notification-floating ${
|
|
||||||
notification.type === "error"
|
|
||||||
? "notification-error"
|
|
||||||
: "notification-success"
|
|
||||||
} ${isNotificationVisible ? "fade-in" : "fade-out"}`}
|
|
||||||
>
|
|
||||||
{notification.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProviderList
|
<ProviderList
|
||||||
providers={providers}
|
providers={providers}
|
||||||
currentProviderId={currentProviderId}
|
currentProviderId={currentProviderId}
|
||||||
onSwitch={handleSwitchProvider}
|
appId={activeApp}
|
||||||
onDelete={handleDeleteProvider}
|
isLoading={isLoading}
|
||||||
onEdit={setEditingProviderId}
|
isEditMode={isEditMode}
|
||||||
|
onSwitch={switchProvider}
|
||||||
|
onEdit={setEditingProvider}
|
||||||
|
onDelete={setConfirmDelete}
|
||||||
|
onDuplicate={handleDuplicateProvider}
|
||||||
|
onConfigureUsage={setUsageProvider}
|
||||||
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
onCreate={() => setIsAddOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configStatus && (
|
|
||||||
<div className="config-path">
|
|
||||||
<span>
|
|
||||||
配置文件位置: {configStatus.path}
|
|
||||||
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="browse-btn"
|
|
||||||
onClick={handleOpenConfigFolder}
|
|
||||||
title="打开配置文件夹"
|
|
||||||
>
|
|
||||||
打开
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{isAddModalOpen && (
|
<AddProviderDialog
|
||||||
<AddProviderModal
|
open={isAddOpen}
|
||||||
appType={activeApp}
|
onOpenChange={setIsAddOpen}
|
||||||
onAdd={handleAddProvider}
|
appId={activeApp}
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
onSubmit={addProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditProviderDialog
|
||||||
|
open={Boolean(editingProvider)}
|
||||||
|
provider={editingProvider}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingProvider(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={handleEditProvider}
|
||||||
|
appId={activeApp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{usageProvider && (
|
||||||
|
<UsageScriptModal
|
||||||
|
provider={usageProvider}
|
||||||
|
appId={activeApp}
|
||||||
|
isOpen={Boolean(usageProvider)}
|
||||||
|
onClose={() => setUsageProvider(null)}
|
||||||
|
onSave={(script) => {
|
||||||
|
void saveUsageScript(usageProvider, script);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingProviderId && providers[editingProviderId] && (
|
<ConfirmDialog
|
||||||
<EditProviderModal
|
isOpen={Boolean(confirmDelete)}
|
||||||
appType={activeApp}
|
title={t("confirm.deleteProvider")}
|
||||||
provider={providers[editingProviderId]}
|
message={
|
||||||
onSave={handleEditProvider}
|
confirmDelete
|
||||||
onClose={() => setEditingProviderId(null)}
|
? t("confirm.deleteProviderMessage", {
|
||||||
/>
|
name: confirmDelete.name,
|
||||||
)}
|
})
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={() => void handleConfirmDelete()}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{confirmDialog && (
|
<SettingsDialog
|
||||||
<ConfirmDialog
|
open={isSettingsOpen}
|
||||||
isOpen={confirmDialog.isOpen}
|
onOpenChange={setIsSettingsOpen}
|
||||||
title={confirmDialog.title}
|
onImportSuccess={handleImportSuccess}
|
||||||
message={confirmDialog.message}
|
/>
|
||||||
onConfirm={confirmDialog.onConfirm}
|
|
||||||
onCancel={() => setConfirmDialog(null)}
|
<PromptPanel
|
||||||
/>
|
open={isPromptOpen}
|
||||||
)}
|
onOpenChange={setIsPromptOpen}
|
||||||
|
appId={activeApp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||||
|
|
||||||
|
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>{t("skills.title")}</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
</DialogHeader>
|
||||||
|
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<DeepLinkImportDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,268 +0,0 @@
|
|||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 640px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow: hidden; /* 由 body 滚动,标题栏固定 */
|
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
|
||||||
position: relative;
|
|
||||||
z-index: 1001;
|
|
||||||
display: flex; /* 纵向布局,便于底栏固定 */
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 模拟窗口标题栏 */
|
|
||||||
.modal-titlebar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 3rem; /* 与主窗口标题栏一致 */
|
|
||||||
padding: 0 12px; /* 接近主头部的水平留白 */
|
|
||||||
background: #3498db; /* 与 .app-header 相同 */
|
|
||||||
color: #fff;
|
|
||||||
border-top-left-radius: 10px;
|
|
||||||
border-top-right-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
|
|
||||||
.modal-spacer {
|
|
||||||
width: 32px;
|
|
||||||
flex: 0 0 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.18);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-form {
|
|
||||||
/* 表单外层包裹 body + footer */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0; /* 允许子元素正确计算高度 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 1.25rem 1.5rem 1.5rem;
|
|
||||||
overflow: auto; /* 仅内容区滚动 */
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: #fee;
|
|
||||||
color: #c33;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid #fcc;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presets {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #ecf0f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presets label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border: 1px solid #3498db;
|
|
||||||
background: white;
|
|
||||||
color: #3498db;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn:hover,
|
|
||||||
.preset-btn.selected {
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 官方按钮橙色主题(Anthropic 风格) */
|
|
||||||
.preset-btn.official {
|
|
||||||
border: 1px solid #d97706;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn.official:hover,
|
|
||||||
.preset-btn.official.selected {
|
|
||||||
background: #d97706;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* API Key 输入框容器 - 预留空间避免抖动 */
|
|
||||||
.form-group.api-key-group {
|
|
||||||
min-height: 88px; /* 固定高度:label + input + 间距 */
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group.api-key-group.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
background: white;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
/* 固定在弹窗底部(非滚动区) */
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-top: 1px solid #ecf0f1;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn,
|
|
||||||
.submit-btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn {
|
|
||||||
background: #ecf0f1;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:hover {
|
|
||||||
background: #bdc3c7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
background: #27ae60;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover {
|
|
||||||
background: #229954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 添加标签和选择框的样式 */
|
|
||||||
.label-with-checkbox {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-with-checkbox label:first-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #666;
|
|
||||||
font-weight: normal;
|
|
||||||
margin-bottom: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
|
||||||
width: auto;
|
|
||||||
margin: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transform: translateY(2px);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
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}
|
|
||||||
onSubmit={onAdd}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddProviderModal;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/* 药丸式切换按钮 */
|
|
||||||
.switcher-pills {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 50px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switcher-pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50px;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 200ms ease;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switcher-pill:hover:not(.active) {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switcher-pill.active {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
color: white;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
|
||||||
0 1px 0 rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.4;
|
|
||||||
transition: all 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switcher-pill.active .pill-dot {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 8px currentColor;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pills-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
|
||||||
50% { transform: scale(1.2); opacity: 0.8; }
|
|
||||||
}
|
|
||||||