Merge branch 'feat/add-provider-notes' into main
- Add provider notes field support - Add deeplink import functionality - Merge with environment variable conflict detection - Merge with Skills management feature - Combine tauri features: tray-icon and protocol-asset - Keep both env and deeplink modules in commands - Merge i18n translations for all new features
This commit is contained in:
551
deplink.html
Normal file
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>
|
||||||
|
|
||||||
@@ -26,13 +26,14 @@ serde_json = "1.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
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-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-store = "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"
|
toml_edit = "0.22"
|
||||||
@@ -46,6 +47,7 @@ anyhow = "1.0"
|
|||||||
zip = "2.2"
|
zip = "2.2"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
19
src-tauri/Info.plist
Normal file
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>
|
||||||
|
|
||||||
@@ -184,12 +184,12 @@ pub async fn get_common_config_snippet(
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
let guard = state
|
let guard = state
|
||||||
.config
|
.config
|
||||||
.read()
|
.read()
|
||||||
.map_err(|e| format!("读取配置锁失败: {}", e))?;
|
.map_err(|e| format!("读取配置锁失败: {e}"))?;
|
||||||
|
|
||||||
Ok(guard.common_config_snippets.get(&app).cloned())
|
Ok(guard.common_config_snippets.get(&app).cloned())
|
||||||
}
|
}
|
||||||
@@ -204,12 +204,12 @@ pub async fn set_common_config_snippet(
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
|
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
|
|
||||||
let mut guard = state
|
let mut guard = state
|
||||||
.config
|
.config
|
||||||
.write()
|
.write()
|
||||||
.map_err(|e| format!("写入配置锁失败: {}", e))?;
|
.map_err(|e| format!("写入配置锁失败: {e}"))?;
|
||||||
|
|
||||||
// 验证格式(根据应用类型)
|
// 验证格式(根据应用类型)
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
@@ -217,7 +217,7 @@ pub async fn set_common_config_snippet(
|
|||||||
AppType::Claude | AppType::Gemini => {
|
AppType::Claude | AppType::Gemini => {
|
||||||
// 验证 JSON 格式
|
// 验证 JSON 格式
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
.map_err(|e| format!("无效的 JSON 格式: {}", e))?;
|
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
// TOML 格式暂不验证(或可使用 toml crate)
|
// TOML 格式暂不验证(或可使用 toml crate)
|
||||||
|
|||||||
29
src-tauri/src/commands/deeplink.rs
Normal file
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)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod env;
|
mod env;
|
||||||
|
mod deeplink;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
@@ -13,6 +14,7 @@ pub mod skill;
|
|||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use env::*;
|
pub use env::*;
|
||||||
|
pub use deeplink::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
|||||||
407
src-tauri/src/deeplink.rs
Normal file
407
src-tauri/src/deeplink.rs
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
/// 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("GOOGLE_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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
mod error;
|
mod error;
|
||||||
mod gemini_config; // 新增
|
mod gemini_config; // 新增
|
||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
@@ -22,6 +23,7 @@ pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
|||||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
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 error::AppError;
|
||||||
pub use mcp::{
|
pub use mcp::{
|
||||||
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||||
@@ -36,6 +38,7 @@ pub use services::{
|
|||||||
};
|
};
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -283,6 +286,65 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一处理 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
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
/// 内部切换供应商函数
|
||||||
@@ -348,7 +410,27 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
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") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
@@ -358,6 +440,8 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let builder = builder
|
let builder = builder
|
||||||
|
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
@@ -473,7 +557,40 @@ pub fn run() {
|
|||||||
config_guard.ensure_app(&app_config::AppType::Codex);
|
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||||||
|
|
||||||
|
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API)
|
||||||
|
log::info!("=== Registering deep-link URL handler ===");
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
log::info!("✓ Deep link schemes registered (Linux/Windows)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册 URL 处理回调(所有平台通用)
|
||||||
|
app.deep_link().on_open_url({
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
move |event| {
|
||||||
|
log::info!("=== Deep Link Event Received (on_open_url) ===");
|
||||||
|
let urls = event.urls();
|
||||||
|
log::info!("Received {} URL(s)", urls.len());
|
||||||
|
|
||||||
|
for (i, url) in urls.iter().enumerate() {
|
||||||
|
let url_str = url.as_str();
|
||||||
|
log::info!(" URL[{i}]: {url_str}");
|
||||||
|
|
||||||
|
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||||
|
break; // Process only first ccswitch:// URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log::info!("✓ Deep-link URL handler registered");
|
||||||
|
|
||||||
// 创建动态托盘菜单
|
// 创建动态托盘菜单
|
||||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||||
@@ -585,6 +702,9 @@ pub fn run() {
|
|||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
|
// Deep link import
|
||||||
|
commands::parse_deeplink,
|
||||||
|
commands::import_from_deeplink,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
// Environment variable management
|
// Environment variable management
|
||||||
commands::check_env_conflicts,
|
commands::check_env_conflicts,
|
||||||
@@ -605,8 +725,10 @@ pub fn run() {
|
|||||||
|
|
||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match event {
|
||||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||||
if let RunEvent::Reopen { .. } = event {
|
RunEvent::Reopen { .. } => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -618,6 +740,61 @@ pub fn run() {
|
|||||||
apply_tray_policy(app_handle, true);
|
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"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "sortIndex")]
|
#[serde(rename = "sortIndex")]
|
||||||
pub sort_index: Option<usize>,
|
pub sort_index: Option<usize>,
|
||||||
|
/// 备注信息
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub meta: Option<ProviderMeta>,
|
pub meta: Option<ProviderMeta>,
|
||||||
@@ -43,6 +46,7 @@ impl Provider {
|
|||||||
category: None,
|
category: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
meta: None,
|
meta: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:"
|
"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": {
|
||||||
@@ -42,9 +46,17 @@
|
|||||||
"wix": {
|
"wix": {
|
||||||
"template": "wix/per-user-main.wxs"
|
"template": "wix/per-user-main.wxs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["ccswitch"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
121
src-tauri/tests/deeplink_import.rs
Normal file
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import UsageScriptModal from "@/components/UsageScriptModal";
|
|||||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||||
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -402,6 +403,7 @@ function App() {
|
|||||||
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<DeepLinkImportDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
204
src/components/DeepLinkImportDialog.tsx
Normal file
204
src/components/DeepLinkImportDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface DeeplinkError {
|
||||||
|
url: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeepLinkImportDialog() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for deep link import events
|
||||||
|
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||||
|
"deeplink-import",
|
||||||
|
(event) => {
|
||||||
|
console.log("Deep link import event received:", event.payload);
|
||||||
|
setRequest(event.payload);
|
||||||
|
setIsOpen(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for deep link error events
|
||||||
|
const unlistenError = listen<DeeplinkError>("deeplink-error", (event) => {
|
||||||
|
console.error("Deep link error:", event.payload);
|
||||||
|
toast.error(t("deeplink.parseError"), {
|
||||||
|
description: event.payload.error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenImport.then((fn) => fn());
|
||||||
|
unlistenError.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deeplinkApi.importFromDeeplink(request);
|
||||||
|
|
||||||
|
// Invalidate provider queries to refresh the list
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["providers", request.app],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(t("deeplink.importSuccess"), {
|
||||||
|
description: t("deeplink.importSuccessDescription", {
|
||||||
|
name: request.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setRequest(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import provider from deep link:", error);
|
||||||
|
toast.error(t("deeplink.importError"), {
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setRequest(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
// Mask API key for display (show first 4 chars + ***)
|
||||||
|
const maskedApiKey =
|
||||||
|
request.apiKey.length > 4
|
||||||
|
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
|
||||||
|
: "****";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
{/* 标题显式左对齐,避免默认居中样式影响 */}
|
||||||
|
<DialogHeader className="text-left sm:text-left">
|
||||||
|
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("deeplink.confirmImportDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
|
||||||
|
<div className="space-y-4 px-8 py-4">
|
||||||
|
{/* App Type */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.app")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-medium capitalize">
|
||||||
|
{request.app}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Name */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.providerName")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-medium">{request.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Homepage */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.homepage")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
|
||||||
|
{request.homepage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Endpoint */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.endpoint")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm break-all">
|
||||||
|
{request.endpoint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key (masked) */}
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.apiKey")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||||
|
{maskedApiKey}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model (if present) */}
|
||||||
|
{request.model && (
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.model")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-mono">
|
||||||
|
{request.model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes (if present) */}
|
||||||
|
{request.notes && (
|
||||||
|
<div className="grid grid-cols-3 items-start gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.notes")}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-muted-foreground">
|
||||||
|
{request.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
{t("deeplink.warning")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isImporting}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={isImporting}>
|
||||||
|
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp, Wand2 } from "lucide-react";
|
import {
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
initialServer,
|
initialServer,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio");
|
const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">(
|
||||||
|
"stdio",
|
||||||
|
);
|
||||||
const [wizardTitle, setWizardTitle] = useState("");
|
const [wizardTitle, setWizardTitle] = useState("");
|
||||||
// stdio 字段
|
// stdio 字段
|
||||||
const [wizardCommand, setWizardCommand] = useState("");
|
const [wizardCommand, setWizardCommand] = useState("");
|
||||||
|
|||||||
@@ -76,10 +76,7 @@ export function useMcpValidation() {
|
|||||||
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
|
||||||
return t("mcp.error.commandRequired");
|
return t("mcp.error.commandRequired");
|
||||||
}
|
}
|
||||||
if (
|
if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) {
|
||||||
(typ === "http" || typ === "sse") &&
|
|
||||||
!(obj as any)?.url?.trim()
|
|
||||||
) {
|
|
||||||
return t("mcp.wizard.urlRequired");
|
return t("mcp.wizard.urlRequired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function AddProviderDialog({
|
|||||||
// 构造基础提交数据
|
// 构造基础提交数据
|
||||||
const providerData: Omit<Provider, "id"> = {
|
const providerData: Omit<Provider, "id"> = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function EditProviderDialog({
|
|||||||
const updatedProvider: Provider = {
|
const updatedProvider: Provider = {
|
||||||
...provider,
|
...provider,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
settingsConfig: parsedConfig,
|
settingsConfig: parsedConfig,
|
||||||
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
...(values.presetCategory ? { category: values.presetCategory } : {}),
|
||||||
@@ -129,6 +130,7 @@ export function EditProviderDialog({
|
|||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={{
|
initialData={{
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
|
notes: provider.notes,
|
||||||
websiteUrl: provider.websiteUrl,
|
websiteUrl: provider.websiteUrl,
|
||||||
// 若读取到实时配置则优先使用
|
// 若读取到实时配置则优先使用
|
||||||
settingsConfig: initialSettingsConfig,
|
settingsConfig: initialSettingsConfig,
|
||||||
|
|||||||
@@ -33,10 +33,17 @@ interface ProviderCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
|
// 优先级 1: 备注
|
||||||
|
if (provider.notes?.trim()) {
|
||||||
|
return provider.notes.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 2: 官网地址
|
||||||
if (provider.websiteUrl) {
|
if (provider.websiteUrl) {
|
||||||
return provider.websiteUrl;
|
return provider.websiteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先级 3: 从配置中提取请求地址
|
||||||
const config = provider.settingsConfig;
|
const config = provider.settingsConfig;
|
||||||
|
|
||||||
if (config && typeof config === "object") {
|
if (config && typeof config === "object") {
|
||||||
@@ -83,10 +90,24 @@ export function ProviderCard({
|
|||||||
return extractApiUrl(provider, fallbackUrlText);
|
return extractApiUrl(provider, fallbackUrlText);
|
||||||
}, [provider, fallbackUrlText]);
|
}, [provider, fallbackUrlText]);
|
||||||
|
|
||||||
|
// 判断是否为可点击的 URL(备注不可点击)
|
||||||
|
const isClickableUrl = useMemo(() => {
|
||||||
|
// 如果有备注,则不可点击
|
||||||
|
if (provider.notes?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 如果显示的是回退文本,也不可点击
|
||||||
|
if (displayUrl === fallbackUrlText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 其他情况(官网地址或请求地址)可点击
|
||||||
|
return true;
|
||||||
|
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!displayUrl || displayUrl === fallbackUrlText) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
@@ -174,8 +195,14 @@ export function ProviderCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenWebsite}
|
onClick={handleOpenWebsite}
|
||||||
className="inline-flex items-center text-sm text-blue-500 transition-colors hover:underline dark:text-blue-400 max-w-[280px]"
|
className={cn(
|
||||||
|
"inline-flex items-center text-sm max-w-[280px]",
|
||||||
|
isClickableUrl
|
||||||
|
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||||||
|
: "text-muted-foreground cursor-default",
|
||||||
|
)}
|
||||||
title={displayUrl}
|
title={displayUrl}
|
||||||
|
disabled={!isClickableUrl}
|
||||||
>
|
>
|
||||||
<span className="truncate">{displayUrl}</span>
|
<span className="truncate">{displayUrl}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("provider.notes")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface ProviderFormProps {
|
|||||||
initialData?: {
|
initialData?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
|
notes?: string;
|
||||||
settingsConfig?: Record<string, unknown>;
|
settingsConfig?: Record<string, unknown>;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
@@ -138,6 +139,7 @@ export function ProviderForm({
|
|||||||
() => ({
|
() => ({
|
||||||
name: initialData?.name ?? "",
|
name: initialData?.name ?? "",
|
||||||
websiteUrl: initialData?.websiteUrl ?? "",
|
websiteUrl: initialData?.websiteUrl ?? "",
|
||||||
|
notes: initialData?.notes ?? "",
|
||||||
settingsConfig: initialData?.settingsConfig
|
settingsConfig: initialData?.settingsConfig
|
||||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||||
: appId === "codex"
|
: appId === "codex"
|
||||||
@@ -621,7 +623,6 @@ export function ProviderForm({
|
|||||||
presetCategoryLabels={presetCategoryLabels}
|
presetCategoryLabels={presetCategoryLabels}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
category={category}
|
category={category}
|
||||||
appId={appId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
|||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +19,6 @@ interface ProviderPresetSelectorProps {
|
|||||||
presetCategoryLabels: Record<string, string>;
|
presetCategoryLabels: Record<string, string>;
|
||||||
onPresetChange: (value: string) => void;
|
onPresetChange: (value: string) => void;
|
||||||
category?: ProviderCategory; // 当前选中的分类
|
category?: ProviderCategory; // 当前选中的分类
|
||||||
appId?: AppId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderPresetSelector({
|
export function ProviderPresetSelector({
|
||||||
@@ -30,7 +28,6 @@ export function ProviderPresetSelector({
|
|||||||
presetCategoryLabels,
|
presetCategoryLabels,
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
category,
|
category,
|
||||||
appId,
|
|
||||||
}: ProviderPresetSelectorProps) {
|
}: ProviderPresetSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@
|
|||||||
"name": "Provider Name",
|
"name": "Provider Name",
|
||||||
"namePlaceholder": "e.g., Claude Official",
|
"namePlaceholder": "e.g., Claude Official",
|
||||||
"websiteUrl": "Website URL",
|
"websiteUrl": "Website URL",
|
||||||
|
"notes": "Notes",
|
||||||
|
"notesPlaceholder": "e.g., Company dedicated account",
|
||||||
"configJson": "Config JSON",
|
"configJson": "Config JSON",
|
||||||
"writeCommonConfig": "Write common config",
|
"writeCommonConfig": "Write common config",
|
||||||
"editCommonConfigButton": "Edit common config",
|
"editCommonConfigButton": "Edit common config",
|
||||||
@@ -408,7 +410,6 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"usage_query_failed": "Usage query failed"
|
"usage_query_failed": "Usage query failed"
|
||||||
},
|
},
|
||||||
|
|
||||||
"presetSelector": {
|
"presetSelector": {
|
||||||
"title": "Select Configuration Type",
|
"title": "Select Configuration Type",
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
@@ -645,6 +646,8 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noSelection": "Please select environment variables to delete"
|
"noSelection": "Please select environment variables to delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"manage": "Skills",
|
"manage": "Skills",
|
||||||
"title": "Claude Skills Management",
|
"title": "Claude Skills Management",
|
||||||
@@ -688,5 +691,23 @@
|
|||||||
"removeFailed": "Failed to remove",
|
"removeFailed": "Failed to remove",
|
||||||
"skillCount": "{{count}} skills detected"
|
"skillCount": "{{count}} skills detected"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"deeplink": {
|
||||||
|
"confirmImport": "Confirm Import Provider",
|
||||||
|
"confirmImportDescription": "The following configuration will be imported from deep link into CC Switch",
|
||||||
|
"app": "App Type",
|
||||||
|
"providerName": "Provider Name",
|
||||||
|
"homepage": "Homepage",
|
||||||
|
"endpoint": "API Endpoint",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"model": "Model",
|
||||||
|
"notes": "Notes",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"warning": "Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.",
|
||||||
|
"parseError": "Failed to parse deep link",
|
||||||
|
"importSuccess": "Import successful",
|
||||||
|
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
|
||||||
|
"importError": "Failed to import"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,8 @@
|
|||||||
"name": "供应商名称",
|
"name": "供应商名称",
|
||||||
"namePlaceholder": "例如:Claude 官方",
|
"namePlaceholder": "例如:Claude 官方",
|
||||||
"websiteUrl": "官网链接",
|
"websiteUrl": "官网链接",
|
||||||
|
"notes": "备注",
|
||||||
|
"notesPlaceholder": "例如:公司专用账号",
|
||||||
"configJson": "配置 JSON",
|
"configJson": "配置 JSON",
|
||||||
"writeCommonConfig": "写入通用配置",
|
"writeCommonConfig": "写入通用配置",
|
||||||
"editCommonConfigButton": "编辑通用配置",
|
"editCommonConfigButton": "编辑通用配置",
|
||||||
@@ -408,7 +410,6 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"usage_query_failed": "用量查询失败"
|
"usage_query_failed": "用量查询失败"
|
||||||
},
|
},
|
||||||
|
|
||||||
"presetSelector": {
|
"presetSelector": {
|
||||||
"title": "选择配置类型",
|
"title": "选择配置类型",
|
||||||
"custom": "自定义",
|
"custom": "自定义",
|
||||||
@@ -645,6 +646,8 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"noSelection": "请选择要删除的环境变量"
|
"noSelection": "请选择要删除的环境变量"
|
||||||
|
}
|
||||||
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"manage": "Skills",
|
"manage": "Skills",
|
||||||
"title": "Claude Skills 管理",
|
"title": "Claude Skills 管理",
|
||||||
@@ -688,5 +691,23 @@
|
|||||||
"removeFailed": "删除失败",
|
"removeFailed": "删除失败",
|
||||||
"skillCount": "识别到 {{count}} 个技能"
|
"skillCount": "识别到 {{count}} 个技能"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"deeplink": {
|
||||||
|
"confirmImport": "确认导入供应商配置",
|
||||||
|
"confirmImportDescription": "以下配置将导入到 CC Switch",
|
||||||
|
"app": "应用类型",
|
||||||
|
"providerName": "供应商名称",
|
||||||
|
"homepage": "官网地址",
|
||||||
|
"endpoint": "API 端点",
|
||||||
|
"apiKey": "API 密钥",
|
||||||
|
"model": "模型",
|
||||||
|
"notes": "备注",
|
||||||
|
"import": "导入",
|
||||||
|
"importing": "导入中...",
|
||||||
|
"warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。",
|
||||||
|
"parseError": "深链接解析失败",
|
||||||
|
"importSuccess": "导入成功",
|
||||||
|
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
|
||||||
|
"importError": "导入失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
35
src/lib/api/deeplink.ts
Normal file
35
src/lib/api/deeplink.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export interface DeepLinkImportRequest {
|
||||||
|
version: string;
|
||||||
|
resource: string;
|
||||||
|
app: "claude" | "codex" | "gemini";
|
||||||
|
name: string;
|
||||||
|
homepage: string;
|
||||||
|
endpoint: string;
|
||||||
|
apiKey: string;
|
||||||
|
model?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deeplinkApi = {
|
||||||
|
/**
|
||||||
|
* Parse a deep link URL
|
||||||
|
* @param url The ccswitch:// URL to parse
|
||||||
|
* @returns Parsed deep link request
|
||||||
|
*/
|
||||||
|
parseDeeplink: async (url: string): Promise<DeepLinkImportRequest> => {
|
||||||
|
return invoke("parse_deeplink", { url });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a provider from a deep link request
|
||||||
|
* @param request The deep link import request
|
||||||
|
* @returns The ID of the imported provider
|
||||||
|
*/
|
||||||
|
importFromDeeplink: async (
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
): Promise<string> => {
|
||||||
|
return invoke("import_from_deeplink", { request });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@ function parseJsonError(error: unknown): string {
|
|||||||
export const providerSchema = z.object({
|
export const providerSchema = z.object({
|
||||||
name: z.string().min(1, "请填写供应商名称"),
|
name: z.string().min(1, "请填写供应商名称"),
|
||||||
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
|
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
|
||||||
|
notes: z.string().optional(),
|
||||||
settingsConfig: z
|
settingsConfig: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "请填写配置内容")
|
.min(1, "请填写配置内容")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Provider {
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
createdAt?: number; // 添加时间戳(毫秒)
|
createdAt?: number; // 添加时间戳(毫秒)
|
||||||
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
||||||
|
// 备注信息
|
||||||
|
notes?: string;
|
||||||
// 新增:是否为商业合作伙伴
|
// 新增:是否为商业合作伙伴
|
||||||
isPartner?: boolean;
|
isPartner?: boolean;
|
||||||
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ describe("McpFormModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("缺少配置命令时阻止提交并提示错误", async () => {
|
it("缺少配置命令时阻止提交并提示错误", async () => {
|
||||||
const { onSave } = renderForm();
|
renderForm();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||||
target: { value: "no-command" },
|
target: { value: "no-command" },
|
||||||
@@ -288,7 +288,7 @@ command = "run"
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
|
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
|
||||||
const { onSave } = renderForm({ defaultFormat: "toml" });
|
renderForm({ defaultFormat: "toml" });
|
||||||
|
|
||||||
// 填写 ID 字段
|
// 填写 ID 字段
|
||||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||||
|
|||||||
Reference in New Issue
Block a user