19 Commits

Author SHA1 Message Date
Jason
6ed9cf47df feat: add auto-updater support with GitHub releases
- Configure Tauri updater plugin with Ed25519 signing
- Add GitHub Actions support for signed builds
- Set up GitHub releases as update endpoint
- Enable update checking in Settings modal
2025-09-09 15:13:06 +08:00
Jason
a3582f54e9 fix(updater): 避免因 Updater 配置不完整导致应用启动中止\n\n- Updater 插件注册改为容错(失败仅告警,不影响应用启动) 2025-09-09 10:28:34 +08:00
Jason
9ff7516c51 feat(updater): 集成 Rust 侧 Updater/Process 插件与权限配置
- Cargo 依赖:tauri-plugin-updater、tauri-plugin-process
- 插件注册:process 顶层 init,updater 于 setup 中注册
- 权限配置:capabilities 增加 updater:default、process:allow-restart
- 配置:tauri.conf.json 启用 createUpdaterArtifacts 与 updater 占位(pubkey/endpoints)
2025-09-08 16:58:41 +08:00
Jason
a1a16be2aa feat(updater): 前端 Updater 封装与设置页接入\n\n- 新增 src/lib/updater.ts(check→download→install→relaunch 封装与类型)\n- SettingsModal 按钮优先走 Updater,失败回退打开 Releases\n- 依赖:@tauri-apps/plugin-updater、@tauri-apps/plugin-process\n- 文档:精简并重写 docs/updater-plan.md(明确接口/流程/配置) 2025-09-08 16:48:24 +08:00
Jason
df7d818514 feat(ui): enhance dark mode styling support
- AppSwitcher: add dark mode backgrounds, borders and hover effects
- ConfirmDialog: apply dark theme colors to dialog elements
- ProviderList: update list items and buttons for dark mode
- Global styles: use color-scheme property for native controls theming

Ensures all interactive components have proper visual feedback and consistent UX in dark mode.
2025-09-08 15:57:29 +08:00
Jason
c0d9d0296d feat(ui): implement dark mode with system preference support
- Add useDarkMode hook for managing theme state and persistence
- Integrate dark mode toggle button in app header
- Update all components with dark variant styles using Tailwind v4
- Create centralized style utilities for consistent theming
- Support system color scheme preference as fallback
- Store user preference in localStorage for persistence
2025-09-08 15:38:06 +08:00
Jason
77a65aaad8 refactor(styles): migrate from CSS variables to Tailwind classes
- Replace all CSS custom properties with Tailwind utility classes
- Add tailwind.config.js with custom color palette matching Linear design
- Reduce index.css from 89 to 37 lines (58% reduction)
- Maintain consistent visual appearance with semantic color usage
- Update all components to use Tailwind classes instead of CSS variables
2025-09-08 11:48:05 +08:00
Jason
3ce847d2e0 refactor(ui): remove config file path display module
Remove the configuration file path information section from the main page footer, including:
- configStatus state and related functions
- loadConfigStatus function
- handleOpenConfigFolder function
- UI component showing config file path and folder open button

This simplifies the interface while preserving all core functionality.
2025-09-07 22:41:55 +08:00
Jason
1482dc9e66 feat(providers): add timestamp-based sorting for provider list
- Add createdAt timestamp field to Provider interface
- Implement sorting logic: old providers without timestamp first, then by creation time
- Providers without timestamps are sorted alphabetically by name
- New providers are automatically timestamped on creation
2025-09-07 22:29:08 +08:00
Jason
fa2b11fcc2 fix(settings): update version display and comment out incomplete dock settings
- Fix version display to use actual app version (3.1.1) from Tauri API
- Comment out dock display settings as feature is not yet implemented
- Update GitHub releases URL from yungookim to farion1231
2025-09-07 22:14:17 +08:00
Jason
02bfc97ee6 refactor(types): introduce Settings and apply in API
- style(prettier): format src files
- style(rustfmt): format Rust sources
- refactor(tauri-api): type-safe getSettings/saveSettings
- refactor(d.ts): declare window.api with Settings

[skip ci]
2025-09-07 11:36:09 +08:00
Jason
77bdeb02fb feat(settings): add minimal settings panel
- Add settings icon button next to app title
- Create SettingsModal component with:
  - Show in Dock option (macOS)
  - Version info and check for updates button
  - Config file location with open folder button
- Add settings-related APIs in tauri-api
- Update type definitions for new API methods
2025-09-07 10:48:27 +08:00
Jason
48bd37a74b feat(ui): unify link/address styles and set primary header color
- Links use primary color; removed leading icon
- Show API address when website is missing; use secondary text color, non-monospace
- Parse base_url from Codex TOML; change fallback copy to "未配置官网地址"
- Use primary color for the top-left header title
- Clean up unused imports

