feat(deeplink): implement ccswitch:// protocol for provider import
Add deep link support to enable one-click provider configuration import via ccswitch:// URLs. Backend: - Implement URL parsing and validation (src-tauri/src/deeplink.rs) - Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs) - Register ccswitch:// protocol in macOS Info.plist - Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs) Frontend: - Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx) - Add API wrapper (src/lib/api/deeplink.ts) - Integrate event listeners in App.tsx Configuration: - Update Tauri config for deep link handling - Add i18n support for Chinese and English - Include test page for deep link validation (deeplink-test.html) Files: 15 changed, 1312 insertions(+)
This commit is contained in:
548
deeplink-test.html
Normal file
548
deeplink-test.html
Normal file
@@ -0,0 +1,548 @@
|
||||
<!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 {
|
||||
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-sonnet-4-20250514。
|
||||
</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-sonnet-4-20250514¬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-sonnet-4-20250514。
|
||||
</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-sonnet-4-20250514¬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-4o。
|
||||
</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-4o¬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-4o。
|
||||
</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-4o¬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-2.0-flash-exp。
|
||||
</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-2.0-flash-exp¬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-2.0-flash-exp。
|
||||
</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-2.0-flash-exp¬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-sonnet-4-20250514, gpt-4o, gemini-2.0-flash-exp">
|
||||
</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>
|
||||
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>
|
||||
|
||||
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)
|
||||
}
|
||||
354
src-tauri/src/deeplink.rs
Normal file
354
src-tauri/src/deeplink.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
/// 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
|
||||
|
||||
// Generate TOML string for config.toml
|
||||
let mut config_toml = format!(
|
||||
r#"[api]
|
||||
base_url = "{}"
|
||||
"#,
|
||||
request.endpoint
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
if let Some(model) = &request.model {
|
||||
config_toml.push_str(&format!("model = \"{model}\"\n"));
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
206
src/components/DeepLinkImportDialog.tsx
Normal file
206
src/components/DeepLinkImportDialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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">
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary capitalize">
|
||||
{request.app}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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 });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user