Affected files:
- src/components/ProviderList.tsx
- src/App.tsx
2025-09-06 23:57:10 +08:00
TinsFox
7346fcde2c feat: refactor ProviderForm component with new subcomponents (#13)
* feat: refactor ProviderForm component with new subcomponents

- Introduced PresetSelector, ApiKeyInput, ClaudeConfigEditor, and CodexConfigEditor for improved modularity and readability.
- Simplified preset selection logic for both Claude and Codex configurations.
- Enhanced API Key input handling with dedicated components for better user experience.
- Removed redundant code and improved state management in the ProviderForm component.

* feat: add Kimi model selection to ProviderForm component

- Introduced KimiModelSelector for enhanced model configuration options.
- Implemented state management for Kimi model selection, including initialization and updates based on preset selection.
- Improved user experience by conditionally displaying the Kimi model selector based on the selected preset.
- Refactored related logic to ensure proper handling of Kimi-specific settings in the ProviderForm.

* feat: enhance API Key input and model selection in ProviderForm

- Added toggle functionality to show/hide API Key in ApiKeyInput component for improved user experience.
- Updated placeholder text in ProviderForm to provide clearer instructions based on the selected preset.
- Enhanced KimiModelSelector to display a more informative message when API Key is not provided.
- Refactored provider presets to remove hardcoded API Key values for better security practices.

* fix(kimi): optimize debounce implementation in model selector

- Fix initial state: use empty string instead of apiKey.trim()
- Refactor fetchModels to fetchModelsWithKey with explicit key parameter
- Ensure consistent behavior between auto-fetch and manual refresh
- Eliminate mental overhead from optional parameter fallback logic

* fix(api-key): remove custom masking logic, use native password input

- Remove getDisplayValue function with custom star masking
- Use native browser password input behavior for better UX consistency
- Simplify component logic while maintaining show/hide toggle functionality

* chore: format code with prettier

- Apply consistent code formatting across all TypeScript files
- Fix indentation and spacing according to project style guide

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 23:13:01 +08:00
TinsFox
5af476d376 feat: 系统托盘 (#12)
* feat: 系统托盘

1. 添加系统托盘
2. 托盘添加切换供应商功能
3. 整理组件目录

* feat: 优化系统托盘菜单结构

- 扁平化Claude和Codex的菜单结构,直接将所有供应商添加到主菜单,简化用户交互。
- 添加无供应商时的提示信息,提升用户体验。
- 更新分隔符文本以增强可读性。

* feat: integrate Tailwind CSS and Lucide icons

- Added Tailwind CSS for styling and layout improvements.
- Integrated Lucide icons for enhanced UI elements.
- Updated project structure by removing unused CSS files and components.
- Refactored configuration files to support new styling and component structure.
- Introduced new components for managing providers with improved UI interactions.   

* fix: 修复类型声明和分隔符实现问题

- 修复 updateTrayMenu 返回类型不一致(Promise<void> -> Promise<boolean>)
- 添加缺失的 UnlistenFn 类型导入
- 使用 MenuBuilder.separator() 替代文本分隔符

---------

Co-authored-by: farion1231 <farion1231@gmail.c
2025-09-06 16:21:21 +08:00
farion1231
07b870488d fix: add .gitattributes to resolve line ending inconsistencies
- Configure unified line ending rules (LF)
- Fix issue where Cargo.toml always shows as modified on Windows
2025-09-06 11:28:58 +08:00
Jason
74ab14f572 feat: add ModelScope provider preset with GLM-4.5 model configuration 2025-09-06 11:01:09 +08:00
QuentinHsu
a14f7ef9b2 feat: JsonEditor for inputting JSON content (#4)
* feat(editor): add JsonEditor component for JSON configuration editing

* fix(provider): update API Key visibility logic in ProviderForm component

* fix(editor): stabilize JsonEditor height and restore API Key logic

- Revert API Key visibility to preset-based rule and injection to non-custom preset only.
- Reduce JsonEditor min-height from rows*22px to rows*18px to lessen layout jitter when switching presets.
- Keep fonts/size consistent with the previous textarea for visual parity.

* - fix(form): remove websiteUrl auto-extraction from JSON to prevent incorrect overrides

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 09:30:09 +08:00
Jason Young
07dd70570e Merge pull request #10 from farion1231/feat/ssot
feat(ssot): 首次迁移副本入库 + 切换回填不丢数据 + Codex 原子写入回滚
2025-09-05 21:42:11 +08:00
45 changed files with 3349 additions and 1453 deletions

38
.gitattributes vendored Normal file
View File

@@ -0,0 +1,38 @@
# Auto detect text files and perform LF normalization
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.rs text eol=lf
*.toml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.txt text eol=lf
# TypeScript/JavaScript files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
# HTML/CSS files
*.html text eol=lf
*.css text eol=lf
*.scss text eol=lf
# Shell scripts
*.sh text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.exe binary
*.dll binary

View File

@@ -85,14 +85,20 @@ jobs:
- name: Build Tauri App (macOS)
if: runner.os == 'macOS'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build --target universal-apple-darwin
- name: Build Tauri App (Windows)
if: runner.os == 'Windows'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: pnpm tauri build
- name: Prepare macOS Assets
@@ -180,6 +186,16 @@ jobs:
run: |
ls -la release-assets || true
- name: Collect Signatures
shell: bash
run: |
# 查找并复制签名文件到 release-assets
find src-tauri/target -name "*.sig" -type f 2>/dev/null | while read sig; do
cp "$sig" release-assets/ || true
done
echo "Collected signatures:"
ls -la release-assets/*.sig || echo "No signatures found"
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
with:

91
docs/updater-plan.md Normal file
View File

@@ -0,0 +1,91 @@
# 更新功能开发计划Tauri v2 Updater
> 目标:基于 Tauri v2 官方 Updater完成“检查更新 → 下载 → 安装 → 重启”的完整闭环;提供清晰的前后端接口、配置与测试/发布流程。
## 范围与目标
- 能力:静态 JSON 与动态接口两种更新源;可选稳定/测试通道;进度反馈与错误处理。
- 平台macOS `.app` 优先Windows 使用安装器NSIS/MSI
- 安全:启用 Ed25519 更新签名校验;上线前建议平台代码签名与公证。
## 架构与依赖
- 插件:`tauri-plugin-updater`(更新)、`@tauri-apps/plugin-updater`JS`tauri-plugin-process``@tauri-apps/plugin-process`(重启)。
- 签名与构建:`tauri signer generate` 生成密钥CI/本机注入 `TAURI_SIGNING_PRIVATE_KEY``bundle.createUpdaterArtifacts: true` 生成签名制品。
- 权限:在 `src-tauri/capabilities/default.json` 启用 `updater:default``process:allow-restart`
- 配置(`src-tauri/tauri.conf.json`
- `plugins.updater.pubkey: "<PUBLICKEY.PEM>"`
- `plugins.updater.endpoints: ["<更新源 URL 列表>"]`
- Windows可选`plugins.updater.windows.installMode: "passive|basicUi|quiet"`
## 前端接口设计TypeScript
- 类型
- `type UpdateChannel = 'stable' | 'beta'`
- `type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'restarting' | 'upToDate' | 'error'`
- `type UpdateInfo = { currentVersion: string; availableVersion: string; notes?: string; pubDate?: string }`
- `type UpdateProgressEvent = { event: 'Started' | 'Progress' | 'Finished'; total?: number; downloaded?: number }`
- `type UpdateError = { code: string; message: string; cause?: unknown }`
- `type CheckOptions = { timeout?: number; channel?: UpdateChannel }`
- API`src/lib/updater.ts`
- `getCurrentVersion(): Promise<string>` 读取当前版本。
- `checkForUpdate(opts?: CheckOptions)``up-to-date``{ status: 'available', info, update }`
- `downloadAndInstall(update, onProgress?)` 下载并安装,进度回调映射 Started/Progress/Finished。
- `relaunchApp()` 调用 `@tauri-apps/plugin-process.relaunch()`
- `runUpdateFlow(opts?)` 编排:检查 → 下载安装 → 重启;错误统一抛出 `UpdateError`
- `setUpdateChannel(channel)` 前端记录偏好;实际端点切换见“端点动态化”。
- Hook可选 `useUpdater()`
- 返回 `{ phase, info?, progress?, error?, actions: { check, startUpdate, relaunch } }`
- UI组件建议
- `UpdateBanner`:发现新版本时展示;`UpdaterDialog`:显示说明、进度与错误/重试。
## Rust 集成与权限
- 插件注册(`src-tauri/src/main.rs`
- `app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;`
- `.plugin(tauri_plugin_process::init())` 用于重启。
- Windows 清理钩子(可选):`UpdaterExt::on_before_exit(app.cleanup_before_exit)`,避免安装器启动前文件占用。
- 端点动态化(可选):在 `setup` 根据配置/环境切换 `endpoints`、超时、代理或 headers。
## 更新源与格式
- 静态 JSONlatest.json字段 `version``platforms[target].url``platforms[target].signature``.sig` 内容);可选 `notes``pub_date`
- 动态接口:
- 无更新HTTP 204
- 有更新HTTP 200 → `{ version, url, signature, notes?, pub_date? }`
- 通道组织:`/stable/latest.json``/beta/latest.json`CDN 缓存需可控,回滚可强制刷新。
## 用户流程与 UX
- 流程:检查 → 展示版本/日志 → 下载进度(累计/百分比)→ 安装 → 提示并重启。
- 错误:网络异常(超时/断网/证书)、签名不匹配、权限/文件占用Win。提供“重试/稍后更新”。
- 平台提示:
- macOS建议安装在 `~/Applications`,避免 `/Applications` 提权导致失败。
- Windows优先安装器分发并选择合适 `installMode`
## 测试计划
- 功能:有更新/无更新204/下载中断/重试/安装后重启成功与版本号提升。
- 安全:签名不匹配必须拒绝更新;端点不可用/被劫持有清晰提示。
- 网络:超时/断网/代理场景提示与恢复。
- 平台:
- macOS`/Applications``~/Applications` 的权限差异。
- Windows`passive|basicUi|quiet` 行为差异与成功率。
- 本地自测:以 v1.0.0 运行,构建 v1.0.1 制品+`.sig`,本地 HTTP 托管 `latest.json`,验证全链路。
## 发布与回滚
- 发布CI 推荐):注入 `TAURI_SIGNING_PRIVATE_KEY` → 构建生成各平台制品+签名 → 上传产物与 `latest.json` 至 Releases/CDN。
- 回滚:撤下问题版本或将 `latest.json` 指回上一个稳定版本如需降级Rust 侧可定制版本比较策略(可选)。
## 里程碑与验收
- D1密钥与基础集成插件/配置/权限)。
- D2前端入口与进度 UI静态 JSON 自测通过。
- D3Releases/CDN 端到端验证,平台专项测试。
- D4文档完善、回滚与异常流程演练。
- 验收:两平台完成“发现→下载→安装→重启→版本提升”;签名校验生效;异常有明确提示与可行恢复。
## 待确认
- 更新源托管GitHub Releases 还是自有 CDN
- 是否需要 beta 通道与运行时切换。
- Windows 是否仅支持安装器分发;便携版兼容策略是否需要明确说明。
- UI 文案与样式偏好。
## 落地步骤(实施顺序)
1) 生成 Ed25519 密钥,将公钥写入 `plugins.updater.pubkey`,在构建环境配置 `TAURI_SIGNING_PRIVATE_KEY`
2) `src-tauri` 注册 `tauri-plugin-updater``tauri-plugin-process`,补齐 `capabilities/default.json``tauri.conf.json`
3) 前端新增 `src/lib/updater.ts` 封装与 `UpdateBanner`/`UpdaterDialog` 组件,接入入口按钮。
4) 本地静态 `latest.json` 自测全链路;完善错误与进度提示。
5) 配置 CI 发布产物与 `latest.json`;编写发布/回滚操作手册。

View File

@@ -26,8 +26,18 @@
"vite": "^5.0.0"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"codemirror": "^6.0.2",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"tailwindcss": "^4.1.13"
}
}

591
pnpm-lock.yaml generated
View File

@@ -8,15 +8,45 @@ importers:
.:
dependencies:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/view':
specifier: ^6.38.2
version: 6.38.2
'@tailwindcss/vite':
specifier: ^4.1.13
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.8.0
'@tauri-apps/plugin-process':
specifier: ^2.0.0
version: 2.3.0
'@tauri-apps/plugin-updater':
specifier: ^2.0.0
version: 2.9.0
codemirror:
specifier: ^6.0.2
version: 6.0.2
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@18.3.1)
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
tailwindcss:
specifier: ^4.1.13
version: 4.1.13
devDependencies:
'@tauri-apps/cli':
specifier: ^2.8.0
@@ -32,7 +62,7 @@ importers:
version: 18.3.7(@types/react@18.3.23)
'@vitejs/plugin-react':
specifier: ^4.2.0
version: 4.7.0(vite@5.4.19(@types/node@20.19.9))
version: 4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -41,7 +71,7 @@ importers:
version: 5.9.2
vite:
specifier: ^5.0.0
version: 5.4.19(@types/node@20.19.9)
version: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
packages:
@@ -132,6 +162,33 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.18.7':
resolution: {integrity: sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
'@codemirror/lint@6.8.5':
resolution: {integrity: sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==}
'@codemirror/search@6.5.11':
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.38.2':
resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -270,9 +327,16 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -280,9 +344,27 @@ packages:
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -386,6 +468,96 @@ packages:
cpu: [x64]
os: [win32]
'@tailwindcss/node@4.1.13':
resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==}
'@tailwindcss/oxide-android-arm64@4.1.13':
resolution: {integrity: sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.13':
resolution: {integrity: sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.13':
resolution: {integrity: sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.13':
resolution: {integrity: sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
resolution: {integrity: sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
resolution: {integrity: sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
resolution: {integrity: sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.13':
resolution: {integrity: sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.13':
resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==}
engines: {node: '>= 10'}
'@tailwindcss/vite@4.1.13':
resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
@@ -460,6 +632,12 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
'@tauri-apps/plugin-updater@2.9.0':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -503,9 +681,19 @@ packages:
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -518,9 +706,17 @@ packages:
supports-color:
optional: true
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
electron-to-chromium@1.5.197:
resolution: {integrity: sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -539,6 +735,13 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -552,6 +755,70 @@ packages:
engines: {node: '>=6'}
hasBin: true
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.1:
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.1:
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.1:
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.1:
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.1:
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.1:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -559,6 +826,27 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.0.2:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -611,6 +899,20 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
tailwindcss@4.1.13:
resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==}
tapable@2.2.3:
resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
engines: {node: '>=6'}
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
@@ -656,9 +958,16 @@ packages:
terser:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
snapshots:
'@ampproject/remapping@2.3.0':
@@ -778,6 +1087,64 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@codemirror/autocomplete@6.18.7':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.11.3
'@lezer/json': 1.0.3
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
style-mod: 4.1.2
'@codemirror/lint@6.8.5':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
crelt: 1.0.6
'@codemirror/search@6.5.11':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
'@lezer/highlight': 1.2.1
'@codemirror/view@6.38.2':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.6
style-mod: 4.1.2
w3c-keyname: 2.2.8
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -847,20 +1214,49 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@lezer/common@1.2.3': {}
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/lr@1.4.2':
dependencies:
'@lezer/common': 1.2.3
'@marijn/find-cluster-break@1.0.2': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.46.2':
@@ -923,6 +1319,77 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
'@tailwindcss/node@4.1.13':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
jiti: 2.5.1
lightningcss: 1.30.1
magic-string: 0.30.18
source-map-js: 1.2.1
tailwindcss: 4.1.13
'@tailwindcss/oxide-android-arm64@4.1.13':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.13':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.13':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.13':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.13':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.13':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.13':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.13':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.13':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.13':
optional: true
'@tailwindcss/oxide@4.1.13':
dependencies:
detect-libc: 2.0.4
tar: 7.4.3
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.13
'@tailwindcss/oxide-darwin-arm64': 4.1.13
'@tailwindcss/oxide-darwin-x64': 4.1.13
'@tailwindcss/oxide-freebsd-x64': 4.1.13
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.13
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.13
'@tailwindcss/oxide-linux-arm64-musl': 4.1.13
'@tailwindcss/oxide-linux-x64-gnu': 4.1.13
'@tailwindcss/oxide-linux-x64-musl': 4.1.13
'@tailwindcss/oxide-wasm32-wasi': 4.1.13
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.13
'@tailwindcss/oxide-win32-x64-msvc': 4.1.13
'@tailwindcss/vite@4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
dependencies:
'@tailwindcss/node': 4.1.13
'@tailwindcss/oxide': 4.1.13
tailwindcss: 4.1.13
vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.8.1':
@@ -972,6 +1439,14 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.1
'@tauri-apps/cli-win32-x64-msvc': 2.8.1
'@tauri-apps/plugin-process@2.3.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-updater@2.9.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.0
@@ -1010,7 +1485,7 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.1.3
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9))':
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
@@ -1018,7 +1493,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.19(@types/node@20.19.9)
vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)
transitivePeerDependencies:
- supports-color
@@ -1031,16 +1506,37 @@ snapshots:
caniuse-lite@1.0.30001731: {}
chownr@3.0.0: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.18.7
'@codemirror/commands': 6.8.1
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.8.5
'@codemirror/search': 6.5.11
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.2
convert-source-map@2.0.0: {}
crelt@1.0.6: {}
csstype@3.1.3: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
detect-libc@2.0.4: {}
electron-to-chromium@1.5.197: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.3
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -1074,12 +1570,61 @@ snapshots:
gensync@1.0.0-beta.2: {}
graceful-fs@4.2.11: {}
jiti@2.5.1: {}
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
lightningcss-darwin-x64@1.30.1:
optional: true
lightningcss-freebsd-x64@1.30.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.1:
optional: true
lightningcss-linux-arm64-gnu@1.30.1:
optional: true
lightningcss-linux-arm64-musl@1.30.1:
optional: true
lightningcss-linux-x64-gnu@1.30.1:
optional: true
lightningcss-linux-x64-musl@1.30.1:
optional: true
lightningcss-win32-arm64-msvc@1.30.1:
optional: true
lightningcss-win32-x64-msvc@1.30.1:
optional: true
lightningcss@1.30.1:
dependencies:
detect-libc: 2.0.4
optionalDependencies:
lightningcss-darwin-arm64: 1.30.1
lightningcss-darwin-x64: 1.30.1
lightningcss-freebsd-x64: 1.30.1
lightningcss-linux-arm-gnueabihf: 1.30.1
lightningcss-linux-arm64-gnu: 1.30.1
lightningcss-linux-arm64-musl: 1.30.1
lightningcss-linux-x64-gnu: 1.30.1
lightningcss-linux-x64-musl: 1.30.1
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -1088,6 +1633,22 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.542.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minipass@7.1.2: {}
minizlib@3.0.2:
dependencies:
minipass: 7.1.2
mkdirp@3.0.1: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -1150,6 +1711,21 @@ snapshots:
source-map-js@1.2.1: {}
style-mod@4.1.2: {}
tailwindcss@4.1.13: {}
tapable@2.2.3: {}
tar@7.4.3:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.0.2
mkdirp: 3.0.1
yallist: 5.0.0
typescript@5.9.2: {}
undici-types@6.21.0: {}
@@ -1160,7 +1736,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
vite@5.4.19(@types/node@20.19.9):
vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
@@ -1168,5 +1744,10 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.9
fsevents: 2.3.3
lightningcss: 1.30.1
w3c-keyname@2.2.8: {}
yallist@3.1.1: {}
yallist@5.0.0: {}

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'

361
src-tauri/Cargo.lock generated
View File

@@ -90,6 +90,15 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -562,6 +571,8 @@ dependencies = [
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"toml 0.8.2",
]
@@ -834,6 +845,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1123,6 +1145,18 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.60.2",
]
[[package]]
name = "flate2"
version = "1.1.2"
@@ -1412,8 +1446,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1423,9 +1459,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -1694,6 +1732,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.16"
@@ -2102,6 +2157,7 @@ checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags 2.9.3",
"libc",
"redox_syscall",
]
[[package]]
@@ -2135,6 +2191,12 @@ dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2193,6 +2255,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2549,6 +2617,18 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d"
dependencies = [
"bitflags 2.9.3",
"objc2 0.6.2",
"objc2-app-kit 0.3.1",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -2655,6 +2735,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.2",
"objc2-foundation 0.3.1",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.16",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3042,6 +3136,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.16",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.16",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.40"
@@ -3088,6 +3237,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3108,6 +3267,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3126,6 +3295,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3253,16 +3431,21 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3272,6 +3455,21 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
@@ -3325,6 +3523,12 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3347,6 +3551,41 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rustls"
version = "0.23.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3791,6 +4030,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3926,6 +4171,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4108,6 +4364,48 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.16",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.8.0"
@@ -4347,6 +4645,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -4624,6 +4932,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
@@ -4850,6 +5164,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -4894,6 +5218,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.0"
@@ -5563,6 +5896,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -5702,6 +6045,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerotrie"
version = "0.2.2"
@@ -5735,6 +6084,18 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.11.0",
"memchr",
]
[[package]]
name = "zvariant"
version = "4.0.0"

View File

@@ -21,9 +21,11 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.8.2", features = [] }
tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
dirs = "5.0"
toml = "0.8"

View File

@@ -7,6 +7,8 @@
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"updater:default",
"process:allow-restart"
]
}

View File

@@ -4,9 +4,9 @@ use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use serde_json::Value;
use std::fs;
use std::path::Path;
use serde_json::Value;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
@@ -77,7 +77,8 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?;
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
// 第一步:写 auth.json
@@ -121,7 +122,9 @@ pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text).map(|_| ()).map_err(|e| format!("config.toml 语法错误: {}", e))
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)

View File

@@ -6,7 +6,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{ConfigStatus, get_claude_settings_path};
use crate::config::{get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
@@ -116,7 +116,9 @@ pub async fn add_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -146,7 +148,10 @@ pub async fn update_provider(
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
(manager.providers.contains_key(&provider.id), manager.current == provider.id)
(
manager.providers.contains_key(&provider.id),
manager.current == provider.id,
)
};
if !exists {
return Err(format!("供应商不存在: {}", provider.id));
@@ -182,7 +187,9 @@ pub async fn update_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -390,7 +397,8 @@ pub async fn import_default_config(
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value = crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => return Err(e),
@@ -488,7 +496,7 @@ pub async fn open_config_folder(
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config_dir = match app_type {
AppType::Claude => crate::config::get_claude_config_dir(),
AppType::Codex => crate::codex_config::get_codex_config_dir(),
@@ -500,7 +508,8 @@ pub async fn open_config_folder(
}
// 使用 opener 插件打开文件夹
handle.opener()
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
@@ -524,3 +533,68 @@ pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, S
Ok(true)
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
use crate::config::get_app_config_path;
let config_path = get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
use crate::config::get_app_config_dir;
let config_dir = get_app_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 获取设置
#[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 暂时返回默认设置
Ok(serde_json::json!({
"showInDock": true
}))
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(
_state: State<'_, AppState>,
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现设置保存逻辑
log::info!("保存设置: {:?}", settings);
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
// 打开 GitHub releases 页面
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}

View File

@@ -88,7 +88,6 @@ pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBu
Ok(Some(dest))
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()

View File

@@ -2,18 +2,238 @@ mod app_config;
mod codex_config;
mod commands;
mod config;
mod migration;
mod provider;
mod store;
mod migration;
use store::AppState;
use tauri::Manager;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, String> {
let config = app_state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let mut menu_builder = MenuBuilder::new(app);
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
for (id, provider) in &claude_manager.providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
for (id, provider) in &codex_manager.providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| format!("构建菜单失败: {}", e))
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
println!("处理托盘菜单事件: {}", event_id);
match event_id {
"quit" => {
println!("退出应用");
app.exit(0);
}
id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap();
println!("切换到Claude供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
)
.await
{
eprintln!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap();
println!("切换到Codex供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
)
.await
{
eprintln!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
println!("未处理的菜单事件: {}", event_id);
}
}
}
/// 内部切换供应商函数
async fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), String> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(
app_state.clone().into(),
Some(app_type),
None,
None,
provider_id,
)
.await?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
eprintln!("更新托盘菜单失败: {}", e);
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
eprintln!("发射供应商切换事件失败: {}", e);
}
}
Ok(())
}
/// 更新托盘菜单的Tauri命令
#[tauri::command]
async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
return Ok(true);
}
}
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
}
}
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
@@ -71,6 +291,36 @@ pub fn run() {
// 保存配置
let _ = app_state.save();
// 创建动态托盘菜单
let menu = create_tray_menu(&app.handle(), &app_state)?;
let _tray = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
println!("left click pressed and released");
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
println!("unhandled event {event:?}");
}
})
.menu(&menu)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0);
})
.icon(app.default_window_icon().unwrap().clone())
.show_menu_on_left_click(true)
.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
Ok(())
@@ -88,6 +338,12 @@ pub fn run() {
commands::get_claude_code_config_path,
commands::open_config_folder,
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
update_tray_menu,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -47,7 +47,10 @@ fn extract_claude_api_key(value: &Value) -> Option<String> {
fn extract_codex_api_key(value: &Value) -> Option<String> {
value
.get("auth")
.and_then(|auth| auth.get("OPENAI_API_KEY").or_else(|| auth.get("openai_api_key")))
.and_then(|auth| {
auth.get("OPENAI_API_KEY")
.or_else(|| auth.get("openai_api_key"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
@@ -77,7 +80,9 @@ fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
continue;
}
let name = fname.trim_start_matches("settings-").trim_end_matches(".json");
let name = fname
.trim_start_matches("settings-")
.trim_end_matches(".json");
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
items.push((name.to_string(), p, val));
}
@@ -104,7 +109,9 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
let entry = by_name.entry(name.to_string()).or_default();
entry.0 = Some(p);
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
let name = fname.trim_start_matches("config-").trim_end_matches(".toml");
let name = fname
.trim_start_matches("config-")
.trim_end_matches(".toml");
let entry = by_name.entry(name.to_string()).or_default();
entry.1 = Some(p);
}
@@ -183,17 +190,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_claude {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
@@ -203,43 +207,36 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_claude_id = Some(id);
}
}
for (name, path, value) in claude_items.iter() {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Claude 供应商 '{}' 来自 {} (by name+key)", name, path.display());
log::info!(
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
name,
path.display()
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -257,7 +254,10 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
String::new()
}
};
Some(("default".to_string(), serde_json::json!({"auth": auth, "config": cfg})))
Some((
"default".to_string(),
serde_json::json!({"auth": auth, "config": cfg}),
))
}
Err(e) => {
log::warn!("读取 Codex live auth.json 失败: {}", e);
@@ -277,17 +277,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_codex {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
@@ -297,43 +294,37 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_codex_id = Some(id);
}
}
for (name, authp, cfgp, value) in codex_items.iter() {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)", name, authp, cfgp);
log::info!(
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
name,
authp,
cfgp
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -370,13 +361,17 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
for (_, ap, cp, _) in codex_items.into_iter() {
if let Some(ap) = ap {
match archive_file(ts, "codex", &ap) {
Ok(Some(_)) => { let _ = delete_file(&ap); }
Ok(Some(_)) => {
let _ = delete_file(&ap);
}
_ => {}
}
}
if let Some(cp) = cp {
match archive_file(ts, "codex", &cp) {
Ok(Some(_)) => { let _ = delete_file(&cp); }
Ok(Some(_)) => {
let _ = delete_file(&cp);
}
_ => {}
}
}
@@ -404,7 +399,11 @@ pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
let mut remove: Vec<String> = Vec::new();
for (id, p) in mgr.providers.iter() {
let k = format!("{}|{}", norm_name(&p.name), extract_key(&p.settings_config).unwrap_or_default());
let k = format!(
"{}|{}",
norm_name(&p.name),
extract_key(&p.settings_config).unwrap_or_default()
);
if let Some(exist_id) = keep.get(&k) {
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
if *id == mgr.current {

View File

@@ -30,6 +30,7 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -38,4 +39,13 @@
"icons/icon.ico"
]
}
,
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRERTRCNEUxQUE3MDA4QTYKUldTbUNIQ3E0YlRrVFF2cnFVVE1jczlNZFlmemxXd0h6cTdibXRJWjBDSytQODdZOTYvR3d3d2oK",
"endpoints": [
"https://github.com/jasonyoung/cc-switch/releases/latest/download/latest.json"
]
}
}
}

View File

@@ -1,242 +0,0 @@
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
color: white;
padding: 0.35rem 2rem 0.45rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
". title ."
"tabs . actions";
align-items: center;
row-gap: 0.6rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
user-select: none;
}
.app-tabs {
grid-area: tabs;
}
/* Segmented control */
.segmented {
--seg-bg: rgba(255, 255, 255, 0.16);
--seg-thumb: #ffffff;
--seg-color: rgba(255, 255, 255, 0.85);
--seg-active: #2d89c7;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 280px;
background: var(--seg-bg);
border-radius: 999px;
padding: 4px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
backdrop-filter: saturate(140%) blur(2px);
}
.segmented-thumb {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--seg-thumb);
border-radius: 999px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition:
transform 220ms ease,
width 220ms ease;
will-change: transform;
}
.segmented-item {
position: relative;
z-index: 1;
background: transparent;
border: none;
border-radius: 999px;
padding: 6px 16px; /* 更紧凑的高度 */
color: var(--seg-color);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
transition: color 200ms ease;
}
.segmented-item.active {
color: var(--seg-active);
}
.segmented-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.8);
outline-offset: 2px;
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
grid-area: title;
text-align: center;
}
.header-actions {
display: flex;
gap: 1rem;
grid-area: actions;
justify-self: end;
}
.refresh-btn,
.add-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.refresh-btn {
background: #3498db;
color: white;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.import-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.import-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.import-btn:focus {
outline: none;
}
.add-btn {
background: #27ae60;
color: white;
border: none;
}
.add-btn:hover {
background: #229954;
}
.add-btn:focus {
outline: none;
}
.app-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.config-path {
margin-top: 2rem;
padding: 1rem;
background: #ecf0f1;
border-radius: 4px;
font-size: 0.9rem;
color: #7f8c8d;
display: flex;
justify-content: space-between;
align-items: center;
}
.browse-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
margin-left: 1rem;
}
.browse-btn:hover {
background: #2980b9;
}
/* 供应商列表区域 - 相对定位容器 */
.provider-section {
position: relative;
}
/* 浮动通知 - 绝对定位,不占据空间 */
.notification-floating {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
width: fit-content;
white-space: nowrap;
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
.fade-out {
animation: fadeOut 0.3s ease-out;
}
.notification-success {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: white;
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
}
.notification-error {
background: linear-gradient(135deg, #e74c3c 0%, #ec7063 100%);
color: white;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -6,17 +6,17 @@ import AddProviderModal from "./components/AddProviderModal";
import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import "./App.css";
import SettingsModal from "./components/SettingsModal";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
function App() {
const { isDarkMode, toggleDarkMode } = useDarkMode();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [configStatus, setConfigStatus] = useState<{
exists: boolean;
path: string;
} | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
);
@@ -31,6 +31,7 @@ function App() {
message: string;
onConfirm: () => void;
} | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 设置通知的辅助函数
@@ -62,7 +63,6 @@ function App() {
// 加载供应商列表
useEffect(() => {
loadProviders();
loadConfigStatus();
}, [activeApp]); // 当切换应用时重新加载
// 清理定时器
@@ -74,6 +74,35 @@ function App() {
};
}, []);
// 监听托盘切换事件
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
try {
unlisten = await window.api.onProviderSwitched(async (data) => {
console.log("收到供应商切换事件:", data);
// 如果当前应用类型匹配,则重新加载数据
if (data.appType === activeApp) {
await loadProviders();
}
});
} catch (error) {
console.error("设置供应商切换监听器失败:", error);
}
};
setupListener();
// 清理监听器
return () => {
if (unlisten) {
unlisten();
}
};
}, [activeApp]); // 依赖activeApp切换应用时重新设置监听器
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
const currentId = await window.api.getCurrentProvider(activeApp);
@@ -86,13 +115,6 @@ function App() {
}
};
const loadConfigStatus = async () => {
const status = await window.api.getConfigStatus(activeApp);
setConfigStatus({
exists: Boolean(status?.exists),
path: String(status?.path || ""),
});
};
// 生成唯一ID
const generateId = () => {
@@ -103,10 +125,13 @@ function App() {
const newProvider: Provider = {
...provider,
id: generateId(),
createdAt: Date.now(), // 添加创建时间戳
};
await window.api.addProvider(newProvider, activeApp);
await loadProviders();
setIsAddModalOpen(false);
// 更新托盘菜单
await window.api.updateTrayMenu();
};
const handleEditProvider = async (provider: Provider) => {
@@ -116,6 +141,8 @@ function App() {
setEditingProviderId(null);
// 显示编辑成功提示
showNotification("供应商配置已保存", "success", 2000);
// 更新托盘菜单
await window.api.updateTrayMenu();
} catch (error) {
console.error("更新供应商失败:", error);
setEditingProviderId(null);
@@ -134,6 +161,8 @@ function App() {
await loadProviders();
setConfirmDialog(null);
showNotification("供应商删除成功", "success");
// 更新托盘菜单
await window.api.updateTrayMenu();
},
});
};
@@ -149,6 +178,8 @@ function App() {
"success",
2000,
);
// 更新托盘菜单
await window.api.updateTrayMenu();
} else {
showNotification("切换失败,请检查配置", "error");
}
@@ -162,6 +193,8 @@ function App() {
if (result.success) {
await loadProviders();
showNotification("已从现有配置创建默认供应商", "success", 3000);
// 更新托盘菜单
await window.api.updateTrayMenu();
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
@@ -170,34 +203,57 @@ function App() {
}
};
const handleOpenConfigFolder = async () => {
await window.api.openConfigFolder(activeApp);
};
return (
<div className="app">
<header className="app-header">
<h1>CC Switch</h1>
<div className="app-tabs">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
</div>
<div className="header-actions">
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
</button>
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* Linear 风格的顶部导航 */}
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
CC Switch
</h1>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title="设置"
>
<Settings size={18} />
</button>
</div>
<div className="flex items-center gap-4">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<button
onClick={() => setIsAddModalOpen(true)}
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
</button>
</div>
</div>
</header>
<main className="app-main">
<div className="provider-section">
{/* 浮动通知组件 */}
{/* 主内容区域 */}
<main className="flex-1 p-6">
<div className="max-w-4xl mx-auto">
{/* 通知组件 */}
{notification && (
<div
className={`notification-floating ${
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "notification-error"
: "notification-success"
} ${isNotificationVisible ? "fade-in" : "fade-out"}`}
? "bg-red-500 text-white"
: "bg-green-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
>
{notification.message}
</div>
@@ -210,23 +266,8 @@ function App() {
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
/>
</div>
{configStatus && (
<div className="config-path">
<span>
: {configStatus.path}
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
</span>
<button
className="browse-btn"
onClick={handleOpenConfigFolder}
title="打开配置文件夹"
>
</button>
</div>
)}
</div>
</main>
{isAddModalOpen && (
@@ -255,6 +296,10 @@ function App() {
onCancel={() => setConfirmDialog(null)}
/>
)}
{isSettingsOpen && (
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
)}
</div>
);
}

View File

@@ -1,268 +0,0 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 10px;
padding: 0;
width: 90%;
max-width: 640px;
max-height: 90vh;
overflow: hidden; /* 由 body 滚动,标题栏固定 */
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1001;
display: flex; /* 纵向布局,便于底栏固定 */
flex-direction: column;
}
/* 模拟窗口标题栏 */
.modal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem; /* 与主窗口标题栏一致 */
padding: 0 12px; /* 接近主头部的水平留白 */
background: #3498db; /* 与 .app-header 相同 */
color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
.modal-spacer {
width: 32px;
flex: 0 0 32px;
}
.modal-title {
flex: 1;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-close-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.modal-close-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modal-form {
/* 表单外层包裹 body + footer */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0; /* 允许子元素正确计算高度 */
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
overflow: auto; /* 仅内容区滚动 */
flex: 1 1 auto;
min-height: 0;
}
.error-message {
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #fcc;
font-size: 0.9rem;
}
.presets {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #ecf0f1;
}
.presets label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.preset-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.preset-btn:hover,
.preset-btn.selected {
background: #3498db;
color: white;
}
/* 官方按钮橙色主题Anthropic 风格) */
.preset-btn.official {
border: 1px solid #d97706;
color: #d97706;
}
.preset-btn.official:hover,
.preset-btn.official.selected {
background: #d97706;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
/* API Key 输入框容器 - 预留空间避免抖动 */
.form-group.api-key-group {
min-height: 88px; /* 固定高度label + input + 间距 */
transition: opacity 0.2s ease;
}
.form-group.api-key-group.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s;
background: white;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
min-height: 200px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.modal-footer {
/* 固定在弹窗底部(非滚动区) */
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 0.75rem 1.5rem;
border-top: 1px solid #ecf0f1;
background: #fff;
}
.cancel-btn,
.submit-btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.cancel-btn {
background: #ecf0f1;
color: #555;
}
.cancel-btn:hover {
background: #bdc3c7;
}
.submit-btn {
background: #27ae60;
color: white;
}
.submit-btn:hover {
background: #229954;
}
.field-hint {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
font-size: 0.8rem;
line-height: 1.3;
}
/* 添加标签和选择框的样式 */
.label-with-checkbox {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.label-with-checkbox label:first-child {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: #666;
font-weight: normal;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 2px;
cursor: pointer;
transform: translateY(2px);
}

View File

@@ -1,73 +0,0 @@
/* 药丸式切换按钮 */
.switcher-pills {
display: inline-flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 6px 8px;
border-radius: 50px;
backdrop-filter: blur(10px);
}
.switcher-pill {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease;
min-width: 120px;
}
.switcher-pill:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.switcher-pill.active {
background: rgba(255, 255, 255, 0.15);
color: white;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
transition: all 200ms ease;
}
.switcher-pill.active .pill-dot {
opacity: 1;
box-shadow: 0 0 8px currentColor;
animation: pulse 2s infinite;
}
.pills-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}

View File

@@ -1,5 +1,5 @@
import { AppType } from "../lib/tauri-api";
import "./AppSwitcher.css";
import { Terminal, Code2 } from "lucide-react";
interface AppSwitcherProps {
activeApp: AppType;
@@ -13,22 +13,30 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="switcher-pills">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent dark:border-gray-700">
<button
type="button"
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
onClick={() => handleSwitch("claude")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<span className="pill-dot" />
<Code2 size={16} />
<span>Claude Code</span>
</button>
<div className="pills-divider" />
<button
type="button"
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<span className="pill-dot" />
<Terminal size={16} />
<span>Codex</span>
</button>
</div>

View File

@@ -1,107 +0,0 @@
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.confirm-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 400px;
animation: confirmSlideIn 0.2s ease-out;
}
@keyframes confirmSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.confirm-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid #eee;
}
.confirm-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.confirm-content {
padding: 1rem 1.5rem;
}
.confirm-content p {
margin: 0;
color: #666;
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.confirm-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition:
background-color 0.2s,
transform 0.1s;
min-width: 70px;
}
.confirm-btn:hover {
transform: translateY(-1px);
}
.confirm-btn:active {
transform: translateY(0);
}
.cancel-btn {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.cancel-btn:hover {
background: #e9ecef;
}
.confirm-btn-primary {
background: #dc3545;
color: white;
}
.confirm-btn-primary:hover {
background: #c82333;
}
.confirm-btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import "./ConfirmDialog.css";
import { AlertTriangle, X } from "lucide-react";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -23,25 +23,52 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
if (!isOpen) return null;
return (
<div className="confirm-overlay">
<div className="confirm-dialog">
<div className="confirm-header">
<h3>{title}</h3>
</div>
<div className="confirm-content">
<p>{message}</p>
</div>
<div className="confirm-actions">
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
</div>
<button
className="confirm-btn cancel-btn"
onClick={onCancel}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
autoFocus
>
{cancelText}
</button>
<button
className="confirm-btn confirm-btn-primary"
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
>
{confirmText}
</button>

View File

@@ -0,0 +1,97 @@
import React, { useRef, useEffect } from "react";
import { EditorView, basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
rows?: number;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
value,
onChange,
placeholder: placeholderText = "",
darkMode = false,
rows = 12,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
// 创建编辑器扩展
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
const sizingTheme = EditorView.theme({
"&": { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
});
const extensions = [
basicSetup,
json(),
placeholder(placeholderText || ""),
sizingTheme,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
];
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// 清理函数
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
const transaction = viewRef.current.state.update({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
viewRef.current.dispatch(transaction);
}
}, [value]);
return <div ref={editorRef} style={{ width: "100%" }} />;
};
export default JsonEditor;

View File

@@ -4,14 +4,18 @@ import { AppType } from "../lib/tauri-api";
import {
updateCoAuthoredSetting,
checkCoAuthoredSetting,
extractWebsiteUrl,
getApiKeyFromConfig,
hasApiKeyField,
setApiKeyInConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import "./AddProviderModal.css";
import PresetSelector from "./ProviderForm/PresetSelector";
import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
interface ProviderFormProps {
appType?: AppType;
@@ -70,6 +74,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}
}, [isCodex, initialData]);
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
@@ -78,12 +83,33 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
);
const [apiKey, setApiKey] = useState("");
// Kimi 模型选择状态
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
useState("");
// 初始化时检查禁用签名状态
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 初始化 Kimi 模型选择(编辑模式)
if (
initialData.settingsConfig &&
typeof initialData.settingsConfig === "object"
) {
const config = initialData.settingsConfig as {
env?: Record<string, any>;
};
if (config.env) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
);
}
}
}
}, [initialData]);
@@ -160,9 +186,6 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const { name, value } = e.target;
if (name === "settingsConfig") {
// 当用户修改配置时,尝试自动提取官网地址
const extractedWebsiteUrl = extractWebsiteUrl(value);
// 同时检查并同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
setDisableCoAuthored(hasCoAuthoredDisabled);
@@ -171,12 +194,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const parsedKey = getApiKeyFromConfig(value);
setApiKey(parsedKey);
setFormData({
...formData,
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
setFormData((prev) => ({
...prev,
[name]: value,
// 只有在官网地址为空时才自动填入
websiteUrl: formData.websiteUrl || extractedWebsiteUrl,
});
}));
} else {
setFormData({
...formData,
@@ -218,6 +240,24 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 如果是 Kimi 预设,初始化模型选择
if (
preset.name?.includes("Kimi") &&
preset.settingsConfig &&
typeof preset.settingsConfig === "object"
) {
const config = preset.settingsConfig as { env?: Record<string, any> };
if (config.env) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
);
}
} else {
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
}
};
// 处理点击自定义按钮
@@ -230,6 +270,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
setApiKey("");
setDisableCoAuthored(false);
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
};
// Codex: 应用预设
@@ -241,11 +283,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuth(authString);
setCodexConfig(preset.config || "");
setFormData({
setFormData((prev) => ({
...prev,
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: formData.settingsConfig,
});
}));
setSelectedCodexPreset(index);
@@ -311,6 +353,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.isOfficial === true;
// 判断当前选中的预设是否是 Kimi
const isKimiPreset =
selectedPreset !== null &&
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.name?.includes("Kimi");
// 判断当前编辑的是否是 Kimi 提供商(通过名称或配置判断)
const isEditingKimi =
initialData &&
(formData.name.includes("Kimi") ||
formData.name.includes("kimi") ||
(formData.settingsConfig.includes("api.moonshot.cn") &&
formData.settingsConfig.includes("ANTHROPIC_MODEL")));
// 综合判断是否应该显示 Kimi 模型选择器
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
// Codex: 控制显示 API Key 与官方标记
const getCodexAuthApiKey = (authString: string): string => {
try {
@@ -320,15 +379,44 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return "";
}
};
// 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset =
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
// Kimi 模型选择处理函数
const handleKimiModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
) => {
if (field === "ANTHROPIC_MODEL") {
setKimiAnthropicModel(value);
} else {
setKimiAnthropicSmallFastModel(value);
}
// 更新配置 JSON
try {
const currentConfig = JSON.parse(formData.settingsConfig || "{}");
if (!currentConfig.env) currentConfig.env = {};
currentConfig.env[field] = value;
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
setFormData((prev) => ({
...prev,
settingsConfig: updatedConfigString,
}));
} catch (err) {
console.error("更新 Kimi 模型配置失败:", err);
}
};
// 初始时从配置中同步 API Key编辑模式
useEffect(() => {
if (initialData) {
@@ -354,130 +442,74 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return (
<div
className="modal-overlay"
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-content">
<div className="modal-titlebar">
<div className="modal-spacer" />
<div className="modal-title" title={title}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{title}
</div>
</h2>
<button
type="button"
className="modal-close-btn"
aria-label="关闭"
onClick={onClose}
title="关闭"
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
aria-label="关闭"
>
×
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-auto p-6 space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-red-100 border border-red-500/20 rounded-lg">
<AlertCircle
size={20}
className="text-red-500 flex-shrink-0"
/>
<p className="text-red-500 text-sm font-medium">
{error}
</p>
</div>
)}
{showPresets && !isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedPreset === -1 ? "selected" : ""
}`}
onClick={handleCustomClick}
>
</button>
{providerPresets.map((preset, index) => {
return (
<button
key={index}
type="button"
className={`preset-btn ${
selectedPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
onClick={() => applyPreset(preset, index)}
>
{preset.name}
</button>
);
})}
</div>
{selectedPreset === -1 && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
</small>
)}
{selectedPreset !== -1 && selectedPreset !== null && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</div>
<PresetSelector
presets={providerPresets}
selectedIndex={selectedPreset}
onSelectPreset={(index) =>
applyPreset(providerPresets[index], index)
}
onCustomClick={handleCustomClick}
/>
)}
{showPresets && isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedCodexPreset === -1 ? "selected" : ""
}`}
onClick={handleCodexCustomClick}
>
</button>
{codexProviderPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`preset-btn ${
selectedCodexPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
onClick={() => applyCodexPreset(preset, index)}
>
{preset.name}
</button>
))}
</div>
{selectedCodexPreset === -1 && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
</small>
)}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</div>
<PresetSelector
presets={codexProviderPresets}
selectedIndex={selectedCodexPreset}
onSelectPreset={(index) =>
applyCodexPreset(codexProviderPresets[index], index)
}
onCustomClick={handleCodexCustomClick}
/>
)}
<div className="form-group">
<label htmlFor="name"> *</label>
<div className="space-y-2">
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900"
>
*
</label>
<input
type="text"
id="name"
@@ -487,76 +519,62 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
placeholder="例如Anthropic 官方"
required
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
/>
</div>
{!isCodex && (
<div
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
>
<label htmlFor="apiKey">API Key *</label>
<input
type="text"
id="apiKey"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key直接保存即可"
{!isCodex && showApiKey && (
<ApiKeyInput
value={apiKey}
onChange={handleApiKeyChange}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key直接保存即可"
: shouldShowKimiSelector
? "sk-xxx-api-key-here (填写后可获取模型列表)"
: "只需要填这里,下方配置会自动填充"
}
disabled={isOfficialPreset}
autoComplete="off"
style={
isOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
/>
</div>
}
disabled={isOfficialPreset}
/>
)}
{isCodex && (
<div
className={`form-group api-key-group ${!showCodexApiKey ? "hidden" : ""}`}
{!isCodex && shouldShowKimiSelector && apiKey.trim() && (
<KimiModelSelector
apiKey={apiKey}
anthropicModel={kimiAnthropicModel}
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
onModelChange={handleKimiModelChange}
disabled={isOfficialPreset}
/>
)}
{isCodex && showCodexApiKey && (
<ApiKeyInput
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={handleCodexApiKeyChange}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
}
/>
)}
<div className="space-y-2">
<label
htmlFor="websiteUrl"
className="block text-sm font-medium text-gray-900"
>
<label htmlFor="codexApiKey">API Key *</label>
<input
type="text"
id="codexApiKey"
value={codexApiKey}
onChange={(e) => handleCodexApiKeyChange(e.target.value)}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
}
autoComplete="off"
style={
isCodexOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
/>
</div>
)}
<div className="form-group">
<label htmlFor="websiteUrl"></label>
</label>
<input
type="url"
id="websiteUrl"
@@ -565,100 +583,58 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
onChange={handleChange}
placeholder="https://example.com可选"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
/>
</div>
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
// Codex: 双编辑器
<>
<div className="form-group">
<label htmlFor="codexAuth">auth.json (JSON) *</label>
<textarea
id="codexAuth"
value={codexAuth}
onChange={(e) => {
const value = e.target.value;
setCodexAuth(value);
try {
const auth = JSON.parse(value || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
}}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
/>
<small className="field-hint">Codex auth.json </small>
</div>
<div className="form-group">
<label htmlFor="codexConfig">config.toml (TOML)</label>
<textarea
id="codexConfig"
value={codexConfig}
onChange={(e) => setCodexConfig(e.target.value)}
placeholder={``}
rows={8}
style={{ fontFamily: "monospace", fontSize: "14px" }}
/>
<small className="field-hint">
Codex config.toml
</small>
</div>
</>
<CodexConfigEditor
authValue={codexAuth}
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={setCodexConfig}
onAuthBlur={() => {
try {
const auth = JSON.parse(codexAuth || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
}}
/>
) : (
// Claude: 原有的单编辑器
<div className="form-group">
<div className="label-with-checkbox">
<label htmlFor="settingsConfig">
Claude Code (JSON) *
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
/>
Claude Code
</label>
</div>
<textarea
id="settingsConfig"
name="settingsConfig"
value={formData.settingsConfig}
onChange={handleChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
}
}`}
rows={12}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
/>
<small className="field-hint">
Claude Code settings.json
</small>
</div>
<ClaudeConfigEditor
value={formData.settingsConfig}
onChange={(value) =>
handleChange({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
disableCoAuthored={disableCoAuthored}
onCoAuthoredToggle={handleCoAuthoredToggle}
/>
)}
</div>
<div className="modal-footer">
<button type="button" className="cancel-btn" onClick={onClose}>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white rounded-lg transition-colors"
>
</button>
<button type="submit" className="submit-btn">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium"
>
<Save size={16} />
{submitText}
</button>
</div>

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
interface ApiKeyInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
label?: string;
id?: string;
}
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
value,
onChange,
placeholder = "请输入API Key",
disabled = false,
required = false,
label = "API Key",
id = "apiKey",
}) => {
const [showKey, setShowKey] = useState(false);
const toggleShowKey = () => {
setShowKey(!showKey);
};
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
}`;
return (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-gray-900"
>
{label} {required && "*"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className={inputClass}
/>
{!disabled && value && (
<button
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-900 transition-colors"
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
);
};
export default ApiKeyInput;

View File

@@ -0,0 +1,54 @@
import React from "react";
import JsonEditor from "../JsonEditor";
interface ClaudeConfigEditorProps {
value: string;
onChange: (value: string) => void;
disableCoAuthored: boolean;
onCoAuthoredToggle: (checked: boolean) => void;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
disableCoAuthored,
onCoAuthoredToggle,
}) => {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="settingsConfig"
className="block text-sm font-medium text-gray-900"
>
Claude Code (JSON) *
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white border-gray-200 rounded focus:ring-blue-500 focus:ring-2"
/>
Claude Code
</label>
</div>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
}
}`}
rows={12}
/>
<p className="text-xs text-gray-500">
Claude Code settings.json
</p>
</div>
);
};
export default ClaudeConfigEditor;

View File

@@ -0,0 +1,67 @@
import React from "react";
interface CodexConfigEditorProps {
authValue: string;
configValue: string;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
onAuthBlur?: () => void;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
authValue,
configValue,
onAuthChange,
onConfigChange,
onAuthBlur,
}) => {
return (
<div className="space-y-6">
<div className="space-y-2">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900"
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={authValue}
onChange={(e) => onAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
required
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[8rem]"
/>
<p className="text-xs text-gray-500">
Codex auth.json
</p>
</div>
<div className="space-y-2">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900"
>
config.toml (TOML)
</label>
<textarea
id="codexConfig"
value={configValue}
onChange={(e) => onConfigChange(e.target.value)}
placeholder=""
rows={8}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[10rem]"
/>
<p className="text-xs text-gray-500">
Codex config.toml
</p>
</div>
</div>
);
};
export default CodexConfigEditor;

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from "react";
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
interface KimiModel {
id: string;
object: string;
created: number;
owned_by: string;
}
interface KimiModelSelectorProps {
apiKey: string;
anthropicModel: string;
anthropicSmallFastModel: string;
onModelChange: (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
) => void;
disabled?: boolean;
}
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
apiKey,
anthropicModel,
anthropicSmallFastModel,
onModelChange,
disabled = false,
}) => {
const [models, setModels] = useState<KimiModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [debouncedKey, setDebouncedKey] = useState("");
// 获取模型列表
const fetchModelsWithKey = async (key: string) => {
if (!key) {
setError("请先填写 API Key");
return;
}
setLoading(true);
setError("");
try {
const response = await fetch("https://api.moonshot.cn/v1/models", {
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
setModels(data.data);
} else {
throw new Error("返回数据格式错误");
}
} catch (err) {
console.error("获取模型列表失败:", err);
setError(err instanceof Error ? err.message : "获取模型列表失败");
} finally {
setLoading(false);
}
};
// 500ms 防抖 API Key
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKey(apiKey.trim());
}, 500);
return () => clearTimeout(timer);
}, [apiKey]);
// 当防抖后的 Key 改变时自动获取模型列表
useEffect(() => {
if (debouncedKey) {
fetchModelsWithKey(debouncedKey);
} else {
setModels([]);
setError("");
}
}, [debouncedKey]);
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white ${
disabled
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
}`;
const ModelSelect: React.FC<{
label: string;
value: string;
onChange: (value: string) => void;
}> = ({ label, value, onChange }) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-900">
{label}
</label>
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading || models.length === 0}
className={selectClass}
>
<option value="">
{loading
? "加载中..."
: models.length === 0
? "暂无模型"
: "请选择模型"}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.id}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 pointer-events-none"
/>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">
</h3>
<button
type="button"
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
disabled={disabled || loading || !debouncedKey}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-100 border border-red-500/20 rounded-lg">
<AlertCircle
size={16}
className="text-red-500 flex-shrink-0"
/>
<p className="text-red-500 text-xs">{error}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect
label="主模型 (ANTHROPIC_MODEL)"
value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/>
<ModelSelect
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)"
value={anthropicSmallFastModel}
onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
}
/>
</div>
{!apiKey.trim() && (
<div className="p-3 bg-gray-100 border border-gray-200 rounded-lg">
<p className="text-xs text-gray-500">
📝 API Keysk-xxx-api-key-here
</p>
</div>
)}
</div>
);
};
export default KimiModelSelector;

View File

@@ -0,0 +1,91 @@
import React from "react";
import { Zap } from "lucide-react";
interface Preset {
name: string;
isOfficial?: boolean;
}
interface PresetSelectorProps {
title?: string;
presets: Preset[];
selectedIndex: number | null;
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
title = "选择配置类型",
presets,
selectedIndex,
onSelectPreset,
onCustomClick,
customLabel = "自定义",
}) => {
const getButtonClass = (index: number, isOfficial?: boolean) => {
const isSelected = selectedIndex === index;
const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) {
return isOfficial
? `${baseClass} bg-amber-500 text-white`
: `${baseClass} bg-blue-500 text-white`;
}
return `${baseClass} bg-gray-100 text-gray-500 hover:bg-gray-200`;
};
const getDescription = () => {
if (selectedIndex === -1) {
return "手动配置供应商,需要填写完整的配置信息";
}
if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex];
return preset?.isOfficial
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key";
}
return null;
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 mb-3">
{title}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? '' : ''}`}
onClick={onCustomClick}
>
{customLabel}
</button>
{presets.map((preset, index) => (
<button
key={index}
type="button"
className={getButtonClass(index, preset.isOfficial)}
onClick={() => onSelectPreset(index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
))}
</div>
</div>
{getDescription() && (
<p className="text-sm text-gray-500">
{getDescription()}
</p>
)}
</div>
);
};
export default PresetSelector;

View File

@@ -1,206 +0,0 @@
.provider-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #7f8c8d;
}
.empty-state p:first-child {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.provider-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-item {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #ecf0f1;
border-radius: 6px;
transition: all 0.2s;
}
.provider-item:hover {
border-color: #3498db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.provider-item.current {
border-color: #27ae60;
background: #f0fdf4;
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.provider-name input[type="radio"] {
cursor: pointer;
}
.provider-name input[type="radio"]:disabled {
cursor: not-allowed;
}
.current-badge {
background: #27ae60;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
}
.provider-url {
color: #7f8c8d;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.url-link {
color: #3498db;
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.url-link:hover {
color: #2980b9;
text-decoration: underline;
}
.api-url {
color: #7f8c8d;
}
.provider-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 2rem;
}
.status-icon {
font-size: 1.1rem;
}
.status-text {
color: #555;
font-size: 0.9rem;
}
.response-time {
color: #3498db;
font-size: 0.85rem;
font-family: monospace;
}
.provider-actions {
display: flex;
gap: 0.5rem;
}
.check-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #f39c12;
background: white;
color: #f39c12;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.check-btn:hover:not(:disabled) {
background: #f39c12;
color: white;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.enable-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.enable-btn:hover:not(:disabled) {
background: #27ae60;
color: white;
}
.enable-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.edit-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.edit-btn:hover:not(:disabled) {
background: #3498db;
color: white;
}
.edit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #e74c3c;
background: white;
color: #e74c3c;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #e74c3c;
color: white;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,6 +1,7 @@
import React from "react";
import { Provider } from "../types";
import "./ProviderList.css";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -17,14 +18,20 @@ const ProviderList: React.FC<ProviderListProps> = ({
onDelete,
onEdit,
}) => {
// 提取API地址
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
const config = provider.settingsConfig;
if (config?.env?.ANTHROPIC_BASE_URL) {
return config.env.ANTHROPIC_BASE_URL;
const cfg = provider.settingsConfig;
// Claude/Anthropic: 从 env 中读取
if (cfg?.env?.ANTHROPIC_BASE_URL) {
return cfg.env.ANTHROPIC_BASE_URL;
}
return "未设置";
// Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
if (match && match[1]) return match[1];
}
return "未配置官网地址";
} catch {
return "配置错误";
}
@@ -38,72 +45,128 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
};
// 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => {
// 按添加时间排序
// 没有时间戳的视为最早添加的(排在最前面)
// 有时间戳的按时间升序排列
const timeA = a.createdAt || 0;
const timeB = b.createdAt || 0;
// 如果都没有时间戳,按名称排序
if (timeA === 0 && timeB === 0) {
return a.name.localeCompare(b.name, 'zh-CN');
}
// 如果只有一个没有时间戳,没有时间戳的排在前面
if (timeA === 0) return -1;
if (timeB === 0) return 1;
// 都有时间戳,按时间升序
return timeA - timeB;
});
return (
<div className="provider-list">
{Object.values(providers).length === 0 ? (
<div className="empty-state">
<p></p>
<p>"添加供应商"</p>
<div className="space-y-4">
{sortedProviders.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<Users size={24} className="text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
"添加供应商"API供应商
</p>
</div>
) : (
<div className="provider-items">
{Object.values(providers).map((provider) => {
<div className="space-y-3">
{sortedProviders.map((provider) => {
const isCurrent = provider.id === currentProviderId;
const apiUrl = getApiUrl(provider);
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? "current" : ""}`}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="provider-info">
<div className="provider-name">
<span>{provider.name}</span>
{isCurrent && (
<span className="current-badge">使</span>
)}
</div>
<div className="provider-url">
{provider.websiteUrl ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
>
{provider.websiteUrl}
</a>
) : (
<span className="api-url" title={getApiUrl(provider)}>
{getApiUrl(provider)}
</span>
)}
</div>
</div>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</h3>
{isCurrent && (
<div className={badgeStyles.success}>
<CheckCircle2 size={12} />
使
</div>
)}
</div>
<div className="provider-actions">
<button
className="enable-btn"
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
>
</button>
<button
className="edit-btn"
onClick={() => onEdit(provider.id)}
>
</button>
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
>
</button>
<div className="flex items-center gap-2 text-sm">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={`访问 ${provider.websiteUrl}`}
>
{provider.websiteUrl}
</button>
) : (
<span
className="text-gray-500 dark:text-gray-400"
title={apiUrl}
>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
<Play size={14} />
{isCurrent ? "使用中" : "启用"}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title="编辑供应商"
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={cn(
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
)}
title="删除供应商"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from "react";
import { X, Info, RefreshCw, FolderOpen } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import "../lib/tauri-api";
import { runUpdateFlow } from "../lib/updater";
import type { Settings } from "../types";
interface SettingsModalProps {
onClose: () => void;
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
const [settings, setSettings] = useState<Settings>({
showInDock: true,
});
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
useEffect(() => {
loadSettings();
loadConfigPath();
loadVersion();
}, []);
const loadVersion = async () => {
try {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error("获取版本信息失败:", error);
setVersion("3.1.1"); // 降级使用默认版本
}
};
const loadSettings = async () => {
try {
const loadedSettings = await window.api.getSettings();
if (loadedSettings?.showInDock !== undefined) {
setSettings({ showInDock: loadedSettings.showInDock });
}
} catch (error) {
console.error("加载设置失败:", error);
}
};
const loadConfigPath = async () => {
try {
const path = await window.api.getAppConfigPath();
if (path) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", error);
}
};
const saveSettings = async () => {
try {
await window.api.saveSettings(settings);
onClose();
} catch (error) {
console.error("保存设置失败:", error);
}
};
const handleCheckUpdate = async () => {
setIsCheckingUpdate(true);
try {
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面
await runUpdateFlow({ timeout: 30000 });
} catch (error) {
console.error("检查更新失败,回退到 Releases 页面:", error);
await window.api.checkForUpdates();
} finally {
setIsCheckingUpdate(false);
}
};
const handleOpenConfigFolder = async () => {
try {
await window.api.openAppConfigFolder();
} catch (error) {
console.error("打开配置文件夹失败:", error);
}
};
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
</h2>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6">
{/* 显示设置 - 功能还未实现 */}
{/* <div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
显示设置
</h3>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-500">
在 Dock 中显示macOS
</span>
<input
type="checkbox"
checked={settings.showInDock}
onChange={(e) =>
setSettings({ ...settings, showInDock: e.target.checked })
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div> */}
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{configPath || "加载中..."}
</span>
</div>
<button
onClick={handleOpenConfigFolder}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="打开文件夹"
>
<FolderOpen
size={18}
className="text-gray-500 dark:text-gray-400"
/>
</button>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Info
size={18}
className="text-gray-500 mt-0.5"
/>
<div className="text-sm">
<p className="font-medium text-gray-900 dark:text-gray-100">
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{version}
</p>
</div>
</div>
<button
onClick={handleCheckUpdate}
disabled={isCheckingUpdate}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
isCheckingUpdate
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400"
}`}
>
{isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span>
) : (
"检查更新"
)}
</button>
</div>
</div>
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
</button>
<button
onClick={saveSettings}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -23,7 +23,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
},
@@ -35,7 +35,7 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
},
},
},
@@ -46,7 +46,7 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
},
},
},
@@ -56,19 +56,31 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
},
},
},
{
name: "魔搭",
websiteUrl: "https://modelscope.cn",
settingsConfig: {
env: {
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
},
},
},
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
},
},
},

80
src/hooks/useDarkMode.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
export function useDarkMode() {
// 初始设为 false挂载后在 useEffect 中加载真实值
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState(false);
// 组件挂载后加载初始值(兼容 Tauri 环境)
useEffect(() => {
if (typeof window === 'undefined') return;
try {
// 尝试读取已保存的偏好
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
const savedBool = saved === 'true';
setIsDarkMode(savedBool);
console.log('[DarkMode] Loaded from localStorage:', savedBool);
} else {
// 回退到系统偏好
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
setIsDarkMode(prefersDark);
console.log('[DarkMode] Using system preference:', prefersDark);
}
} catch (error) {
console.error('[DarkMode] Error loading preference:', error);
setIsDarkMode(false);
}
setIsInitialized(true);
}, []); // 仅在首次挂载时运行
// 将 dark 类应用到文档根节点
useEffect(() => {
if (!isInitialized) return;
// 添加短暂延迟以确保 Tauri 中 DOM 已就绪
const timer = setTimeout(() => {
try {
if (isDarkMode) {
document.documentElement.classList.add('dark');
console.log('[DarkMode] Added dark class to document');
} else {
document.documentElement.classList.remove('dark');
console.log('[DarkMode] Removed dark class from document');
}
// 检查类名是否已成功应用
const hasClass = document.documentElement.classList.contains('dark');
console.log('[DarkMode] Document has dark class:', hasClass);
} catch (error) {
console.error('[DarkMode] Error applying dark class:', error);
}
}, 0);
return () => clearTimeout(timer);
}, [isDarkMode, isInitialized]);
// 将偏好保存到 localStorage
useEffect(() => {
if (!isInitialized) return;
try {
localStorage.setItem('darkMode', isDarkMode.toString());
console.log('[DarkMode] Saved to localStorage:', isDarkMode);
} catch (error) {
console.error('[DarkMode] Error saving preference:', error);
}
}, [isDarkMode, isInitialized]);
const toggleDarkMode = () => {
setIsDarkMode(prev => {
const newValue = !prev;
console.log('[DarkMode] Toggling from', prev, 'to', newValue);
return newValue;
});
};
return { isDarkMode, toggleDarkMode };
}

View File

@@ -1,30 +1,46 @@
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为“类选择器”模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* 全局基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
@apply font-sans antialiased;
line-height: 1.5;
/* 让原生控件与滚动条随主题切换配色 */
color-scheme: light;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
@apply m-0 p-0 bg-gray-50 text-gray-900 text-sm dark:bg-gray-950 dark:text-gray-100;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
/* 暗色模式下启用暗色原生控件/滚动条配色 */
html.dark { color-scheme: dark; }
/* 滚动条样式 */
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
}
/* 仅在 macOS 下为顶部预留交通灯空间 */
/* 保持 mac 下与内容区域左对齐(不额外偏移) */
/* 在 macOS 下稍微增加 banner 高度,拉开与交通灯的垂直距离 */
body.is-mac .app-header {
padding-top: 1.4rem;
padding-bottom: 1.4rem;
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded dark:bg-gray-600;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
/* 焦点样式 */
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
}

68
src/lib/styles.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* 复用的 Tailwind 样式组合,覆盖常见 UI 模式
*/
// 按钮样式
export const buttonStyles = {
// 主按钮:蓝底白字
primary: "px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
// 次按钮:灰背景,深色文本
secondary: "px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
// 危险按钮:用于不可撤销/破坏性操作
danger: "px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
// 幽灵按钮:无背景,仅悬浮反馈
ghost: "px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
// 图标按钮:小尺寸,仅图标
icon: "p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors",
// 禁用态:可与其他样式组合
disabled: "opacity-50 cursor-not-allowed pointer-events-none",
} as const;
// 卡片样式
export const cardStyles = {
// 基础卡片容器
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
// 带悬浮效果的卡片
interactive: "bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
// 选中/激活态卡片
selected: "bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
} as const;
// 输入控件样式
export const inputStyles = {
// 文本输入框
text: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 下拉选择框
select: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 复选框
checkbox: "w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
} as const;
// 徽标Badge样式
export const badgeStyles = {
// 成功徽标
success: "inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
// 信息徽标
info: "inline-flex items-center gap-1 px-2 py-1 bg-blue-500/10 text-blue-500 rounded-md text-xs font-medium",
// 警告徽标
warning: "inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
// 错误徽标
error: "inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
} as const;
// 组合类名的工具函数
export function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(' ');
}

View File

@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { Provider } from "../types";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Provider, Settings } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -167,6 +168,25 @@ export const tauriAPI = {
}
},
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string });
});
},
// (保留空位,取消迁移提示)
// 选择配置文件Tauri 暂不实现,保留接口兼容性)
@@ -174,6 +194,54 @@ export const tauriAPI = {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
return null;
},
// 获取设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
return { showInDock: true };
}
},
// 保存设置
saveSettings: async (settings: Settings): Promise<boolean> => {
try {
return await invoke("save_settings", { settings });
} catch (error) {
console.error("保存设置失败:", error);
return false;
}
},
// 检查更新
checkForUpdates: async (): Promise<void> => {
try {
await invoke("check_for_updates");
} catch (error) {
console.error("检查更新失败:", error);
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {
return await invoke("get_app_config_path");
} catch (error) {
console.error("获取应用配置路径失败:", error);
return "";
}
},
// 打开应用配置文件夹
openAppConfigFolder: async (): Promise<void> => {
try {
await invoke("open_app_config_folder");
} catch (error) {
console.error("打开应用配置文件夹失败:", error);
}
},
};
// 创建全局 API 对象,兼容现有代码

146
src/lib/updater.ts Normal file
View File

@@ -0,0 +1,146 @@
import { getVersion } from "@tauri-apps/api/app";
// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底
// 我们按需加载并在运行时捕获错误,避免构建期类型问题
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdateChannel = "stable" | "beta";
export type UpdaterPhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "restarting"
| "upToDate"
| "error";
export interface UpdateInfo {
currentVersion: string;
availableVersion: string;
notes?: string;
pubDate?: string;
}
export interface UpdateProgressEvent {
event: "Started" | "Progress" | "Finished";
total?: number;
downloaded?: number;
}
export interface UpdateHandle {
version: string;
notes?: string;
date?: string;
downloadAndInstall: (
onProgress?: (e: UpdateProgressEvent) => void,
) => Promise<void>;
download?: () => Promise<void>;
install?: () => Promise<void>;
}
export interface CheckOptions {
timeout?: number;
channel?: UpdateChannel;
}
function mapUpdateHandle(raw: Update): UpdateHandle {
return {
version: (raw as any).version ?? "",
notes: (raw as any).notes,
date: (raw as any).date,
async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {
await (raw as any).downloadAndInstall((evt: any) => {
if (!onProgress) return;
const mapped: UpdateProgressEvent = {
event: evt?.event,
};
if (evt?.event === "Started") {
mapped.total = evt?.data?.contentLength ?? 0;
mapped.downloaded = 0;
} else if (evt?.event === "Progress") {
mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成
}
onProgress(mapped);
});
},
// 透传可选 API若插件版本支持
download: (raw as any).download
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).download();
}
: undefined,
install: (raw as any).install
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).install();
}
: undefined,
};
}
export async function getCurrentVersion(): Promise<string> {
try {
return await getVersion();
} catch {
return "";
}
}
export async function checkForUpdate(
opts: CheckOptions = {},
): Promise<
| { status: "up-to-date" }
| { status: "available"; info: UpdateInfo; update: UpdateHandle }
> {
// 动态引入,避免在未安装插件时导致打包期问题
const { check } = await import("@tauri-apps/plugin-updater");
const currentVersion = await getCurrentVersion();
const update = await check({ timeout: opts.timeout ?? 30000 } as any);
if (!update) {
return { status: "up-to-date" };
}
const mapped = mapUpdateHandle(update);
const info: UpdateInfo = {
currentVersion,
availableVersion: mapped.version,
notes: mapped.notes,
pubDate: mapped.date,
};
return { status: "available", info, update: mapped };
}
export async function relaunchApp(): Promise<void> {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
export async function runUpdateFlow(
opts: CheckOptions = {},
): Promise<{ status: "up-to-date" | "done" }> {
const result = await checkForUpdate(opts);
if (result.status === "up-to-date") return result;
let downloaded = 0;
let total = 0;
await result.update.downloadAndInstall((e) => {
if (e.event === "Started") {
total = e.total ?? 0;
downloaded = 0;
} else if (e.event === "Progress") {
downloaded += e.downloaded ?? 0;
// 调用方可监听此处并更新 UI目前设置页仅显示加载态
console.debug("update progress", { downloaded, total });
}
});
await relaunchApp();
return { status: "done" };
}

View File

@@ -3,9 +3,15 @@ export interface Provider {
name: string;
settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config }
websiteUrl?: string;
createdAt?: number; // 添加时间戳(毫秒)
}
export interface AppConfig {
providers: Record<string, Provider>;
current: string;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
showInDock: boolean;
}

View File

@@ -33,22 +33,6 @@ export const checkCoAuthoredSetting = (jsonString: string): boolean => {
}
};
// 从JSON配置中提取并处理官网地址
export const extractWebsiteUrl = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
if (baseUrl && typeof baseUrl === "string") {
// 去掉 "api." 前缀
return baseUrl.replace(/^https?:\/\/api\./, "https://");
}
} catch (err) {
// 忽略JSON解析错误
}
return "";
};
// 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN
export const getApiKeyFromConfig = (jsonString: string): string => {
try {

12
src/vite-env.d.ts vendored
View File

@@ -1,7 +1,8 @@
/// <reference types="vite/client" />
import { Provider } from "./types";
import { Provider, Settings } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
interface ImportResult {
success: boolean;
@@ -30,6 +31,15 @@ declare global {
selectConfigFile: () => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>;
onProviderSwitched: (
callback: (data: { appType: string; providerId: string }) => void,
) => Promise<UnlistenFn>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
};
platform: {
isMac: boolean;

62
tailwind.config.js Normal file
View File

@@ -0,0 +1,62 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 扩展蓝色系列以匹配 Linear 风格
blue: {
500: '#3498db',
600: '#2980b9',
400: '#5dade2',
},
// 自定义灰色系列
gray: {
50: '#fafafa', // bg-primary
100: '#f4f4f5', // bg-tertiary
200: '#e4e4e7', // border
300: '#d4d4d8', // border-hover
400: '#a1a1aa', // text-tertiary
500: '#71717a', // text-secondary
600: '#52525b', // text-secondary-dark
700: '#3f3f46', // bg-tertiary-dark
800: '#27272a', // bg-secondary-dark
900: '#18181b', // text-primary
950: '#0a0a0b', // bg-primary-dark
},
// 状态颜色
green: {
500: '#10b981',
100: '#d1fae5',
},
red: {
500: '#ef4444',
100: '#fee2e2',
},
amber: {
500: '#f59e0b',
100: '#fef3c7',
},
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
borderRadius: {
'sm': '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '0.875rem',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
},
},
},
plugins: [],
}

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "CommonJS",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"esModuleInterop": true,
@@ -11,5 +11,5 @@
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
"include": ["vite.config.mts"]
}

19
vite.config.mts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",
emptyOutDir: true,
},
server: {
port: 3000,
strictPort: true,
},
clearScreen: false,
envPrefix: ["VITE_", "TAURI_"],
});

View File

@@ -1,18 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'src',
plugins: [react()],
base: './',
build: {
outDir: '../dist',
emptyOutDir: true
},
server: {
port: 3000,
strictPort: true
},
clearScreen: false,
envPrefix: ['VITE_', 'TAURI_']
})