Spaces:
Paused
Paused
icebear0828 commited on
Commit ·
178e38e
1
Parent(s): 59ed94c
refactor: migrate electron from branch to npm workspace
Browse filesElectron code moved from a separate branch into packages/electron/,
eliminating the fragile master→electron branch sync that caused
frequent merge conflicts (package.json, lockfile, release.yml).
- Add npm workspaces to root package.json
- Create packages/electron/ with independent deps (electron, electron-builder, esbuild)
- Desktop UI imports use @shared / Vite alias instead of deep relative paths
- Rewrite release.yml for workspace-aware build
- Delete sync-electron.yml (no longer needed)
- Cherry-pick setup-curl.ts fixes from electron branch (GITHUB_TOKEN, dll rename, tar fallback)
- .github/workflows/release.yml +24 -42
- .github/workflows/sync-electron.yml +0 -146
- CHANGELOG.md +5 -0
- package-lock.json +0 -0
- package.json +3 -0
- packages/electron/desktop/index.html +19 -0
- packages/electron/desktop/package-lock.json +0 -0
- packages/electron/desktop/package.json +20 -0
- packages/electron/desktop/postcss.config.cjs +6 -0
- packages/electron/desktop/src/App.tsx +136 -0
- packages/electron/desktop/src/components/AccountCard.tsx +254 -0
- packages/electron/desktop/src/components/AccountList.tsx +72 -0
- packages/electron/desktop/src/components/AccountTable.tsx +279 -0
- packages/electron/desktop/src/components/AddAccount.tsx +61 -0
- packages/electron/desktop/src/components/AnthropicSetup.tsx +78 -0
- packages/electron/desktop/src/components/ApiConfig.tsx +224 -0
- packages/electron/desktop/src/components/BulkActions.tsx +88 -0
- packages/electron/desktop/src/components/CodeExamples.tsx +232 -0
- packages/electron/desktop/src/components/CopyButton.tsx +81 -0
- packages/electron/desktop/src/components/Footer.tsx +31 -0
- packages/electron/desktop/src/components/Header.tsx +133 -0
- packages/electron/desktop/src/components/ImportExport.tsx +168 -0
- packages/electron/desktop/src/components/ProxyGroupList.tsx +96 -0
- packages/electron/desktop/src/components/ProxyPool.tsx +317 -0
- packages/electron/desktop/src/components/RuleAssign.tsx +113 -0
- packages/electron/desktop/src/components/TestConnection.tsx +145 -0
- packages/electron/desktop/src/index.css +53 -0
- packages/electron/desktop/src/main.tsx +8 -0
- packages/electron/desktop/src/pages/ProxySettings.tsx +154 -0
- packages/electron/desktop/src/platform.ts +13 -0
- packages/electron/desktop/tailwind.config.ts +50 -0
- packages/electron/desktop/vite.config.ts +29 -0
- packages/electron/electron-builder.yml +77 -0
- packages/electron/electron/assets/icon.png +0 -0
- packages/electron/electron/auto-updater.ts +181 -0
- packages/electron/electron/build.mjs +44 -0
- packages/electron/electron/main.ts +342 -0
- packages/electron/package.json +23 -0
- packages/electron/src/electron-entry.ts +8 -0
- scripts/setup-curl.ts +19 -8
.github/workflows/release.yml
CHANGED
|
@@ -7,7 +7,7 @@ on:
|
|
| 7 |
workflow_dispatch:
|
| 8 |
inputs:
|
| 9 |
tag:
|
| 10 |
-
description: "Tag to release (e.g. v1.0.
|
| 11 |
required: true
|
| 12 |
type: string
|
| 13 |
|
|
@@ -49,7 +49,7 @@ jobs:
|
|
| 49 |
node-version: 20
|
| 50 |
cache: npm
|
| 51 |
|
| 52 |
-
- name: Install
|
| 53 |
run: npm ci --ignore-scripts
|
| 54 |
|
| 55 |
- name: Setup curl-impersonate
|
|
@@ -57,29 +57,27 @@ jobs:
|
|
| 57 |
env:
|
| 58 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 59 |
|
| 60 |
-
- name: Install web dependencies
|
| 61 |
run: cd web && npm ci
|
| 62 |
|
| 63 |
-
- name: Install desktop dependencies
|
| 64 |
-
run: cd desktop && npm ci
|
| 65 |
|
| 66 |
-
- name: Build
|
| 67 |
-
run: npm run
|
| 68 |
|
| 69 |
-
- name:
|
| 70 |
-
run:
|
| 71 |
|
| 72 |
-
- name:
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
release/*.yaml
|
| 82 |
-
if-no-files-found: ignore
|
| 83 |
|
| 84 |
release:
|
| 85 |
needs: build
|
|
@@ -93,20 +91,13 @@ jobs:
|
|
| 93 |
ref: ${{ inputs.tag || github.ref }}
|
| 94 |
fetch-depth: 0
|
| 95 |
|
| 96 |
-
- name: Download all artifacts
|
| 97 |
-
uses: actions/download-artifact@v4
|
| 98 |
-
with:
|
| 99 |
-
path: artifacts
|
| 100 |
-
merge-multiple: true
|
| 101 |
-
|
| 102 |
- name: Resolve tag
|
| 103 |
id: tag
|
| 104 |
run: |
|
| 105 |
TAG="${{ inputs.tag || github.ref_name }}"
|
| 106 |
echo "name=$TAG" >> "$GITHUB_OUTPUT"
|
| 107 |
|
| 108 |
-
- name: Generate release notes
|
| 109 |
-
id: notes
|
| 110 |
run: |
|
| 111 |
TAG="${{ steps.tag.outputs.name }}"
|
| 112 |
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -v "^${TAG}$" | head -1)
|
|
@@ -120,17 +111,8 @@ jobs:
|
|
| 120 |
BODY="Bug fixes and improvements"
|
| 121 |
fi
|
| 122 |
fi
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
- name: Create GitHub Release
|
| 130 |
-
uses: softprops/action-gh-release@v2
|
| 131 |
-
with:
|
| 132 |
-
tag_name: ${{ steps.tag.outputs.name }}
|
| 133 |
-
body: ${{ steps.notes.outputs.NOTES }}
|
| 134 |
-
draft: false
|
| 135 |
-
prerelease: false
|
| 136 |
-
files: artifacts/*
|
|
|
|
| 7 |
workflow_dispatch:
|
| 8 |
inputs:
|
| 9 |
tag:
|
| 10 |
+
description: "Tag to release (e.g. v1.0.57)"
|
| 11 |
required: true
|
| 12 |
type: string
|
| 13 |
|
|
|
|
| 49 |
node-version: 20
|
| 50 |
cache: npm
|
| 51 |
|
| 52 |
+
- name: Install all workspace dependencies
|
| 53 |
run: npm ci --ignore-scripts
|
| 54 |
|
| 55 |
- name: Setup curl-impersonate
|
|
|
|
| 57 |
env:
|
| 58 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 59 |
|
| 60 |
+
- name: Install web frontend dependencies
|
| 61 |
run: cd web && npm ci
|
| 62 |
|
| 63 |
+
- name: Install desktop frontend dependencies
|
| 64 |
+
run: cd packages/electron/desktop && npm ci
|
| 65 |
|
| 66 |
+
- name: Build core (web + tsc)
|
| 67 |
+
run: npm run build
|
| 68 |
|
| 69 |
+
- name: Build desktop frontend
|
| 70 |
+
run: cd packages/electron/desktop && npx vite build
|
| 71 |
|
| 72 |
+
- name: Bundle Electron (esbuild)
|
| 73 |
+
working-directory: packages/electron
|
| 74 |
+
run: node electron/build.mjs
|
| 75 |
+
|
| 76 |
+
- name: Pack (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
|
| 77 |
+
working-directory: packages/electron
|
| 78 |
+
run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }} ${{ matrix.arch && format('--{0}', matrix.arch) || '' }} --publish always
|
| 79 |
+
env:
|
| 80 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
|
|
|
| 81 |
|
| 82 |
release:
|
| 83 |
needs: build
|
|
|
|
| 91 |
ref: ${{ inputs.tag || github.ref }}
|
| 92 |
fetch-depth: 0
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
- name: Resolve tag
|
| 95 |
id: tag
|
| 96 |
run: |
|
| 97 |
TAG="${{ inputs.tag || github.ref_name }}"
|
| 98 |
echo "name=$TAG" >> "$GITHUB_OUTPUT"
|
| 99 |
|
| 100 |
+
- name: Generate release notes and publish
|
|
|
|
| 101 |
run: |
|
| 102 |
TAG="${{ steps.tag.outputs.name }}"
|
| 103 |
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -v "^${TAG}$" | head -1)
|
|
|
|
| 111 |
BODY="Bug fixes and improvements"
|
| 112 |
fi
|
| 113 |
fi
|
| 114 |
+
echo "$BODY" > /tmp/release-notes.md
|
| 115 |
+
gh release edit "$TAG" --draft=false --notes-file /tmp/release-notes.md 2>/dev/null || \
|
| 116 |
+
gh release create "$TAG" --title "$TAG" --notes-file /tmp/release-notes.md
|
| 117 |
+
env:
|
| 118 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.github/workflows/sync-electron.yml
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
name: Sync master → electron
|
| 2 |
-
|
| 3 |
-
on:
|
| 4 |
-
push:
|
| 5 |
-
branches: [master]
|
| 6 |
-
workflow_dispatch:
|
| 7 |
-
|
| 8 |
-
concurrency:
|
| 9 |
-
group: sync-electron
|
| 10 |
-
cancel-in-progress: false
|
| 11 |
-
|
| 12 |
-
permissions:
|
| 13 |
-
actions: write
|
| 14 |
-
contents: write
|
| 15 |
-
issues: write
|
| 16 |
-
|
| 17 |
-
jobs:
|
| 18 |
-
sync:
|
| 19 |
-
runs-on: ubuntu-latest
|
| 20 |
-
steps:
|
| 21 |
-
- uses: actions/checkout@v4
|
| 22 |
-
with:
|
| 23 |
-
fetch-depth: 0
|
| 24 |
-
|
| 25 |
-
- name: Merge master into electron
|
| 26 |
-
run: |
|
| 27 |
-
git config user.name "github-actions[bot]"
|
| 28 |
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 29 |
-
|
| 30 |
-
git checkout electron
|
| 31 |
-
git merge origin/master --no-edit || {
|
| 32 |
-
# Conflict — abort and create issue
|
| 33 |
-
git merge --abort
|
| 34 |
-
echo "CONFLICT=true" >> "$GITHUB_ENV"
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
- name: Auto bump + tag if new commits
|
| 38 |
-
if: env.CONFLICT != 'true'
|
| 39 |
-
run: |
|
| 40 |
-
# Find last version tag
|
| 41 |
-
LAST_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "")
|
| 42 |
-
if [ -z "$LAST_TAG" ]; then
|
| 43 |
-
echo "No previous tag found, skipping auto-bump"
|
| 44 |
-
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 45 |
-
exit 0
|
| 46 |
-
fi
|
| 47 |
-
|
| 48 |
-
# Check for new non-merge commits since last tag
|
| 49 |
-
NEW_COMMITS=$(git log "${LAST_TAG}..HEAD" --no-merges --oneline | wc -l)
|
| 50 |
-
if [ "$NEW_COMMITS" -eq 0 ]; then
|
| 51 |
-
echo "No new non-merge commits since $LAST_TAG, skipping bump"
|
| 52 |
-
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 53 |
-
exit 0
|
| 54 |
-
fi
|
| 55 |
-
|
| 56 |
-
echo "Found $NEW_COMMITS new commit(s) since $LAST_TAG"
|
| 57 |
-
|
| 58 |
-
# Parse current version and bump patch
|
| 59 |
-
CURRENT="${LAST_TAG#v}"
|
| 60 |
-
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
| 61 |
-
NEW_PATCH=$((PATCH + 1))
|
| 62 |
-
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
| 63 |
-
NEW_TAG="v${NEW_VERSION}"
|
| 64 |
-
|
| 65 |
-
# Check tag doesn't already exist (concurrent protection)
|
| 66 |
-
if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
|
| 67 |
-
echo "Tag $NEW_TAG already exists, skipping"
|
| 68 |
-
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 69 |
-
exit 0
|
| 70 |
-
fi
|
| 71 |
-
|
| 72 |
-
# Bump version in package.json
|
| 73 |
-
node -e "
|
| 74 |
-
const fs = require('fs');
|
| 75 |
-
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
| 76 |
-
pkg.version = '${NEW_VERSION}';
|
| 77 |
-
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
| 78 |
-
"
|
| 79 |
-
|
| 80 |
-
git add package.json
|
| 81 |
-
git commit -m "chore: bump version to ${NEW_VERSION}"
|
| 82 |
-
git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}"
|
| 83 |
-
|
| 84 |
-
echo "BUMP=true" >> "$GITHUB_ENV"
|
| 85 |
-
echo "NEW_TAG=$NEW_TAG" >> "$GITHUB_ENV"
|
| 86 |
-
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
|
| 87 |
-
echo "Bumped to $NEW_VERSION, tagged $NEW_TAG"
|
| 88 |
-
|
| 89 |
-
- name: Sync version to master
|
| 90 |
-
if: env.BUMP == 'true'
|
| 91 |
-
run: |
|
| 92 |
-
git checkout master
|
| 93 |
-
node -e "
|
| 94 |
-
const fs = require('fs');
|
| 95 |
-
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
| 96 |
-
pkg.version = process.env.NEW_VERSION;
|
| 97 |
-
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
| 98 |
-
"
|
| 99 |
-
git add package.json
|
| 100 |
-
git commit -m "chore: sync version to ${NEW_VERSION} [skip ci]"
|
| 101 |
-
git checkout electron
|
| 102 |
-
|
| 103 |
-
- name: Push electron + master + tags
|
| 104 |
-
if: env.CONFLICT != 'true'
|
| 105 |
-
run: |
|
| 106 |
-
if [ "$BUMP" = "true" ]; then
|
| 107 |
-
git push origin electron master --follow-tags
|
| 108 |
-
else
|
| 109 |
-
git push origin electron
|
| 110 |
-
fi
|
| 111 |
-
|
| 112 |
-
- name: Trigger release workflow
|
| 113 |
-
if: env.BUMP == 'true'
|
| 114 |
-
run: gh workflow run release.yml -f tag="$NEW_TAG" --ref electron
|
| 115 |
-
env:
|
| 116 |
-
GH_TOKEN: ${{ github.token }}
|
| 117 |
-
|
| 118 |
-
- name: Create issue on conflict
|
| 119 |
-
if: env.CONFLICT == 'true'
|
| 120 |
-
run: |
|
| 121 |
-
# Check for existing open issue to avoid duplicates
|
| 122 |
-
EXISTING=$(gh issue list --label "sync-conflict" --state open --limit 1 --json number -q '.[0].number')
|
| 123 |
-
if [ -n "$EXISTING" ]; then
|
| 124 |
-
echo "Issue #$EXISTING already open, skipping"
|
| 125 |
-
exit 0
|
| 126 |
-
fi
|
| 127 |
-
gh issue create \
|
| 128 |
-
--title "electron 分支合并冲突需手动解决" \
|
| 129 |
-
--label "sync-conflict" \
|
| 130 |
-
--body "$(cat <<'EOF'
|
| 131 |
-
\`master\` → \`electron\` 自动合并失败,需要手动解决冲突。
|
| 132 |
-
|
| 133 |
-
```bash
|
| 134 |
-
git checkout electron
|
| 135 |
-
git merge master
|
| 136 |
-
# 解决冲突(通常是 CHANGELOG.md)
|
| 137 |
-
git add .
|
| 138 |
-
git commit
|
| 139 |
-
git push origin electron
|
| 140 |
-
```
|
| 141 |
-
|
| 142 |
-
触发 commit: ${{ github.sha }}
|
| 143 |
-
EOF
|
| 144 |
-
)"
|
| 145 |
-
env:
|
| 146 |
-
GH_TOKEN: ${{ github.token }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
### Added
|
| 10 |
|
| 11 |
- Dashboard 额度设置面板:可在 Web UI 直接调整额度刷新间隔、主/次预警阈值、自动跳过耗尽账号开关,无需手动编辑 YAML;API `GET/POST /admin/quota-settings` 支持鉴权 (#92)
|
|
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
| 9 |
+
### Changed
|
| 10 |
+
|
| 11 |
+
- Electron 桌面端从独立分支迁移为 npm workspace(`packages/electron/`),消除 master→electron 分支同步冲突;删除 `sync-electron.yml`,release.yml 改为 workspace 感知构建
|
| 12 |
+
- `scripts/setup-curl.ts`:加入 GITHUB_TOKEN 认证避免 CI rate limit;Windows DLL 名适配 v1.5+(`libcurl-impersonate.dll`);tar 解压 bsdtar/GNU tar 自动 fallback
|
| 13 |
+
|
| 14 |
### Added
|
| 15 |
|
| 16 |
- Dashboard 额度设置面板:可在 Web UI 直接调整额度刷新间隔、主/次预警阈值、自动跳过耗尽账号开关,无需手动编辑 YAML;API `GET/POST /admin/quota-settings` 支持鉴权 (#92)
|
package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -9,6 +9,9 @@
|
|
| 9 |
}
|
| 10 |
],
|
| 11 |
"type": "module",
|
|
|
|
|
|
|
|
|
|
| 12 |
"scripts": {
|
| 13 |
"test": "vitest run",
|
| 14 |
"test:watch": "vitest",
|
|
|
|
| 9 |
}
|
| 10 |
],
|
| 11 |
"type": "module",
|
| 12 |
+
"workspaces": [
|
| 13 |
+
"packages/*"
|
| 14 |
+
],
|
| 15 |
"scripts": {
|
| 16 |
"test": "vitest run",
|
| 17 |
"test:watch": "vitest",
|
packages/electron/desktop/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Codex Proxy</title>
|
| 7 |
+
<script>
|
| 8 |
+
try {
|
| 9 |
+
const t = localStorage.getItem('codex-proxy-theme');
|
| 10 |
+
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
| 11 |
+
document.documentElement.classList.add('dark');
|
| 12 |
+
} catch {}
|
| 13 |
+
</script>
|
| 14 |
+
</head>
|
| 15 |
+
<body class="bg-bg-light dark:bg-bg-dark font-display text-slate-900 dark:text-text-main antialiased min-h-screen flex flex-col transition-colors">
|
| 16 |
+
<div id="app"></div>
|
| 17 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
packages/electron/desktop/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
packages/electron/desktop/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@codex-proxy/desktop",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
| 7 |
+
},
|
| 8 |
+
"keywords": [],
|
| 9 |
+
"author": "",
|
| 10 |
+
"license": "ISC",
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@preact/preset-vite": "^2.10.3",
|
| 13 |
+
"@tailwindcss/vite": "^4.2.1",
|
| 14 |
+
"autoprefixer": "^10.4.27",
|
| 15 |
+
"postcss": "^8.5.8",
|
| 16 |
+
"preact": "^10.28.4",
|
| 17 |
+
"tailwindcss": "^3.4.19",
|
| 18 |
+
"vite": "^7.3.1"
|
| 19 |
+
}
|
| 20 |
+
}
|
packages/electron/desktop/postcss.config.cjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
packages/electron/desktop/src/App.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "preact/hooks";
|
| 2 |
+
import { I18nProvider } from "@shared/i18n/context";
|
| 3 |
+
import { ThemeProvider } from "@shared/theme/context";
|
| 4 |
+
import { Header } from "./components/Header";
|
| 5 |
+
import { AccountList } from "./components/AccountList";
|
| 6 |
+
import { AddAccount } from "./components/AddAccount";
|
| 7 |
+
import { ProxyPool } from "./components/ProxyPool";
|
| 8 |
+
import { ApiConfig } from "./components/ApiConfig";
|
| 9 |
+
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 10 |
+
import { CodeExamples } from "./components/CodeExamples";
|
| 11 |
+
import { TestConnection } from "./components/TestConnection";
|
| 12 |
+
import { Footer } from "./components/Footer";
|
| 13 |
+
import { ProxySettings } from "./pages/ProxySettings";
|
| 14 |
+
import { useAccounts } from "@shared/hooks/use-accounts";
|
| 15 |
+
import { useProxies } from "@shared/hooks/use-proxies";
|
| 16 |
+
import { useStatus } from "@shared/hooks/use-status";
|
| 17 |
+
import { useUpdateStatus } from "@shared/hooks/use-update-status";
|
| 18 |
+
|
| 19 |
+
/** Minimal status hook — only used for version/commit display.
|
| 20 |
+
* Updates are handled by electron-updater via system tray. */
|
| 21 |
+
function useVersionInfo() {
|
| 22 |
+
const update = useUpdateStatus();
|
| 23 |
+
return {
|
| 24 |
+
version: update.status?.proxy.version ?? null,
|
| 25 |
+
commit: update.status?.proxy.commit ?? null,
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function Dashboard() {
|
| 30 |
+
const accounts = useAccounts();
|
| 31 |
+
const proxies = useProxies();
|
| 32 |
+
const status = useStatus(accounts.list.length);
|
| 33 |
+
const versionInfo = useVersionInfo();
|
| 34 |
+
|
| 35 |
+
const handleProxyChange = async (accountId: string, proxyId: string) => {
|
| 36 |
+
accounts.patchLocal(accountId, { proxyId });
|
| 37 |
+
await proxies.assignProxy(accountId, proxyId);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<>
|
| 42 |
+
<Header
|
| 43 |
+
onAddAccount={accounts.startAdd}
|
| 44 |
+
version={versionInfo.version}
|
| 45 |
+
commit={versionInfo.commit}
|
| 46 |
+
/>
|
| 47 |
+
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 48 |
+
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
| 49 |
+
<AddAccount
|
| 50 |
+
visible={accounts.addVisible}
|
| 51 |
+
onSubmitRelay={accounts.submitRelay}
|
| 52 |
+
addInfo={accounts.addInfo}
|
| 53 |
+
addError={accounts.addError}
|
| 54 |
+
/>
|
| 55 |
+
<AccountList
|
| 56 |
+
accounts={accounts.list}
|
| 57 |
+
loading={accounts.loading}
|
| 58 |
+
onDelete={accounts.deleteAccount}
|
| 59 |
+
onRefresh={accounts.refresh}
|
| 60 |
+
refreshing={accounts.refreshing}
|
| 61 |
+
lastUpdated={accounts.lastUpdated}
|
| 62 |
+
proxies={proxies.proxies}
|
| 63 |
+
onProxyChange={handleProxyChange}
|
| 64 |
+
/>
|
| 65 |
+
<ProxyPool proxies={proxies} />
|
| 66 |
+
<ApiConfig
|
| 67 |
+
baseUrl={status.baseUrl}
|
| 68 |
+
apiKey={status.apiKey}
|
| 69 |
+
models={status.models}
|
| 70 |
+
selectedModel={status.selectedModel}
|
| 71 |
+
onModelChange={status.setSelectedModel}
|
| 72 |
+
modelFamilies={status.modelFamilies}
|
| 73 |
+
selectedEffort={status.selectedEffort}
|
| 74 |
+
onEffortChange={status.setSelectedEffort}
|
| 75 |
+
selectedSpeed={status.selectedSpeed}
|
| 76 |
+
onSpeedChange={status.setSelectedSpeed}
|
| 77 |
+
/>
|
| 78 |
+
<AnthropicSetup
|
| 79 |
+
apiKey={status.apiKey}
|
| 80 |
+
selectedModel={status.selectedModel}
|
| 81 |
+
reasoningEffort={status.selectedEffort}
|
| 82 |
+
serviceTier={status.selectedSpeed}
|
| 83 |
+
/>
|
| 84 |
+
<CodeExamples
|
| 85 |
+
baseUrl={status.baseUrl}
|
| 86 |
+
apiKey={status.apiKey}
|
| 87 |
+
model={status.selectedModel}
|
| 88 |
+
reasoningEffort={status.selectedEffort}
|
| 89 |
+
serviceTier={status.selectedSpeed}
|
| 90 |
+
/>
|
| 91 |
+
<TestConnection />
|
| 92 |
+
</div>
|
| 93 |
+
</main>
|
| 94 |
+
<Footer updateStatus={null} />
|
| 95 |
+
</>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function useHash(): string {
|
| 100 |
+
const [hash, setHash] = useState(location.hash);
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
const handler = () => setHash(location.hash);
|
| 103 |
+
window.addEventListener("hashchange", handler);
|
| 104 |
+
return () => window.removeEventListener("hashchange", handler);
|
| 105 |
+
}, []);
|
| 106 |
+
return hash;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function App() {
|
| 110 |
+
const hash = useHash();
|
| 111 |
+
const isProxySettings = hash === "#/proxy-settings";
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<I18nProvider>
|
| 115 |
+
<ThemeProvider>
|
| 116 |
+
{isProxySettings ? <ProxySettingsPage /> : <Dashboard />}
|
| 117 |
+
</ThemeProvider>
|
| 118 |
+
</I18nProvider>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function ProxySettingsPage() {
|
| 123 |
+
const versionInfo = useVersionInfo();
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<>
|
| 127 |
+
<Header
|
| 128 |
+
onAddAccount={() => { location.hash = ""; }}
|
| 129 |
+
version={versionInfo.version}
|
| 130 |
+
commit={versionInfo.commit}
|
| 131 |
+
isProxySettings
|
| 132 |
+
/>
|
| 133 |
+
<ProxySettings />
|
| 134 |
+
</>
|
| 135 |
+
);
|
| 136 |
+
}
|
packages/electron/desktop/src/components/AccountCard.tsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback } from "preact/hooks";
|
| 2 |
+
import { useT, useI18n } from "@shared/i18n/context";
|
| 3 |
+
import type { TranslationKey } from "@shared/i18n/translations";
|
| 4 |
+
import { formatNumber, formatResetTime, formatWindowDuration } from "@shared/utils/format";
|
| 5 |
+
import type { Account, ProxyEntry } from "@shared/types";
|
| 6 |
+
|
| 7 |
+
const avatarColors = [
|
| 8 |
+
["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
|
| 9 |
+
["bg-amber-100 dark:bg-[#3d2c16]", "text-amber-600 dark:text-amber-500"],
|
| 10 |
+
["bg-blue-100 dark:bg-[#1a2a3f]", "text-blue-600 dark:text-blue-400"],
|
| 11 |
+
["bg-emerald-100 dark:bg-[#112a1f]", "text-emerald-600 dark:text-emerald-400"],
|
| 12 |
+
["bg-red-100 dark:bg-[#3f1a1a]", "text-red-600 dark:text-red-400"],
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
const statusStyles: Record<string, [string, string]> = {
|
| 16 |
+
active: [
|
| 17 |
+
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
| 18 |
+
"active",
|
| 19 |
+
],
|
| 20 |
+
expired: [
|
| 21 |
+
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
|
| 22 |
+
"expired",
|
| 23 |
+
],
|
| 24 |
+
rate_limited: [
|
| 25 |
+
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
|
| 26 |
+
"rateLimited",
|
| 27 |
+
],
|
| 28 |
+
refreshing: [
|
| 29 |
+
"bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
|
| 30 |
+
"refreshing",
|
| 31 |
+
],
|
| 32 |
+
disabled: [
|
| 33 |
+
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 34 |
+
"disabled",
|
| 35 |
+
],
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
interface AccountCardProps {
|
| 39 |
+
account: Account;
|
| 40 |
+
index: number;
|
| 41 |
+
onDelete: (id: string) => Promise<string | null>;
|
| 42 |
+
proxies?: ProxyEntry[];
|
| 43 |
+
onProxyChange?: (accountId: string, proxyId: string) => void;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function AccountCard({ account, index, onDelete, proxies, onProxyChange }: AccountCardProps) {
|
| 47 |
+
const t = useT();
|
| 48 |
+
const { lang } = useI18n();
|
| 49 |
+
const email = account.email || "Unknown";
|
| 50 |
+
const initial = email.charAt(0).toUpperCase();
|
| 51 |
+
const [bgColor, textColor] = avatarColors[index % avatarColors.length];
|
| 52 |
+
const usage = account.usage || {};
|
| 53 |
+
const requests = usage.request_count ?? 0;
|
| 54 |
+
const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
|
| 55 |
+
const winRequests = usage.window_request_count ?? 0;
|
| 56 |
+
const winTokens = (usage.window_input_tokens ?? 0) + (usage.window_output_tokens ?? 0);
|
| 57 |
+
const plan = account.planType || t("freeTier");
|
| 58 |
+
const windowSec = account.quota?.rate_limit?.limit_window_seconds;
|
| 59 |
+
const windowDur = windowSec ? formatWindowDuration(windowSec, lang === "zh") : null;
|
| 60 |
+
|
| 61 |
+
const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
|
| 62 |
+
|
| 63 |
+
const handleDelete = useCallback(async () => {
|
| 64 |
+
if (!confirm(t("removeConfirm"))) return;
|
| 65 |
+
const err = await onDelete(account.id);
|
| 66 |
+
if (err) alert(err);
|
| 67 |
+
}, [account.id, onDelete, t]);
|
| 68 |
+
|
| 69 |
+
// Quota — primary window
|
| 70 |
+
const q = account.quota;
|
| 71 |
+
const rl = q?.rate_limit;
|
| 72 |
+
const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
|
| 73 |
+
const barColor =
|
| 74 |
+
pct == null ? "bg-primary" : pct >= 90 ? "bg-red-500" : pct >= 60 ? "bg-amber-500" : "bg-primary";
|
| 75 |
+
const pctColor =
|
| 76 |
+
pct == null
|
| 77 |
+
? "text-primary"
|
| 78 |
+
: pct >= 90
|
| 79 |
+
? "text-red-500"
|
| 80 |
+
: pct >= 60
|
| 81 |
+
? "text-amber-600 dark:text-amber-500"
|
| 82 |
+
: "text-primary";
|
| 83 |
+
const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
|
| 84 |
+
|
| 85 |
+
// Quota — secondary window (e.g. weekly)
|
| 86 |
+
const srl = q?.secondary_rate_limit;
|
| 87 |
+
const sPct = srl?.used_percent != null ? Math.round(srl.used_percent) : null;
|
| 88 |
+
const sBarColor =
|
| 89 |
+
sPct == null ? "bg-indigo-500" : sPct >= 90 ? "bg-red-500" : sPct >= 60 ? "bg-amber-500" : "bg-indigo-500";
|
| 90 |
+
const sPctColor =
|
| 91 |
+
sPct == null
|
| 92 |
+
? "text-indigo-500"
|
| 93 |
+
: sPct >= 90
|
| 94 |
+
? "text-red-500"
|
| 95 |
+
: sPct >= 60
|
| 96 |
+
? "text-amber-600 dark:text-amber-500"
|
| 97 |
+
: "text-indigo-500";
|
| 98 |
+
const sResetAt = srl?.reset_at ? formatResetTime(srl.reset_at, lang === "zh") : null;
|
| 99 |
+
const sWindowSec = srl?.limit_window_seconds;
|
| 100 |
+
const sWindowDur = sWindowSec ? formatWindowDuration(sWindowSec, lang === "zh") : null;
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50">
|
| 104 |
+
{/* Header */}
|
| 105 |
+
<div class="flex justify-between items-start mb-4">
|
| 106 |
+
<div class="flex items-center gap-3">
|
| 107 |
+
<div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
|
| 108 |
+
{initial}
|
| 109 |
+
</div>
|
| 110 |
+
<div>
|
| 111 |
+
<h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
|
| 112 |
+
<p class="text-xs text-slate-500 dark:text-text-dim">
|
| 113 |
+
{plan}
|
| 114 |
+
{windowDur && (
|
| 115 |
+
<span class="ml-1.5 px-1.5 py-0.5 rounded bg-slate-100 dark:bg-border-dark text-slate-500 dark:text-text-dim text-[0.65rem] font-medium">
|
| 116 |
+
{windowDur}
|
| 117 |
+
</span>
|
| 118 |
+
)}
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="flex items-center gap-2">
|
| 123 |
+
<span class={`px-2.5 py-1 rounded-full ${statusCls} text-xs font-medium border`}>
|
| 124 |
+
{t(statusKey as TranslationKey)}
|
| 125 |
+
</span>
|
| 126 |
+
<button
|
| 127 |
+
onClick={handleDelete}
|
| 128 |
+
class="p-1.5 text-slate-400 dark:text-text-dim hover:text-red-500 transition-colors rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
| 129 |
+
title={t("deleteAccount")}
|
| 130 |
+
>
|
| 131 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 132 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
| 133 |
+
</svg>
|
| 134 |
+
</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* Stats */}
|
| 139 |
+
<div class="space-y-2">
|
| 140 |
+
<div class="flex justify-between text-[0.78rem]">
|
| 141 |
+
<span class="text-slate-500 dark:text-text-dim">{t("windowRequests")}</span>
|
| 142 |
+
<span class="font-medium">{formatNumber(winRequests)}</span>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="flex justify-between text-[0.78rem]">
|
| 145 |
+
<span class="text-slate-500 dark:text-text-dim">{t("windowTokens")}</span>
|
| 146 |
+
<span class="font-medium">{formatNumber(winTokens)}</span>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="flex justify-between text-[0.68rem]">
|
| 149 |
+
<span class="text-slate-400 dark:text-text-dim/70">{t("totalAll")}</span>
|
| 150 |
+
<span class="text-slate-400 dark:text-text-dim/70">{formatNumber(requests)} req · {formatNumber(tokens)} tok</span>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* Proxy selector */}
|
| 155 |
+
{proxies && onProxyChange && (
|
| 156 |
+
<div class="flex items-center justify-between text-[0.78rem] mt-2 pt-2 border-t border-slate-100 dark:border-border-dark">
|
| 157 |
+
<span class="text-slate-500 dark:text-text-dim">{t("proxyAssignment")}</span>
|
| 158 |
+
<select
|
| 159 |
+
value={account.proxyId || "global"}
|
| 160 |
+
onChange={(e) =>
|
| 161 |
+
onProxyChange(account.id, (e.target as HTMLSelectElement).value)
|
| 162 |
+
}
|
| 163 |
+
class="text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 164 |
+
>
|
| 165 |
+
<option value="global">{t("globalDefault")}</option>
|
| 166 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 167 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 168 |
+
{proxies.map((p) => (
|
| 169 |
+
<option key={p.id} value={p.id}>
|
| 170 |
+
{p.name}
|
| 171 |
+
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
|
| 172 |
+
</option>
|
| 173 |
+
))}
|
| 174 |
+
</select>
|
| 175 |
+
</div>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
{/* Quota bars */}
|
| 179 |
+
{(rl || srl) && (
|
| 180 |
+
<div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark space-y-3">
|
| 181 |
+
{/* Primary window */}
|
| 182 |
+
{rl && (
|
| 183 |
+
<div>
|
| 184 |
+
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
| 185 |
+
<span class="text-slate-500 dark:text-text-dim">
|
| 186 |
+
{t("rateLimit")}
|
| 187 |
+
{windowDur && (
|
| 188 |
+
<span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({windowDur})</span>
|
| 189 |
+
)}
|
| 190 |
+
</span>
|
| 191 |
+
{rl.limit_reached ? (
|
| 192 |
+
<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
|
| 193 |
+
{t("limitReached")}
|
| 194 |
+
</span>
|
| 195 |
+
) : pct != null ? (
|
| 196 |
+
<span class={`font-medium ${pctColor}`}>
|
| 197 |
+
{pct}% {t("used")}
|
| 198 |
+
</span>
|
| 199 |
+
) : (
|
| 200 |
+
<span class="font-medium text-primary">{t("ok")}</span>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
{pct != null && (
|
| 204 |
+
<div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
|
| 205 |
+
<div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
{resetAt && (
|
| 209 |
+
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">
|
| 210 |
+
{t("resetsAt")} {resetAt}
|
| 211 |
+
</p>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
{/* Secondary window (e.g. weekly) */}
|
| 217 |
+
{srl && (
|
| 218 |
+
<div>
|
| 219 |
+
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
| 220 |
+
<span class="text-slate-500 dark:text-text-dim">
|
| 221 |
+
{t("secondaryRateLimit")}
|
| 222 |
+
{sWindowDur && (
|
| 223 |
+
<span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({sWindowDur})</span>
|
| 224 |
+
)}
|
| 225 |
+
</span>
|
| 226 |
+
{srl.limit_reached ? (
|
| 227 |
+
<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
|
| 228 |
+
{t("limitReached")}
|
| 229 |
+
</span>
|
| 230 |
+
) : sPct != null ? (
|
| 231 |
+
<span class={`font-medium ${sPctColor}`}>
|
| 232 |
+
{sPct}% {t("used")}
|
| 233 |
+
</span>
|
| 234 |
+
) : (
|
| 235 |
+
<span class="font-medium text-indigo-500">{t("ok")}</span>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
{sPct != null && (
|
| 239 |
+
<div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
|
| 240 |
+
<div class={`${sBarColor} h-2 rounded-full transition-all`} style={{ width: `${sPct}%` }} />
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
+
{sResetAt && (
|
| 244 |
+
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">
|
| 245 |
+
{t("resetsAt")} {sResetAt}
|
| 246 |
+
</p>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
)}
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
packages/electron/desktop/src/components/AccountList.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n, useT } from "@shared/i18n/context";
|
| 2 |
+
import { AccountCard } from "./AccountCard";
|
| 3 |
+
import type { Account, ProxyEntry } from "@shared/types";
|
| 4 |
+
|
| 5 |
+
interface AccountListProps {
|
| 6 |
+
accounts: Account[];
|
| 7 |
+
loading: boolean;
|
| 8 |
+
onDelete: (id: string) => Promise<string | null>;
|
| 9 |
+
onRefresh: () => void;
|
| 10 |
+
refreshing: boolean;
|
| 11 |
+
lastUpdated: Date | null;
|
| 12 |
+
proxies?: ProxyEntry[];
|
| 13 |
+
onProxyChange?: (accountId: string, proxyId: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated, proxies, onProxyChange }: AccountListProps) {
|
| 17 |
+
const t = useT();
|
| 18 |
+
const { lang } = useI18n();
|
| 19 |
+
|
| 20 |
+
const updatedAtText = lastUpdated
|
| 21 |
+
? lastUpdated.toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
| 22 |
+
: null;
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<section class="flex flex-col gap-4">
|
| 26 |
+
<div class="flex items-center justify-between">
|
| 27 |
+
<div class="flex flex-col gap-1">
|
| 28 |
+
<h2 class="text-[0.95rem] font-bold tracking-tight">{t("connectedAccounts")}</h2>
|
| 29 |
+
<p class="text-slate-500 dark:text-text-dim text-[0.8rem]">{t("connectedAccountsDesc")}</p>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="flex items-center gap-2 shrink-0">
|
| 32 |
+
{updatedAtText && (
|
| 33 |
+
<span class="text-[0.75rem] text-slate-400 dark:text-text-dim hidden sm:inline">
|
| 34 |
+
{t("updatedAt")} {updatedAtText}
|
| 35 |
+
</span>
|
| 36 |
+
)}
|
| 37 |
+
<button
|
| 38 |
+
onClick={onRefresh}
|
| 39 |
+
disabled={refreshing}
|
| 40 |
+
title={t("refresh")}
|
| 41 |
+
class="p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-primary/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 42 |
+
>
|
| 43 |
+
<svg
|
| 44 |
+
class={`size-[18px] ${refreshing ? "animate-spin" : ""}`}
|
| 45 |
+
viewBox="0 0 24 24"
|
| 46 |
+
fill="none"
|
| 47 |
+
stroke="currentColor"
|
| 48 |
+
stroke-width="1.5"
|
| 49 |
+
>
|
| 50 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
| 51 |
+
</svg>
|
| 52 |
+
</button>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 56 |
+
{loading ? (
|
| 57 |
+
<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
|
| 58 |
+
{t("loadingAccounts")}
|
| 59 |
+
</div>
|
| 60 |
+
) : accounts.length === 0 ? (
|
| 61 |
+
<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
|
| 62 |
+
{t("noAccounts")}
|
| 63 |
+
</div>
|
| 64 |
+
) : (
|
| 65 |
+
accounts.map((acct, i) => (
|
| 66 |
+
<AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} proxies={proxies} onProxyChange={onProxyChange} />
|
| 67 |
+
))
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
</section>
|
| 71 |
+
);
|
| 72 |
+
}
|
packages/electron/desktop/src/components/AccountTable.tsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { TranslationKey } from "@shared/i18n/translations";
|
| 4 |
+
import type { AssignmentAccount } from "@shared/hooks/use-proxy-assignments";
|
| 5 |
+
import type { ProxyEntry } from "@shared/types";
|
| 6 |
+
|
| 7 |
+
const PAGE_SIZE = 50;
|
| 8 |
+
|
| 9 |
+
const statusStyles: Record<string, [string, TranslationKey]> = {
|
| 10 |
+
active: [
|
| 11 |
+
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
| 12 |
+
"active",
|
| 13 |
+
],
|
| 14 |
+
expired: [
|
| 15 |
+
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
|
| 16 |
+
"expired",
|
| 17 |
+
],
|
| 18 |
+
rate_limited: [
|
| 19 |
+
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
|
| 20 |
+
"rateLimited",
|
| 21 |
+
],
|
| 22 |
+
refreshing: [
|
| 23 |
+
"bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
|
| 24 |
+
"refreshing",
|
| 25 |
+
],
|
| 26 |
+
disabled: [
|
| 27 |
+
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 28 |
+
"disabled",
|
| 29 |
+
],
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
interface AccountTableProps {
|
| 33 |
+
accounts: AssignmentAccount[];
|
| 34 |
+
proxies: ProxyEntry[];
|
| 35 |
+
selectedIds: Set<string>;
|
| 36 |
+
onSelectionChange: (ids: Set<string>) => void;
|
| 37 |
+
onSingleProxyChange: (accountId: string, proxyId: string) => void;
|
| 38 |
+
filterGroup: string | null;
|
| 39 |
+
statusFilter: string;
|
| 40 |
+
onStatusFilterChange: (status: string) => void;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function AccountTable({
|
| 44 |
+
accounts,
|
| 45 |
+
proxies,
|
| 46 |
+
selectedIds,
|
| 47 |
+
onSelectionChange,
|
| 48 |
+
onSingleProxyChange,
|
| 49 |
+
filterGroup,
|
| 50 |
+
statusFilter,
|
| 51 |
+
onStatusFilterChange,
|
| 52 |
+
}: AccountTableProps) {
|
| 53 |
+
const t = useT();
|
| 54 |
+
const [search, setSearch] = useState("");
|
| 55 |
+
const [page, setPage] = useState(0);
|
| 56 |
+
const lastClickedIndex = useRef<number | null>(null);
|
| 57 |
+
|
| 58 |
+
// Filter accounts
|
| 59 |
+
let filtered = accounts;
|
| 60 |
+
|
| 61 |
+
// By group
|
| 62 |
+
if (filterGroup) {
|
| 63 |
+
filtered = filtered.filter((a) => a.proxyId === filterGroup);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// By status
|
| 67 |
+
if (statusFilter && statusFilter !== "all") {
|
| 68 |
+
filtered = filtered.filter((a) => a.status === statusFilter);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// By search
|
| 72 |
+
if (search) {
|
| 73 |
+
const lower = search.toLowerCase();
|
| 74 |
+
filtered = filtered.filter((a) => a.email.toLowerCase().includes(lower));
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const totalCount = filtered.length;
|
| 78 |
+
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
| 79 |
+
const safePage = Math.min(page, totalPages - 1);
|
| 80 |
+
const pageAccounts = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
| 81 |
+
const pageAccountsRef = useRef(pageAccounts);
|
| 82 |
+
pageAccountsRef.current = pageAccounts;
|
| 83 |
+
|
| 84 |
+
// Reset page when filters change
|
| 85 |
+
const handleSearch = useCallback((value: string) => {
|
| 86 |
+
setSearch(value);
|
| 87 |
+
setPage(0);
|
| 88 |
+
}, []);
|
| 89 |
+
|
| 90 |
+
const handleStatusFilter = useCallback(
|
| 91 |
+
(value: string) => {
|
| 92 |
+
onStatusFilterChange(value);
|
| 93 |
+
setPage(0);
|
| 94 |
+
},
|
| 95 |
+
[onStatusFilterChange],
|
| 96 |
+
);
|
| 97 |
+
|
| 98 |
+
// Select/deselect
|
| 99 |
+
const toggleSelect = useCallback(
|
| 100 |
+
(id: string, index: number, shiftKey: boolean) => {
|
| 101 |
+
const newSet = new Set(selectedIds);
|
| 102 |
+
const currentPage = pageAccountsRef.current;
|
| 103 |
+
|
| 104 |
+
if (shiftKey && lastClickedIndex.current !== null) {
|
| 105 |
+
// Range select
|
| 106 |
+
const start = Math.min(lastClickedIndex.current, index);
|
| 107 |
+
const end = Math.max(lastClickedIndex.current, index);
|
| 108 |
+
for (let i = start; i <= end; i++) {
|
| 109 |
+
if (currentPage[i]) {
|
| 110 |
+
newSet.add(currentPage[i].id);
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
} else {
|
| 114 |
+
if (newSet.has(id)) {
|
| 115 |
+
newSet.delete(id);
|
| 116 |
+
} else {
|
| 117 |
+
newSet.add(id);
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
lastClickedIndex.current = index;
|
| 122 |
+
onSelectionChange(newSet);
|
| 123 |
+
},
|
| 124 |
+
[selectedIds, onSelectionChange],
|
| 125 |
+
);
|
| 126 |
+
|
| 127 |
+
const toggleSelectAll = useCallback(() => {
|
| 128 |
+
const currentPage = pageAccountsRef.current;
|
| 129 |
+
const pageIds = currentPage.map((a) => a.id);
|
| 130 |
+
const allSelected = pageIds.every((id) => selectedIds.has(id));
|
| 131 |
+
const newSet = new Set(selectedIds);
|
| 132 |
+
if (allSelected) {
|
| 133 |
+
for (const id of pageIds) newSet.delete(id);
|
| 134 |
+
} else {
|
| 135 |
+
for (const id of pageIds) newSet.add(id);
|
| 136 |
+
}
|
| 137 |
+
onSelectionChange(newSet);
|
| 138 |
+
}, [selectedIds, onSelectionChange]);
|
| 139 |
+
|
| 140 |
+
const allPageSelected = pageAccounts.length > 0 && pageAccounts.every((a) => selectedIds.has(a.id));
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<div class="flex flex-col gap-3">
|
| 144 |
+
{/* Filters */}
|
| 145 |
+
<div class="flex items-center gap-2">
|
| 146 |
+
<input
|
| 147 |
+
type="text"
|
| 148 |
+
placeholder={t("searchAccount")}
|
| 149 |
+
value={search}
|
| 150 |
+
onInput={(e) => handleSearch((e.target as HTMLInputElement).value)}
|
| 151 |
+
class="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
| 152 |
+
/>
|
| 153 |
+
<select
|
| 154 |
+
value={statusFilter}
|
| 155 |
+
onChange={(e) => handleStatusFilter((e.target as HTMLSelectElement).value)}
|
| 156 |
+
class="px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 157 |
+
>
|
| 158 |
+
<option value="all">{t("allStatuses")}</option>
|
| 159 |
+
<option value="active">{t("active")}</option>
|
| 160 |
+
<option value="expired">{t("expired")}</option>
|
| 161 |
+
<option value="rate_limited">{t("rateLimited")}</option>
|
| 162 |
+
<option value="disabled">{t("disabled")}</option>
|
| 163 |
+
</select>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Table */}
|
| 167 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden">
|
| 168 |
+
{/* Table Header */}
|
| 169 |
+
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-100 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-xs text-slate-500 dark:text-text-dim font-medium">
|
| 170 |
+
<label class="flex items-center cursor-pointer shrink-0" title={t("selectAll")}>
|
| 171 |
+
<input
|
| 172 |
+
type="checkbox"
|
| 173 |
+
checked={allPageSelected}
|
| 174 |
+
onChange={toggleSelectAll}
|
| 175 |
+
class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
|
| 176 |
+
/>
|
| 177 |
+
</label>
|
| 178 |
+
<span class="flex-1 min-w-0">Email</span>
|
| 179 |
+
<span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
|
| 180 |
+
<span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Rows */}
|
| 184 |
+
{pageAccounts.length === 0 ? (
|
| 185 |
+
<div class="px-4 py-8 text-center text-sm text-slate-400 dark:text-text-dim">
|
| 186 |
+
{t("noAccounts")}
|
| 187 |
+
</div>
|
| 188 |
+
) : (
|
| 189 |
+
pageAccounts.map((acct, idx) => {
|
| 190 |
+
const isSelected = selectedIds.has(acct.id);
|
| 191 |
+
const [statusCls, statusKey] = statusStyles[acct.status] || statusStyles.disabled;
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<div
|
| 195 |
+
key={acct.id}
|
| 196 |
+
class={`flex items-center gap-3 px-4 py-2.5 border-b border-gray-50 dark:border-border-dark/50 transition-colors cursor-pointer ${
|
| 197 |
+
isSelected
|
| 198 |
+
? "bg-primary/5 dark:bg-primary/10"
|
| 199 |
+
: "hover:bg-slate-50 dark:hover:bg-border-dark/30"
|
| 200 |
+
}`}
|
| 201 |
+
onClick={(e) => {
|
| 202 |
+
if ((e.target as HTMLElement).tagName === "SELECT") return;
|
| 203 |
+
toggleSelect(acct.id, idx, e.shiftKey);
|
| 204 |
+
}}
|
| 205 |
+
>
|
| 206 |
+
<label class="flex items-center shrink-0" onClick={(e) => e.stopPropagation()}>
|
| 207 |
+
<input
|
| 208 |
+
type="checkbox"
|
| 209 |
+
checked={isSelected}
|
| 210 |
+
onChange={() => toggleSelect(acct.id, idx, false)}
|
| 211 |
+
class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
|
| 212 |
+
/>
|
| 213 |
+
</label>
|
| 214 |
+
<span class="flex-1 min-w-0 text-sm font-medium truncate text-slate-700 dark:text-text-main">
|
| 215 |
+
{acct.email}
|
| 216 |
+
</span>
|
| 217 |
+
<span class="w-20 hidden sm:flex justify-center">
|
| 218 |
+
<span class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}>
|
| 219 |
+
{t(statusKey)}
|
| 220 |
+
</span>
|
| 221 |
+
</span>
|
| 222 |
+
<span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
|
| 223 |
+
<select
|
| 224 |
+
value={acct.proxyId || "global"}
|
| 225 |
+
onChange={(e) => onSingleProxyChange(acct.id, (e.target as HTMLSelectElement).value)}
|
| 226 |
+
class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 227 |
+
>
|
| 228 |
+
<option value="global">{t("globalDefault")}</option>
|
| 229 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 230 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 231 |
+
{proxies.map((p) => (
|
| 232 |
+
<option key={p.id} value={p.id}>
|
| 233 |
+
{p.name}
|
| 234 |
+
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
|
| 235 |
+
</option>
|
| 236 |
+
))}
|
| 237 |
+
</select>
|
| 238 |
+
</span>
|
| 239 |
+
</div>
|
| 240 |
+
);
|
| 241 |
+
})
|
| 242 |
+
)}
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Pagination */}
|
| 246 |
+
{totalPages > 1 && (
|
| 247 |
+
<div class="flex items-center justify-between text-xs text-slate-500 dark:text-text-dim">
|
| 248 |
+
<span>
|
| 249 |
+
{t("totalItems")} {totalCount}
|
| 250 |
+
</span>
|
| 251 |
+
<div class="flex items-center gap-2">
|
| 252 |
+
<button
|
| 253 |
+
onClick={() => setPage(Math.max(0, safePage - 1))}
|
| 254 |
+
disabled={safePage === 0}
|
| 255 |
+
class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
| 256 |
+
>
|
| 257 |
+
{t("prevPage")}
|
| 258 |
+
</button>
|
| 259 |
+
<span class="font-medium">
|
| 260 |
+
{safePage + 1} / {totalPages}
|
| 261 |
+
</span>
|
| 262 |
+
<button
|
| 263 |
+
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
| 264 |
+
disabled={safePage >= totalPages - 1}
|
| 265 |
+
class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
| 266 |
+
>
|
| 267 |
+
{t("nextPage")}
|
| 268 |
+
</button>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
)}
|
| 272 |
+
|
| 273 |
+
{/* Hint */}
|
| 274 |
+
<p class="text-[0.65rem] text-slate-400 dark:text-text-dim text-center">
|
| 275 |
+
{t("shiftSelectHint")}
|
| 276 |
+
</p>
|
| 277 |
+
</div>
|
| 278 |
+
);
|
| 279 |
+
}
|
packages/electron/desktop/src/components/AddAccount.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { TranslationKey } from "@shared/i18n/translations";
|
| 4 |
+
|
| 5 |
+
interface AddAccountProps {
|
| 6 |
+
visible: boolean;
|
| 7 |
+
onSubmitRelay: (callbackUrl: string) => Promise<void>;
|
| 8 |
+
addInfo: string;
|
| 9 |
+
addError: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AddAccount({ visible, onSubmitRelay, addInfo, addError }: AddAccountProps) {
|
| 13 |
+
const t = useT();
|
| 14 |
+
const [input, setInput] = useState("");
|
| 15 |
+
const [submitting, setSubmitting] = useState(false);
|
| 16 |
+
|
| 17 |
+
const handleSubmit = useCallback(async () => {
|
| 18 |
+
setSubmitting(true);
|
| 19 |
+
await onSubmitRelay(input);
|
| 20 |
+
setSubmitting(false);
|
| 21 |
+
setInput("");
|
| 22 |
+
}, [input, onSubmitRelay]);
|
| 23 |
+
|
| 24 |
+
if (!visible && !addInfo && !addError) return null;
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<>
|
| 28 |
+
{addInfo && (
|
| 29 |
+
<p class="text-sm text-primary">{t(addInfo as TranslationKey)}</p>
|
| 30 |
+
)}
|
| 31 |
+
{addError && (
|
| 32 |
+
<p class="text-sm text-red-500">{t(addError as TranslationKey)}</p>
|
| 33 |
+
)}
|
| 34 |
+
{visible && (
|
| 35 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
|
| 36 |
+
<ol class="text-sm text-slate-500 dark:text-text-dim mb-4 space-y-1.5 list-decimal list-inside">
|
| 37 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep1") }} />
|
| 38 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep2") }} />
|
| 39 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep3") }} />
|
| 40 |
+
</ol>
|
| 41 |
+
<div class="flex gap-3">
|
| 42 |
+
<input
|
| 43 |
+
type="text"
|
| 44 |
+
value={input}
|
| 45 |
+
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
|
| 46 |
+
placeholder={t("pasteCallback")}
|
| 47 |
+
class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"
|
| 48 |
+
/>
|
| 49 |
+
<button
|
| 50 |
+
onClick={handleSubmit}
|
| 51 |
+
disabled={submitting}
|
| 52 |
+
class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 53 |
+
>
|
| 54 |
+
{submitting ? t("submitting") : t("submit")}
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
</section>
|
| 58 |
+
)}
|
| 59 |
+
</>
|
| 60 |
+
);
|
| 61 |
+
}
|
packages/electron/desktop/src/components/AnthropicSetup.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import { CopyButton } from "./CopyButton";
|
| 4 |
+
|
| 5 |
+
interface AnthropicSetupProps {
|
| 6 |
+
apiKey: string;
|
| 7 |
+
selectedModel: string;
|
| 8 |
+
reasoningEffort: string;
|
| 9 |
+
serviceTier: string | null;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, serviceTier }: AnthropicSetupProps) {
|
| 13 |
+
const t = useT();
|
| 14 |
+
|
| 15 |
+
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:8080";
|
| 16 |
+
|
| 17 |
+
// Build compound model name with suffixes
|
| 18 |
+
const displayModel = useMemo(() => {
|
| 19 |
+
let name = selectedModel;
|
| 20 |
+
if (reasoningEffort && reasoningEffort !== "medium") name += `-${reasoningEffort}`;
|
| 21 |
+
if (serviceTier === "fast") name += "-fast";
|
| 22 |
+
return name;
|
| 23 |
+
}, [selectedModel, reasoningEffort, serviceTier]);
|
| 24 |
+
|
| 25 |
+
const envLines = useMemo(() => ({
|
| 26 |
+
ANTHROPIC_BASE_URL: origin,
|
| 27 |
+
ANTHROPIC_API_KEY: apiKey,
|
| 28 |
+
ANTHROPIC_MODEL: displayModel,
|
| 29 |
+
}), [origin, apiKey, displayModel]);
|
| 30 |
+
|
| 31 |
+
const allEnvText = useMemo(
|
| 32 |
+
() => Object.entries(envLines).map(([k, v]) => `${k}=${v}`).join("\n"),
|
| 33 |
+
[envLines],
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const getAllEnv = useCallback(() => allEnvText, [allEnvText]);
|
| 37 |
+
const getBaseUrl = useCallback(() => envLines.ANTHROPIC_BASE_URL, [envLines]);
|
| 38 |
+
const getApiKey = useCallback(() => envLines.ANTHROPIC_API_KEY, [envLines]);
|
| 39 |
+
const getModel = useCallback(() => envLines.ANTHROPIC_MODEL, [envLines]);
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
|
| 43 |
+
<div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
|
| 44 |
+
<div class="flex items-center gap-2">
|
| 45 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 46 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
| 47 |
+
</svg>
|
| 48 |
+
<h2 class="text-[0.95rem] font-bold">{t("anthropicSetup")}</h2>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div class="space-y-3">
|
| 53 |
+
{(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL"] as const).map((key) => {
|
| 54 |
+
const getter = key === "ANTHROPIC_BASE_URL" ? getBaseUrl : key === "ANTHROPIC_API_KEY" ? getApiKey : getModel;
|
| 55 |
+
return (
|
| 56 |
+
<div key={key} class="flex items-center gap-3">
|
| 57 |
+
<label class="text-xs font-mono font-semibold text-slate-600 dark:text-text-dim w-44 shrink-0">{key}</label>
|
| 58 |
+
<div class="relative flex items-center flex-1">
|
| 59 |
+
<input
|
| 60 |
+
class="w-full pl-3 pr-10 py-2 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all"
|
| 61 |
+
type="text"
|
| 62 |
+
value={envLines[key]}
|
| 63 |
+
readOnly
|
| 64 |
+
/>
|
| 65 |
+
<CopyButton getText={getter} class="absolute right-2" />
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
})}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="mt-5 flex items-center gap-3">
|
| 73 |
+
<CopyButton getText={getAllEnv} variant="label" />
|
| 74 |
+
<span class="text-xs text-slate-400 dark:text-text-dim">{t("anthropicCopyAllHint")}</span>
|
| 75 |
+
</div>
|
| 76 |
+
</section>
|
| 77 |
+
);
|
| 78 |
+
}
|
packages/electron/desktop/src/components/ApiConfig.tsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useT } from "@shared/i18n/context";
|
| 2 |
+
import { CopyButton } from "./CopyButton";
|
| 3 |
+
import { useCallback, useState, useEffect, useRef } from "preact/hooks";
|
| 4 |
+
import type { ModelFamily } from "@shared/hooks/use-status";
|
| 5 |
+
|
| 6 |
+
interface ApiConfigProps {
|
| 7 |
+
baseUrl: string;
|
| 8 |
+
apiKey: string;
|
| 9 |
+
models: string[];
|
| 10 |
+
selectedModel: string;
|
| 11 |
+
onModelChange: (model: string) => void;
|
| 12 |
+
modelFamilies: ModelFamily[];
|
| 13 |
+
selectedEffort: string;
|
| 14 |
+
onEffortChange: (effort: string) => void;
|
| 15 |
+
selectedSpeed: string | null;
|
| 16 |
+
onSpeedChange: (speed: string | null) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const EFFORT_LABELS: Record<string, string> = {
|
| 20 |
+
none: "None",
|
| 21 |
+
minimal: "Minimal",
|
| 22 |
+
low: "Low",
|
| 23 |
+
medium: "Medium",
|
| 24 |
+
high: "High",
|
| 25 |
+
xhigh: "XHigh",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export function ApiConfig({
|
| 29 |
+
baseUrl,
|
| 30 |
+
apiKey,
|
| 31 |
+
models,
|
| 32 |
+
selectedModel,
|
| 33 |
+
onModelChange,
|
| 34 |
+
modelFamilies,
|
| 35 |
+
selectedEffort,
|
| 36 |
+
onEffortChange,
|
| 37 |
+
selectedSpeed,
|
| 38 |
+
onSpeedChange,
|
| 39 |
+
}: ApiConfigProps) {
|
| 40 |
+
const t = useT();
|
| 41 |
+
|
| 42 |
+
const getBaseUrl = useCallback(() => baseUrl, [baseUrl]);
|
| 43 |
+
const getApiKey = useCallback(() => apiKey, [apiKey]);
|
| 44 |
+
|
| 45 |
+
const [open, setOpen] = useState(false);
|
| 46 |
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
| 47 |
+
|
| 48 |
+
// Close dropdown on click outside
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
if (!open) return;
|
| 51 |
+
const handler = (e: MouseEvent) => {
|
| 52 |
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
| 53 |
+
setOpen(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
document.addEventListener("mousedown", handler);
|
| 57 |
+
return () => document.removeEventListener("mousedown", handler);
|
| 58 |
+
}, [open]);
|
| 59 |
+
|
| 60 |
+
// When a family is selected, update model + snap effort to default if current effort is unsupported
|
| 61 |
+
const handleFamilySelect = useCallback(
|
| 62 |
+
(family: ModelFamily) => {
|
| 63 |
+
onModelChange(family.id);
|
| 64 |
+
setOpen(false);
|
| 65 |
+
const supportedEfforts = family.efforts.map((e) => e.reasoningEffort);
|
| 66 |
+
if (!supportedEfforts.includes(selectedEffort)) {
|
| 67 |
+
onEffortChange(family.defaultEffort);
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
[onModelChange, onEffortChange, selectedEffort],
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
// Find the currently selected family's supported efforts
|
| 74 |
+
const currentFamily = modelFamilies.find((f) => f.id === selectedModel);
|
| 75 |
+
const currentEfforts = currentFamily?.efforts ?? [];
|
| 76 |
+
|
| 77 |
+
const showMatrix = modelFamilies.length > 0;
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
|
| 81 |
+
<div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
|
| 82 |
+
<div class="flex items-center gap-2">
|
| 83 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 84 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
| 85 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 86 |
+
</svg>
|
| 87 |
+
<h2 class="text-[0.95rem] font-bold">{t("apiConfig")}</h2>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 91 |
+
{/* Base URL */}
|
| 92 |
+
<div class="space-y-1.5">
|
| 93 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("baseProxyUrl")}</label>
|
| 94 |
+
<div class="relative flex items-center">
|
| 95 |
+
<input
|
| 96 |
+
class="w-full pl-3 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all"
|
| 97 |
+
type="text"
|
| 98 |
+
value={baseUrl}
|
| 99 |
+
readOnly
|
| 100 |
+
/>
|
| 101 |
+
<CopyButton getText={getBaseUrl} class="absolute right-2" titleKey="copyUrl" />
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
{/* Model selector — matrix or flat fallback */}
|
| 105 |
+
<div class="space-y-1.5">
|
| 106 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("defaultModel")}</label>
|
| 107 |
+
{showMatrix ? (
|
| 108 |
+
<div ref={dropdownRef} class="relative">
|
| 109 |
+
{/* Trigger button */}
|
| 110 |
+
<button
|
| 111 |
+
onClick={() => setOpen(!open)}
|
| 112 |
+
class="w-full flex items-center justify-between px-3 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
|
| 113 |
+
>
|
| 114 |
+
<span>{currentFamily?.displayName ?? selectedModel}</span>
|
| 115 |
+
<svg class={`size-[18px] text-slate-500 dark:text-text-dim transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 116 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 117 |
+
</svg>
|
| 118 |
+
</button>
|
| 119 |
+
{/* Dropdown list */}
|
| 120 |
+
{open && (
|
| 121 |
+
<div class="absolute z-10 mt-1 w-full border border-gray-200 dark:border-border-dark rounded-lg overflow-hidden bg-white dark:bg-card-dark shadow-lg">
|
| 122 |
+
<div class="max-h-[200px] overflow-y-auto">
|
| 123 |
+
{modelFamilies.map((f) => (
|
| 124 |
+
<button
|
| 125 |
+
key={f.id}
|
| 126 |
+
onClick={() => handleFamilySelect(f)}
|
| 127 |
+
class={`w-full text-left px-3 py-2 text-[0.78rem] font-medium border-b border-gray-100 dark:border-border-dark last:border-b-0 transition-colors ${
|
| 128 |
+
selectedModel === f.id
|
| 129 |
+
? "bg-primary/10 text-primary dark:bg-primary/20"
|
| 130 |
+
: "text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d]"
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
{f.displayName}
|
| 134 |
+
</button>
|
| 135 |
+
))}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
{/* Reasoning effort buttons — always visible */}
|
| 140 |
+
{currentEfforts.length > 1 && (
|
| 141 |
+
<div class="flex gap-1.5 mt-2 flex-wrap">
|
| 142 |
+
{currentEfforts.map((e) => (
|
| 143 |
+
<button
|
| 144 |
+
key={e.reasoningEffort}
|
| 145 |
+
onClick={() => onEffortChange(e.reasoningEffort)}
|
| 146 |
+
title={e.description}
|
| 147 |
+
class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
|
| 148 |
+
selectedEffort === e.reasoningEffort
|
| 149 |
+
? "bg-primary text-white shadow-sm"
|
| 150 |
+
: "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
|
| 151 |
+
}`}
|
| 152 |
+
>
|
| 153 |
+
{EFFORT_LABELS[e.reasoningEffort] ?? e.reasoningEffort}
|
| 154 |
+
</button>
|
| 155 |
+
))}
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
{/* Speed toggle — Standard / Fast */}
|
| 159 |
+
<div class="flex items-center gap-1.5 mt-2">
|
| 160 |
+
<span class="text-[0.68rem] font-medium text-slate-500 dark:text-text-dim mr-1">{t("speed")}</span>
|
| 161 |
+
<button
|
| 162 |
+
onClick={() => onSpeedChange(null)}
|
| 163 |
+
class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
|
| 164 |
+
selectedSpeed === null
|
| 165 |
+
? "bg-primary text-white shadow-sm"
|
| 166 |
+
: "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
|
| 167 |
+
}`}
|
| 168 |
+
>
|
| 169 |
+
{t("speedStandard")}
|
| 170 |
+
</button>
|
| 171 |
+
<button
|
| 172 |
+
onClick={() => onSpeedChange("fast")}
|
| 173 |
+
class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
|
| 174 |
+
selectedSpeed === "fast"
|
| 175 |
+
? "bg-primary text-white shadow-sm"
|
| 176 |
+
: "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
|
| 177 |
+
}`}
|
| 178 |
+
>
|
| 179 |
+
{t("speedFast")}
|
| 180 |
+
</button>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
) : (
|
| 184 |
+
<div class="relative">
|
| 185 |
+
<select
|
| 186 |
+
class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
|
| 187 |
+
value={selectedModel}
|
| 188 |
+
onChange={(e) => onModelChange((e.target as HTMLSelectElement).value)}
|
| 189 |
+
>
|
| 190 |
+
{models.map((m) => (
|
| 191 |
+
<option key={m} value={m}>{m}</option>
|
| 192 |
+
))}
|
| 193 |
+
</select>
|
| 194 |
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
|
| 195 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 196 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 197 |
+
</svg>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
</div>
|
| 202 |
+
{/* API Key */}
|
| 203 |
+
<div class="space-y-1.5 md:col-span-2">
|
| 204 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("yourApiKey")}</label>
|
| 205 |
+
<div class="relative flex items-center">
|
| 206 |
+
<div class="absolute left-3 text-slate-400 dark:text-text-dim">
|
| 207 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 208 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
| 209 |
+
</svg>
|
| 210 |
+
</div>
|
| 211 |
+
<input
|
| 212 |
+
class="w-full pl-10 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all tracking-wider"
|
| 213 |
+
type="text"
|
| 214 |
+
value={apiKey}
|
| 215 |
+
readOnly
|
| 216 |
+
/>
|
| 217 |
+
<CopyButton getText={getApiKey} class="absolute right-2" titleKey="copyApiKey" />
|
| 218 |
+
</div>
|
| 219 |
+
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">{t("apiKeyHint")}</p>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</section>
|
| 223 |
+
);
|
| 224 |
+
}
|
packages/electron/desktop/src/components/BulkActions.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { ProxyEntry } from "@shared/types";
|
| 4 |
+
|
| 5 |
+
interface BulkActionsProps {
|
| 6 |
+
selectedCount: number;
|
| 7 |
+
selectedIds: Set<string>;
|
| 8 |
+
proxies: ProxyEntry[];
|
| 9 |
+
onBulkAssign: (proxyId: string) => void;
|
| 10 |
+
onEvenDistribute: () => void;
|
| 11 |
+
onOpenRuleAssign: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function BulkActions({
|
| 15 |
+
selectedCount,
|
| 16 |
+
selectedIds,
|
| 17 |
+
proxies,
|
| 18 |
+
onBulkAssign,
|
| 19 |
+
onEvenDistribute,
|
| 20 |
+
onOpenRuleAssign,
|
| 21 |
+
}: BulkActionsProps) {
|
| 22 |
+
const t = useT();
|
| 23 |
+
const [targetProxy, setTargetProxy] = useState("global");
|
| 24 |
+
|
| 25 |
+
const handleApply = useCallback(() => {
|
| 26 |
+
if (selectedIds.size === 0) return;
|
| 27 |
+
onBulkAssign(targetProxy);
|
| 28 |
+
}, [selectedIds, targetProxy, onBulkAssign]);
|
| 29 |
+
|
| 30 |
+
if (selectedCount === 0) return null;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div class="sticky bottom-0 z-40 bg-white dark:bg-card-dark border-t border-gray-200 dark:border-border-dark shadow-lg px-4 py-3">
|
| 34 |
+
<div class="flex items-center gap-3 flex-wrap">
|
| 35 |
+
{/* Selection count */}
|
| 36 |
+
<span class="text-sm font-medium text-slate-700 dark:text-text-main shrink-0">
|
| 37 |
+
{selectedCount} {t("accountsCount")} {t("selected")}
|
| 38 |
+
</span>
|
| 39 |
+
|
| 40 |
+
<div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
|
| 41 |
+
|
| 42 |
+
{/* Batch assign */}
|
| 43 |
+
<div class="flex items-center gap-2">
|
| 44 |
+
<span class="text-xs text-slate-500 dark:text-text-dim shrink-0">{t("batchAssignTo")}:</span>
|
| 45 |
+
<select
|
| 46 |
+
value={targetProxy}
|
| 47 |
+
onChange={(e) => setTargetProxy((e.target as HTMLSelectElement).value)}
|
| 48 |
+
class="text-xs px-2 py-1.5 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 49 |
+
>
|
| 50 |
+
<option value="global">{t("globalDefault")}</option>
|
| 51 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 52 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 53 |
+
{proxies.map((p) => (
|
| 54 |
+
<option key={p.id} value={p.id}>
|
| 55 |
+
{p.name}
|
| 56 |
+
</option>
|
| 57 |
+
))}
|
| 58 |
+
</select>
|
| 59 |
+
<button
|
| 60 |
+
onClick={handleApply}
|
| 61 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors"
|
| 62 |
+
>
|
| 63 |
+
{t("applyBtn")}
|
| 64 |
+
</button>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="h-4 w-px bg-gray-200 dark:bg-border-dark hidden sm:block" />
|
| 68 |
+
|
| 69 |
+
{/* Even distribute */}
|
| 70 |
+
<button
|
| 71 |
+
onClick={onEvenDistribute}
|
| 72 |
+
disabled={proxies.length === 0}
|
| 73 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 74 |
+
>
|
| 75 |
+
{t("evenDistribute")}
|
| 76 |
+
</button>
|
| 77 |
+
|
| 78 |
+
{/* Rule assign */}
|
| 79 |
+
<button
|
| 80 |
+
onClick={onOpenRuleAssign}
|
| 81 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 82 |
+
>
|
| 83 |
+
{t("ruleAssign")}
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
packages/electron/desktop/src/components/CodeExamples.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import { CopyButton } from "./CopyButton";
|
| 4 |
+
|
| 5 |
+
type Protocol = "openai" | "anthropic" | "gemini";
|
| 6 |
+
type CodeLang = "python" | "node" | "curl";
|
| 7 |
+
|
| 8 |
+
const protocols: { id: Protocol; label: string }[] = [
|
| 9 |
+
{ id: "openai", label: "OpenAI" },
|
| 10 |
+
{ id: "anthropic", label: "Anthropic" },
|
| 11 |
+
{ id: "gemini", label: "Gemini" },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
const langs: { id: CodeLang; label: string }[] = [
|
| 15 |
+
{ id: "python", label: "Python" },
|
| 16 |
+
{ id: "node", label: "Node.js" },
|
| 17 |
+
{ id: "curl", label: "cURL" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
function buildExamples(
|
| 21 |
+
baseUrl: string,
|
| 22 |
+
apiKey: string,
|
| 23 |
+
model: string,
|
| 24 |
+
origin: string,
|
| 25 |
+
reasoningEffort: string
|
| 26 |
+
): Record<string, string> {
|
| 27 |
+
const effortLine = reasoningEffort && reasoningEffort !== "medium"
|
| 28 |
+
? `\n reasoning_effort="${reasoningEffort}",`
|
| 29 |
+
: "";
|
| 30 |
+
const effortJson = reasoningEffort && reasoningEffort !== "medium"
|
| 31 |
+
? `,\n "reasoning_effort": "${reasoningEffort}"`
|
| 32 |
+
: "";
|
| 33 |
+
const effortJs = reasoningEffort && reasoningEffort !== "medium"
|
| 34 |
+
? `\n reasoning_effort: "${reasoningEffort}",`
|
| 35 |
+
: "";
|
| 36 |
+
return {
|
| 37 |
+
"openai-python": `from openai import OpenAI
|
| 38 |
+
|
| 39 |
+
client = OpenAI(
|
| 40 |
+
base_url="${baseUrl}",
|
| 41 |
+
api_key="${apiKey}",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
response = client.chat.completions.create(
|
| 45 |
+
model="${model}",
|
| 46 |
+
messages=[{"role": "user", "content": "Hello"}],${effortLine}
|
| 47 |
+
)
|
| 48 |
+
print(response.choices[0].message.content)`,
|
| 49 |
+
|
| 50 |
+
"openai-curl": `curl ${baseUrl}/chat/completions \\
|
| 51 |
+
-H "Content-Type: application/json" \\
|
| 52 |
+
-H "Authorization: Bearer ${apiKey}" \\
|
| 53 |
+
-d '{
|
| 54 |
+
"model": "${model}",
|
| 55 |
+
"messages": [{"role": "user", "content": "Hello"}]${effortJson}
|
| 56 |
+
}'`,
|
| 57 |
+
|
| 58 |
+
"openai-node": `import OpenAI from "openai";
|
| 59 |
+
|
| 60 |
+
const client = new OpenAI({
|
| 61 |
+
baseURL: "${baseUrl}",
|
| 62 |
+
apiKey: "${apiKey}",
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
const stream = await client.chat.completions.create({
|
| 66 |
+
model: "${model}",
|
| 67 |
+
messages: [{ role: "user", content: "Hello" }],${effortJs}
|
| 68 |
+
stream: true,
|
| 69 |
+
});
|
| 70 |
+
for await (const chunk of stream) {
|
| 71 |
+
process.stdout.write(chunk.choices[0]?.delta?.content || "");
|
| 72 |
+
}`,
|
| 73 |
+
|
| 74 |
+
"anthropic-python": `import anthropic
|
| 75 |
+
|
| 76 |
+
client = anthropic.Anthropic(
|
| 77 |
+
base_url="${origin}/v1",
|
| 78 |
+
api_key="${apiKey}",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
message = client.messages.create(
|
| 82 |
+
model="${model}",
|
| 83 |
+
max_tokens=1024,
|
| 84 |
+
messages=[{"role": "user", "content": "Hello"}],
|
| 85 |
+
)
|
| 86 |
+
print(message.content[0].text)`,
|
| 87 |
+
|
| 88 |
+
"anthropic-curl": `curl ${origin}/v1/messages \\
|
| 89 |
+
-H "Content-Type: application/json" \\
|
| 90 |
+
-H "x-api-key: ${apiKey}" \\
|
| 91 |
+
-H "anthropic-version: 2023-06-01" \\
|
| 92 |
+
-d '{
|
| 93 |
+
"model": "${model}",
|
| 94 |
+
"max_tokens": 1024,
|
| 95 |
+
"messages": [{"role": "user", "content": "Hello"}]
|
| 96 |
+
}'`,
|
| 97 |
+
|
| 98 |
+
"anthropic-node": `import Anthropic from "@anthropic-ai/sdk";
|
| 99 |
+
|
| 100 |
+
const client = new Anthropic({
|
| 101 |
+
baseURL: "${origin}/v1",
|
| 102 |
+
apiKey: "${apiKey}",
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const message = await client.messages.create({
|
| 106 |
+
model: "${model}",
|
| 107 |
+
max_tokens: 1024,
|
| 108 |
+
messages: [{ role: "user", content: "Hello" }],
|
| 109 |
+
});
|
| 110 |
+
console.log(message.content[0].text);`,
|
| 111 |
+
|
| 112 |
+
"gemini-python": `from google import genai
|
| 113 |
+
|
| 114 |
+
client = genai.Client(
|
| 115 |
+
api_key="${apiKey}",
|
| 116 |
+
http_options={"base_url": "${origin}/v1beta"},
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
response = client.models.generate_content(
|
| 120 |
+
model="${model}",
|
| 121 |
+
contents="Hello",
|
| 122 |
+
)
|
| 123 |
+
print(response.text)`,
|
| 124 |
+
|
| 125 |
+
"gemini-curl": `curl "${origin}/v1beta/models/${model}:generateContent?key=${apiKey}" \\
|
| 126 |
+
-H "Content-Type: application/json" \\
|
| 127 |
+
-d '{
|
| 128 |
+
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
|
| 129 |
+
}'`,
|
| 130 |
+
|
| 131 |
+
"gemini-node": `import { GoogleGenAI } from "@google/genai";
|
| 132 |
+
|
| 133 |
+
const ai = new GoogleGenAI({
|
| 134 |
+
apiKey: "${apiKey}",
|
| 135 |
+
httpOptions: { baseUrl: "${origin}/v1beta" },
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const response = await ai.models.generateContent({
|
| 139 |
+
model: "${model}",
|
| 140 |
+
contents: "Hello",
|
| 141 |
+
});
|
| 142 |
+
console.log(response.text);`,
|
| 143 |
+
};
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
interface CodeExamplesProps {
|
| 147 |
+
baseUrl: string;
|
| 148 |
+
apiKey: string;
|
| 149 |
+
model: string;
|
| 150 |
+
reasoningEffort: string;
|
| 151 |
+
serviceTier: string | null;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export function CodeExamples({ baseUrl, apiKey, model, reasoningEffort, serviceTier }: CodeExamplesProps) {
|
| 155 |
+
const t = useT();
|
| 156 |
+
const [protocol, setProtocol] = useState<Protocol>("openai");
|
| 157 |
+
const [codeLang, setCodeLang] = useState<CodeLang>("python");
|
| 158 |
+
|
| 159 |
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
| 160 |
+
|
| 161 |
+
// Build compound model name with suffixes for CLI users
|
| 162 |
+
const displayModel = useMemo(() => {
|
| 163 |
+
let name = model;
|
| 164 |
+
if (reasoningEffort && reasoningEffort !== "medium") name += `-${reasoningEffort}`;
|
| 165 |
+
if (serviceTier === "fast") name += "-fast";
|
| 166 |
+
return name;
|
| 167 |
+
}, [model, reasoningEffort, serviceTier]);
|
| 168 |
+
|
| 169 |
+
// When effort/speed are embedded as suffixes, don't also show separate reasoning_effort param
|
| 170 |
+
const explicitEffort = displayModel === model ? reasoningEffort : "medium";
|
| 171 |
+
const examples = useMemo(
|
| 172 |
+
() => buildExamples(baseUrl, apiKey, displayModel, origin, explicitEffort),
|
| 173 |
+
[baseUrl, apiKey, displayModel, origin, explicitEffort]
|
| 174 |
+
);
|
| 175 |
+
|
| 176 |
+
const currentCode = examples[`${protocol}-${codeLang}`] || "Loading...";
|
| 177 |
+
const getCode = useCallback(() => currentCode, [currentCode]);
|
| 178 |
+
|
| 179 |
+
const protoActive =
|
| 180 |
+
"px-6 py-3 text-[0.82rem] font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors";
|
| 181 |
+
const protoInactive =
|
| 182 |
+
"px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors";
|
| 183 |
+
const langActive =
|
| 184 |
+
"px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all";
|
| 185 |
+
const langInactive =
|
| 186 |
+
"px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all";
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<section class="flex flex-col gap-4">
|
| 190 |
+
<h2 class="text-[0.95rem] font-bold">{t("integrationExamples")}</h2>
|
| 191 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
|
| 192 |
+
{/* Protocol Tabs */}
|
| 193 |
+
<div class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
|
| 194 |
+
{protocols.map((p) => (
|
| 195 |
+
<button
|
| 196 |
+
key={p.id}
|
| 197 |
+
onClick={() => setProtocol(p.id)}
|
| 198 |
+
class={protocol === p.id ? protoActive : protoInactive}
|
| 199 |
+
>
|
| 200 |
+
{p.label}
|
| 201 |
+
</button>
|
| 202 |
+
))}
|
| 203 |
+
</div>
|
| 204 |
+
{/* Language Tabs & Code */}
|
| 205 |
+
<div class="p-5">
|
| 206 |
+
<div class="flex items-center justify-between mb-4">
|
| 207 |
+
<div class="flex gap-2 p-1 bg-slate-100 dark:bg-bg-dark dark:border dark:border-border-dark rounded-lg">
|
| 208 |
+
{langs.map((l) => (
|
| 209 |
+
<button
|
| 210 |
+
key={l.id}
|
| 211 |
+
onClick={() => setCodeLang(l.id)}
|
| 212 |
+
class={codeLang === l.id ? langActive : langInactive}
|
| 213 |
+
>
|
| 214 |
+
{l.label}
|
| 215 |
+
</button>
|
| 216 |
+
))}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
{/* Code Block */}
|
| 220 |
+
<div class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
|
| 221 |
+
<div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 222 |
+
<CopyButton getText={getCode} variant="label" />
|
| 223 |
+
</div>
|
| 224 |
+
<div class="p-4 overflow-x-auto">
|
| 225 |
+
<pre class="m-0"><code>{currentCode}</code></pre>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</section>
|
| 231 |
+
);
|
| 232 |
+
}
|
packages/electron/desktop/src/components/CopyButton.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { clipboardCopy } from "@shared/utils/clipboard";
|
| 3 |
+
import { useT } from "@shared/i18n/context";
|
| 4 |
+
import type { TranslationKey } from "@shared/i18n/translations";
|
| 5 |
+
|
| 6 |
+
interface CopyButtonProps {
|
| 7 |
+
getText: () => string;
|
| 8 |
+
class?: string;
|
| 9 |
+
titleKey?: string;
|
| 10 |
+
variant?: "icon" | "label";
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const SVG_COPY = (
|
| 14 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 15 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 16 |
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
| 17 |
+
</svg>
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
const SVG_CHECK = (
|
| 21 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 22 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
| 23 |
+
</svg>
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
const SVG_FAIL = (
|
| 27 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 28 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
| 29 |
+
</svg>
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
export function CopyButton({ getText, class: className, titleKey, variant = "icon" }: CopyButtonProps) {
|
| 33 |
+
const t = useT();
|
| 34 |
+
const [state, setState] = useState<"idle" | "ok" | "fail">("idle");
|
| 35 |
+
|
| 36 |
+
const handleCopy = useCallback(async () => {
|
| 37 |
+
const ok = await clipboardCopy(getText());
|
| 38 |
+
setState(ok ? "ok" : "fail");
|
| 39 |
+
setTimeout(() => setState("idle"), 2000);
|
| 40 |
+
}, [getText]);
|
| 41 |
+
|
| 42 |
+
if (variant === "label") {
|
| 43 |
+
const bgClass =
|
| 44 |
+
state === "ok"
|
| 45 |
+
? "bg-primary hover:bg-primary-hover"
|
| 46 |
+
: state === "fail"
|
| 47 |
+
? "bg-red-600 hover:bg-red-700"
|
| 48 |
+
: "bg-slate-700 hover:bg-slate-600";
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<button
|
| 52 |
+
onClick={handleCopy}
|
| 53 |
+
class={`flex items-center gap-1.5 px-3 py-1.5 ${bgClass} text-white rounded text-xs font-medium transition-colors ${className || ""}`}
|
| 54 |
+
>
|
| 55 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 56 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 57 |
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
| 58 |
+
</svg>
|
| 59 |
+
<span>
|
| 60 |
+
{state === "ok" ? t("copied") : state === "fail" ? t("copyFailed") : t("copy")}
|
| 61 |
+
</span>
|
| 62 |
+
</button>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<button
|
| 68 |
+
onClick={handleCopy}
|
| 69 |
+
class={`p-1.5 transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark ${
|
| 70 |
+
state === "ok"
|
| 71 |
+
? "text-primary"
|
| 72 |
+
: state === "fail"
|
| 73 |
+
? "text-red-500"
|
| 74 |
+
: "text-slate-400 dark:text-text-dim hover:text-primary"
|
| 75 |
+
} ${className || ""}`}
|
| 76 |
+
title={titleKey ? t(titleKey as TranslationKey) : undefined}
|
| 77 |
+
>
|
| 78 |
+
{state === "ok" ? SVG_CHECK : state === "fail" ? SVG_FAIL : SVG_COPY}
|
| 79 |
+
</button>
|
| 80 |
+
);
|
| 81 |
+
}
|
packages/electron/desktop/src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useT } from "@shared/i18n/context";
|
| 2 |
+
import type { UpdateStatus } from "@shared/hooks/use-update-status";
|
| 3 |
+
|
| 4 |
+
interface FooterProps {
|
| 5 |
+
updateStatus: UpdateStatus | null;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function Footer({ updateStatus }: FooterProps) {
|
| 9 |
+
const t = useT();
|
| 10 |
+
|
| 11 |
+
const proxyVersion = updateStatus?.proxy.version ?? "...";
|
| 12 |
+
const proxyCommit = updateStatus?.proxy.commit;
|
| 13 |
+
const codexVersion = updateStatus?.codex.current_version;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-5 transition-colors">
|
| 17 |
+
<div class="container mx-auto px-4 flex flex-col items-center gap-2">
|
| 18 |
+
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[0.75rem] text-slate-400 dark:text-text-dim font-mono">
|
| 19 |
+
<span>Proxy v{proxyVersion}{proxyCommit ? ` (${proxyCommit})` : ""}</span>
|
| 20 |
+
{codexVersion && (
|
| 21 |
+
<>
|
| 22 |
+
<span class="text-slate-300 dark:text-border-dark">·</span>
|
| 23 |
+
<span>Codex Desktop v{codexVersion}</span>
|
| 24 |
+
</>
|
| 25 |
+
)}
|
| 26 |
+
</div>
|
| 27 |
+
<p class="text-[0.75rem] text-slate-400 dark:text-text-dim">{t("footer")}</p>
|
| 28 |
+
</div>
|
| 29 |
+
</footer>
|
| 30 |
+
);
|
| 31 |
+
}
|
packages/electron/desktop/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from "@shared/i18n/context";
|
| 2 |
+
import { useTheme } from "@shared/theme/context";
|
| 3 |
+
|
| 4 |
+
const SVG_MOON = (
|
| 5 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 6 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
| 7 |
+
</svg>
|
| 8 |
+
);
|
| 9 |
+
|
| 10 |
+
const SVG_SUN = (
|
| 11 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 12 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
| 13 |
+
</svg>
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
interface HeaderProps {
|
| 17 |
+
onAddAccount: () => void;
|
| 18 |
+
version: string | null;
|
| 19 |
+
commit?: string | null;
|
| 20 |
+
isProxySettings?: boolean;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function Header({ onAddAccount, version, commit, isProxySettings }: HeaderProps) {
|
| 24 |
+
const { lang, toggleLang, t } = useI18n();
|
| 25 |
+
const { isDark, toggle: toggleTheme } = useTheme();
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
|
| 29 |
+
<div class="px-4 md:px-8 lg:px-40 flex h-14 items-center justify-center">
|
| 30 |
+
<div class="flex w-full max-w-[960px] items-center justify-between">
|
| 31 |
+
{/* Logo & Title */}
|
| 32 |
+
<div class="flex items-center gap-3">
|
| 33 |
+
{isProxySettings ? (
|
| 34 |
+
<a
|
| 35 |
+
href="#"
|
| 36 |
+
class="flex items-center gap-1.5 text-sm text-slate-500 dark:text-text-dim hover:text-primary transition-colors"
|
| 37 |
+
>
|
| 38 |
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
| 40 |
+
</svg>
|
| 41 |
+
<span class="font-medium">{t("backToDashboard")}</span>
|
| 42 |
+
</a>
|
| 43 |
+
) : (
|
| 44 |
+
<>
|
| 45 |
+
<div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
|
| 46 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 47 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 48 |
+
</svg>
|
| 49 |
+
</div>
|
| 50 |
+
<h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
|
| 51 |
+
</>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
{/* Actions */}
|
| 55 |
+
<div class="flex items-center gap-2">
|
| 56 |
+
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
| 57 |
+
<span class="relative flex h-2.5 w-2.5">
|
| 58 |
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
| 59 |
+
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
|
| 60 |
+
</span>
|
| 61 |
+
<span class="text-xs font-semibold text-primary inline-grid">
|
| 62 |
+
<span class="invisible col-start-1 row-start-1">Server Online</span>
|
| 63 |
+
<span class="col-start-1 row-start-1">{t("serverOnline")}</span>
|
| 64 |
+
</span>
|
| 65 |
+
{version && (
|
| 66 |
+
<span class="text-[0.65rem] font-mono text-primary/70">v{version}</span>
|
| 67 |
+
)}
|
| 68 |
+
{commit && (
|
| 69 |
+
<span class="text-[0.6rem] font-mono text-primary/40">{commit.slice(0, 7)}</span>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
{/* Star on GitHub */}
|
| 73 |
+
<a
|
| 74 |
+
href="https://github.com/icebear0828/codex-proxy"
|
| 75 |
+
target="_blank"
|
| 76 |
+
rel="noopener noreferrer"
|
| 77 |
+
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
|
| 78 |
+
>
|
| 79 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
|
| 80 |
+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
| 81 |
+
</svg>
|
| 82 |
+
<span class="text-xs font-semibold">{t("starOnGithub")}</span>
|
| 83 |
+
</a>
|
| 84 |
+
{/* Check for Updates — hidden in electron mode (tray handles updates) */}
|
| 85 |
+
{/* Language Toggle */}
|
| 86 |
+
<button
|
| 87 |
+
onClick={toggleLang}
|
| 88 |
+
class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
|
| 89 |
+
title="中/EN"
|
| 90 |
+
>
|
| 91 |
+
<span class="text-xs font-bold inline-flex items-center justify-center w-5">{lang === "en" ? "EN" : "中"}</span>
|
| 92 |
+
</button>
|
| 93 |
+
{/* Theme Toggle */}
|
| 94 |
+
<button
|
| 95 |
+
onClick={toggleTheme}
|
| 96 |
+
class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
|
| 97 |
+
title={t("toggleTheme")}
|
| 98 |
+
>
|
| 99 |
+
{isDark ? SVG_SUN : SVG_MOON}
|
| 100 |
+
</button>
|
| 101 |
+
{/* Proxy Settings / Add Account */}
|
| 102 |
+
{isProxySettings ? null : (
|
| 103 |
+
<>
|
| 104 |
+
<a
|
| 105 |
+
href="#/proxy-settings"
|
| 106 |
+
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 107 |
+
>
|
| 108 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 109 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
| 110 |
+
</svg>
|
| 111 |
+
<span class="text-xs font-semibold">{t("proxySettings")}</span>
|
| 112 |
+
</a>
|
| 113 |
+
<button
|
| 114 |
+
onClick={onAddAccount}
|
| 115 |
+
class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95"
|
| 116 |
+
>
|
| 117 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
| 118 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
| 119 |
+
</svg>
|
| 120 |
+
<span class="inline-grid">
|
| 121 |
+
<span class="invisible col-start-1 row-start-1">Add Account</span>
|
| 122 |
+
<span class="col-start-1 row-start-1">{t("addAccount")}</span>
|
| 123 |
+
</span>
|
| 124 |
+
</button>
|
| 125 |
+
</>
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
{/* Update panel hidden — electron-updater handles updates via system tray */}
|
| 131 |
+
</header>
|
| 132 |
+
);
|
| 133 |
+
}
|
packages/electron/desktop/src/components/ImportExport.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { ImportDiff } from "@shared/hooks/use-proxy-assignments";
|
| 4 |
+
|
| 5 |
+
interface ImportExportProps {
|
| 6 |
+
onExport: () => Promise<Array<{ email: string; proxyId: string }>>;
|
| 7 |
+
onImportPreview: (data: Array<{ email: string; proxyId: string }>) => Promise<ImportDiff | null>;
|
| 8 |
+
onApplyImport: (assignments: Array<{ accountId: string; proxyId: string }>) => Promise<void>;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function ImportExport({ onExport, onImportPreview, onApplyImport }: ImportExportProps) {
|
| 12 |
+
const t = useT();
|
| 13 |
+
const [showImport, setShowImport] = useState(false);
|
| 14 |
+
const [diff, setDiff] = useState<ImportDiff | null>(null);
|
| 15 |
+
const [importing, setImporting] = useState(false);
|
| 16 |
+
const fileRef = useRef<HTMLInputElement>(null);
|
| 17 |
+
|
| 18 |
+
const handleExport = useCallback(async () => {
|
| 19 |
+
const data = await onExport();
|
| 20 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
| 21 |
+
const url = URL.createObjectURL(blob);
|
| 22 |
+
const a = document.createElement("a");
|
| 23 |
+
a.href = url;
|
| 24 |
+
a.download = "proxy-assignments.json";
|
| 25 |
+
a.click();
|
| 26 |
+
URL.revokeObjectURL(url);
|
| 27 |
+
}, [onExport]);
|
| 28 |
+
|
| 29 |
+
const handleFileSelect = useCallback(async () => {
|
| 30 |
+
const file = fileRef.current?.files?.[0];
|
| 31 |
+
if (!file) return;
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
const text = await file.text();
|
| 35 |
+
const parsed = JSON.parse(text) as Array<{ email: string; proxyId: string }>;
|
| 36 |
+
if (!Array.isArray(parsed)) {
|
| 37 |
+
alert("Invalid format: expected array of { email, proxyId }");
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
setImporting(true);
|
| 41 |
+
const result = await onImportPreview(parsed);
|
| 42 |
+
setDiff(result);
|
| 43 |
+
setImporting(false);
|
| 44 |
+
} catch {
|
| 45 |
+
alert("Failed to parse JSON file");
|
| 46 |
+
}
|
| 47 |
+
}, [onImportPreview]);
|
| 48 |
+
|
| 49 |
+
const handleApply = useCallback(async () => {
|
| 50 |
+
if (!diff || diff.changes.length === 0) return;
|
| 51 |
+
setImporting(true);
|
| 52 |
+
await onApplyImport(
|
| 53 |
+
diff.changes.map((c) => ({ accountId: c.accountId, proxyId: c.to })),
|
| 54 |
+
);
|
| 55 |
+
setImporting(false);
|
| 56 |
+
setDiff(null);
|
| 57 |
+
setShowImport(false);
|
| 58 |
+
}, [diff, onApplyImport]);
|
| 59 |
+
|
| 60 |
+
const handleClose = useCallback(() => {
|
| 61 |
+
setShowImport(false);
|
| 62 |
+
setDiff(null);
|
| 63 |
+
if (fileRef.current) fileRef.current.value = "";
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<>
|
| 68 |
+
{/* Buttons */}
|
| 69 |
+
<div class="flex items-center gap-2">
|
| 70 |
+
<button
|
| 71 |
+
onClick={() => setShowImport(true)}
|
| 72 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 73 |
+
>
|
| 74 |
+
{t("importBtn")}
|
| 75 |
+
</button>
|
| 76 |
+
<button
|
| 77 |
+
onClick={handleExport}
|
| 78 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 79 |
+
>
|
| 80 |
+
{t("exportBtn")}
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Import Modal */}
|
| 85 |
+
{showImport && (
|
| 86 |
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={handleClose}>
|
| 87 |
+
<div
|
| 88 |
+
class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 w-full max-w-lg mx-4 shadow-xl"
|
| 89 |
+
onClick={(e) => e.stopPropagation()}
|
| 90 |
+
>
|
| 91 |
+
<h3 class="text-lg font-semibold mb-4">{t("importPreview")}</h3>
|
| 92 |
+
|
| 93 |
+
{!diff ? (
|
| 94 |
+
<div class="space-y-4">
|
| 95 |
+
<p class="text-sm text-slate-500 dark:text-text-dim">{t("importFile")}</p>
|
| 96 |
+
<input
|
| 97 |
+
ref={fileRef}
|
| 98 |
+
type="file"
|
| 99 |
+
accept=".json"
|
| 100 |
+
onChange={handleFileSelect}
|
| 101 |
+
class="block w-full text-sm text-slate-500 dark:text-text-dim file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20 cursor-pointer"
|
| 102 |
+
/>
|
| 103 |
+
{importing && (
|
| 104 |
+
<p class="text-sm text-slate-500 dark:text-text-dim animate-pulse">Loading...</p>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
) : (
|
| 108 |
+
<div class="space-y-4">
|
| 109 |
+
{diff.changes.length === 0 ? (
|
| 110 |
+
<p class="text-sm text-slate-500 dark:text-text-dim">{t("noChanges")}</p>
|
| 111 |
+
) : (
|
| 112 |
+
<>
|
| 113 |
+
<p class="text-sm font-medium">
|
| 114 |
+
{diff.changes.length} {t("changesCount")}
|
| 115 |
+
{diff.unchanged > 0 && (
|
| 116 |
+
<span class="text-slate-400 dark:text-text-dim ml-2">
|
| 117 |
+
({diff.unchanged} unchanged)
|
| 118 |
+
</span>
|
| 119 |
+
)}
|
| 120 |
+
</p>
|
| 121 |
+
<div class="max-h-60 overflow-y-auto border border-gray-100 dark:border-border-dark rounded-lg">
|
| 122 |
+
{diff.changes.map((c) => (
|
| 123 |
+
<div
|
| 124 |
+
key={c.accountId}
|
| 125 |
+
class="flex items-center justify-between px-3 py-2 text-xs border-b border-gray-50 dark:border-border-dark/50"
|
| 126 |
+
>
|
| 127 |
+
<span class="font-medium truncate flex-1 text-slate-700 dark:text-text-main">
|
| 128 |
+
{c.email}
|
| 129 |
+
</span>
|
| 130 |
+
<span class="flex items-center gap-1.5 shrink-0 ml-2">
|
| 131 |
+
<span class="text-slate-400 dark:text-text-dim">{c.from}</span>
|
| 132 |
+
<svg class="size-3 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 133 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
| 134 |
+
</svg>
|
| 135 |
+
<span class="text-primary font-medium">{c.to}</span>
|
| 136 |
+
</span>
|
| 137 |
+
</div>
|
| 138 |
+
))}
|
| 139 |
+
</div>
|
| 140 |
+
</>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
{/* Actions */}
|
| 146 |
+
<div class="flex items-center justify-end gap-2 mt-4">
|
| 147 |
+
<button
|
| 148 |
+
onClick={handleClose}
|
| 149 |
+
class="px-4 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 150 |
+
>
|
| 151 |
+
{t("cancelBtn")}
|
| 152 |
+
</button>
|
| 153 |
+
{diff && diff.changes.length > 0 && (
|
| 154 |
+
<button
|
| 155 |
+
onClick={handleApply}
|
| 156 |
+
disabled={importing}
|
| 157 |
+
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
|
| 158 |
+
>
|
| 159 |
+
{importing ? "..." : t("confirmApply")}
|
| 160 |
+
</button>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
</>
|
| 167 |
+
);
|
| 168 |
+
}
|
packages/electron/desktop/src/components/ProxyGroupList.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { AssignmentAccount } from "@shared/hooks/use-proxy-assignments";
|
| 4 |
+
import type { ProxyEntry } from "@shared/types";
|
| 5 |
+
|
| 6 |
+
interface ProxyGroup {
|
| 7 |
+
id: string;
|
| 8 |
+
label: string;
|
| 9 |
+
count: number;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface ProxyGroupListProps {
|
| 13 |
+
accounts: AssignmentAccount[];
|
| 14 |
+
proxies: ProxyEntry[];
|
| 15 |
+
selectedGroup: string | null;
|
| 16 |
+
onSelectGroup: (groupId: string | null) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function ProxyGroupList({ accounts, proxies, selectedGroup, onSelectGroup }: ProxyGroupListProps) {
|
| 20 |
+
const t = useT();
|
| 21 |
+
const [search, setSearch] = useState("");
|
| 22 |
+
|
| 23 |
+
// Build groups with counts
|
| 24 |
+
const groups: ProxyGroup[] = [];
|
| 25 |
+
|
| 26 |
+
// "All"
|
| 27 |
+
groups.push({ id: "__all__", label: t("allAccounts"), count: accounts.length });
|
| 28 |
+
|
| 29 |
+
// Count by assignment
|
| 30 |
+
const countMap = new Map<string, number>();
|
| 31 |
+
for (const acct of accounts) {
|
| 32 |
+
const key = acct.proxyId || "global";
|
| 33 |
+
countMap.set(key, (countMap.get(key) || 0) + 1);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Special groups
|
| 37 |
+
groups.push({ id: "global", label: t("globalDefault"), count: countMap.get("global") || 0 });
|
| 38 |
+
|
| 39 |
+
// Proxy entries
|
| 40 |
+
for (const p of proxies) {
|
| 41 |
+
groups.push({ id: p.id, label: p.name, count: countMap.get(p.id) || 0 });
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
groups.push({ id: "direct", label: t("directNoProxy"), count: countMap.get("direct") || 0 });
|
| 45 |
+
groups.push({ id: "auto", label: t("autoRoundRobin"), count: countMap.get("auto") || 0 });
|
| 46 |
+
|
| 47 |
+
// "Unassigned" — accounts not in any explicit assignment (they default to "global")
|
| 48 |
+
// Since getAssignment defaults to "global", all accounts have a proxyId.
|
| 49 |
+
// "Unassigned" is not really a separate bucket in this model — skip or show 0.
|
| 50 |
+
|
| 51 |
+
const lowerSearch = search.toLowerCase();
|
| 52 |
+
const filtered = search
|
| 53 |
+
? groups.filter((g) => g.label.toLowerCase().includes(lowerSearch))
|
| 54 |
+
: groups;
|
| 55 |
+
|
| 56 |
+
const active = selectedGroup ?? "__all__";
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div class="flex flex-col gap-1">
|
| 60 |
+
{/* Search */}
|
| 61 |
+
<input
|
| 62 |
+
type="text"
|
| 63 |
+
placeholder={t("searchProxy")}
|
| 64 |
+
value={search}
|
| 65 |
+
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
|
| 66 |
+
class="px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary mb-2"
|
| 67 |
+
/>
|
| 68 |
+
|
| 69 |
+
{filtered.map((g) => {
|
| 70 |
+
const isActive = active === g.id;
|
| 71 |
+
return (
|
| 72 |
+
<button
|
| 73 |
+
key={g.id}
|
| 74 |
+
onClick={() => onSelectGroup(g.id === "__all__" ? null : g.id)}
|
| 75 |
+
class={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors text-left ${
|
| 76 |
+
isActive
|
| 77 |
+
? "bg-primary/10 text-primary font-medium border border-primary/20"
|
| 78 |
+
: "hover:bg-slate-50 dark:hover:bg-border-dark text-slate-700 dark:text-text-main"
|
| 79 |
+
}`}
|
| 80 |
+
>
|
| 81 |
+
<span class="truncate">{g.label}</span>
|
| 82 |
+
<span
|
| 83 |
+
class={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium shrink-0 ${
|
| 84 |
+
isActive
|
| 85 |
+
? "bg-primary/20 text-primary"
|
| 86 |
+
: "bg-slate-100 dark:bg-border-dark text-slate-500 dark:text-text-dim"
|
| 87 |
+
}`}
|
| 88 |
+
>
|
| 89 |
+
{g.count}
|
| 90 |
+
</span>
|
| 91 |
+
</button>
|
| 92 |
+
);
|
| 93 |
+
})}
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
packages/electron/desktop/src/components/ProxyPool.tsx
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { TranslationKey } from "@shared/i18n/translations";
|
| 4 |
+
import type { ProxiesState } from "@shared/hooks/use-proxies";
|
| 5 |
+
|
| 6 |
+
const PROTOCOLS = ["http", "https", "socks5", "socks5h"] as const;
|
| 7 |
+
|
| 8 |
+
const statusStyles: Record<string, [string, TranslationKey]> = {
|
| 9 |
+
active: [
|
| 10 |
+
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
| 11 |
+
"proxyActive",
|
| 12 |
+
],
|
| 13 |
+
unreachable: [
|
| 14 |
+
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
|
| 15 |
+
"proxyUnreachable",
|
| 16 |
+
],
|
| 17 |
+
disabled: [
|
| 18 |
+
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 19 |
+
"proxyDisabled",
|
| 20 |
+
],
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const inputCls = "px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary";
|
| 24 |
+
|
| 25 |
+
interface ProxyPoolProps {
|
| 26 |
+
proxies: ProxiesState;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function ProxyPool({ proxies }: ProxyPoolProps) {
|
| 30 |
+
const t = useT();
|
| 31 |
+
const [showAdd, setShowAdd] = useState(false);
|
| 32 |
+
const [newName, setNewName] = useState("");
|
| 33 |
+
const [newProtocol, setNewProtocol] = useState("http");
|
| 34 |
+
const [newHost, setNewHost] = useState("");
|
| 35 |
+
const [newPort, setNewPort] = useState("");
|
| 36 |
+
const [newUsername, setNewUsername] = useState("");
|
| 37 |
+
const [newPassword, setNewPassword] = useState("");
|
| 38 |
+
const [addError, setAddError] = useState("");
|
| 39 |
+
const [checking, setChecking] = useState<string | null>(null);
|
| 40 |
+
const [checkingAll, setCheckingAll] = useState(false);
|
| 41 |
+
|
| 42 |
+
const resetForm = useCallback(() => {
|
| 43 |
+
setNewName("");
|
| 44 |
+
setNewProtocol("http");
|
| 45 |
+
setNewHost("");
|
| 46 |
+
setNewPort("");
|
| 47 |
+
setNewUsername("");
|
| 48 |
+
setNewPassword("");
|
| 49 |
+
setAddError("");
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
const handleAdd = useCallback(async () => {
|
| 53 |
+
setAddError("");
|
| 54 |
+
if (!newHost.trim()) {
|
| 55 |
+
setAddError(t("proxyHost") + " is required");
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
const err = await proxies.addProxy({
|
| 59 |
+
name: newName,
|
| 60 |
+
protocol: newProtocol,
|
| 61 |
+
host: newHost,
|
| 62 |
+
port: newPort,
|
| 63 |
+
username: newUsername,
|
| 64 |
+
password: newPassword,
|
| 65 |
+
});
|
| 66 |
+
if (err) {
|
| 67 |
+
setAddError(err);
|
| 68 |
+
} else {
|
| 69 |
+
resetForm();
|
| 70 |
+
setShowAdd(false);
|
| 71 |
+
}
|
| 72 |
+
}, [newName, newProtocol, newHost, newPort, newUsername, newPassword, proxies, resetForm, t]);
|
| 73 |
+
|
| 74 |
+
const handleCheck = useCallback(
|
| 75 |
+
async (id: string) => {
|
| 76 |
+
setChecking(id);
|
| 77 |
+
await proxies.checkProxy(id);
|
| 78 |
+
setChecking(null);
|
| 79 |
+
},
|
| 80 |
+
[proxies],
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const handleCheckAll = useCallback(async () => {
|
| 84 |
+
setCheckingAll(true);
|
| 85 |
+
await proxies.checkAll();
|
| 86 |
+
setCheckingAll(false);
|
| 87 |
+
}, [proxies]);
|
| 88 |
+
|
| 89 |
+
const handleDelete = useCallback(
|
| 90 |
+
async (id: string) => {
|
| 91 |
+
if (!confirm(t("removeProxyConfirm"))) return;
|
| 92 |
+
await proxies.removeProxy(id);
|
| 93 |
+
},
|
| 94 |
+
[proxies, t],
|
| 95 |
+
);
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<section>
|
| 99 |
+
{/* Header */}
|
| 100 |
+
<div class="flex items-center justify-between mb-2">
|
| 101 |
+
<div>
|
| 102 |
+
<h2 class="text-lg font-semibold">{t("proxyPool")}</h2>
|
| 103 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mt-0.5">
|
| 104 |
+
{t("proxyPoolDesc")}
|
| 105 |
+
</p>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="flex items-center gap-2">
|
| 108 |
+
{proxies.proxies.length > 0 && (
|
| 109 |
+
<button
|
| 110 |
+
onClick={handleCheckAll}
|
| 111 |
+
disabled={checkingAll}
|
| 112 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
|
| 113 |
+
>
|
| 114 |
+
{checkingAll ? "..." : t("checkAllHealth")}
|
| 115 |
+
</button>
|
| 116 |
+
)}
|
| 117 |
+
<button
|
| 118 |
+
onClick={() => setShowAdd(!showAdd)}
|
| 119 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
| 120 |
+
>
|
| 121 |
+
{t("addProxy")}
|
| 122 |
+
</button>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Add form */}
|
| 127 |
+
{showAdd && (
|
| 128 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
|
| 129 |
+
<div class="grid grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_1fr_80px] gap-2 items-center">
|
| 130 |
+
{/* Row 1: Protocol + Host + Port */}
|
| 131 |
+
<select
|
| 132 |
+
value={newProtocol}
|
| 133 |
+
onChange={(e) => setNewProtocol((e.target as HTMLSelectElement).value)}
|
| 134 |
+
class={`${inputCls} w-[110px]`}
|
| 135 |
+
>
|
| 136 |
+
{PROTOCOLS.map((p) => (
|
| 137 |
+
<option key={p} value={p}>{p}://</option>
|
| 138 |
+
))}
|
| 139 |
+
</select>
|
| 140 |
+
<input
|
| 141 |
+
type="text"
|
| 142 |
+
placeholder={t("proxyHost")}
|
| 143 |
+
value={newHost}
|
| 144 |
+
onInput={(e) => setNewHost((e.target as HTMLInputElement).value)}
|
| 145 |
+
class={`${inputCls} font-mono`}
|
| 146 |
+
/>
|
| 147 |
+
<input
|
| 148 |
+
type="text"
|
| 149 |
+
placeholder={t("proxyPort")}
|
| 150 |
+
value={newPort}
|
| 151 |
+
onInput={(e) => setNewPort((e.target as HTMLInputElement).value)}
|
| 152 |
+
class={`${inputCls} font-mono`}
|
| 153 |
+
/>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2">
|
| 156 |
+
{/* Row 2: Name + Username + Password */}
|
| 157 |
+
<input
|
| 158 |
+
type="text"
|
| 159 |
+
placeholder={`${t("proxyName")} (${t("proxyOptional")})`}
|
| 160 |
+
value={newName}
|
| 161 |
+
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
| 162 |
+
class={inputCls}
|
| 163 |
+
/>
|
| 164 |
+
<input
|
| 165 |
+
type="text"
|
| 166 |
+
placeholder={`${t("proxyUsername")} (${t("proxyOptional")})`}
|
| 167 |
+
value={newUsername}
|
| 168 |
+
onInput={(e) => setNewUsername((e.target as HTMLInputElement).value)}
|
| 169 |
+
class={inputCls}
|
| 170 |
+
/>
|
| 171 |
+
<input
|
| 172 |
+
type="password"
|
| 173 |
+
placeholder={`${t("proxyPassword")} (${t("proxyOptional")})`}
|
| 174 |
+
value={newPassword}
|
| 175 |
+
onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}
|
| 176 |
+
class={`${inputCls} col-span-2 sm:col-span-1`}
|
| 177 |
+
/>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="flex items-center justify-between mt-3">
|
| 180 |
+
{addError && (
|
| 181 |
+
<p class="text-xs text-red-500">{addError}</p>
|
| 182 |
+
)}
|
| 183 |
+
<div class="ml-auto">
|
| 184 |
+
<button
|
| 185 |
+
onClick={handleAdd}
|
| 186 |
+
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors whitespace-nowrap"
|
| 187 |
+
>
|
| 188 |
+
{t("addProxy")}
|
| 189 |
+
</button>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
|
| 195 |
+
{/* Empty state */}
|
| 196 |
+
{proxies.proxies.length === 0 && !showAdd && (
|
| 197 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 text-center text-sm text-slate-500 dark:text-text-dim">
|
| 198 |
+
{t("noProxies")}
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{/* Proxy list */}
|
| 203 |
+
{proxies.proxies.length > 0 && (
|
| 204 |
+
<div class="space-y-2">
|
| 205 |
+
{proxies.proxies.map((proxy) => {
|
| 206 |
+
const [statusCls, statusKey] =
|
| 207 |
+
statusStyles[proxy.status] || statusStyles.disabled;
|
| 208 |
+
const isChecking = checking === proxy.id;
|
| 209 |
+
|
| 210 |
+
return (
|
| 211 |
+
<div
|
| 212 |
+
key={proxy.id}
|
| 213 |
+
class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-3 hover:shadow-sm transition-all"
|
| 214 |
+
>
|
| 215 |
+
<div class="flex items-center justify-between">
|
| 216 |
+
{/* Left: name + url + status */}
|
| 217 |
+
<div class="flex items-center gap-3 min-w-0 flex-1">
|
| 218 |
+
<div class="min-w-0">
|
| 219 |
+
<div class="flex items-center gap-2">
|
| 220 |
+
<span class="font-medium text-sm truncate">
|
| 221 |
+
{proxy.name}
|
| 222 |
+
</span>
|
| 223 |
+
<span
|
| 224 |
+
class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}
|
| 225 |
+
>
|
| 226 |
+
{t(statusKey)}
|
| 227 |
+
</span>
|
| 228 |
+
</div>
|
| 229 |
+
<p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
|
| 230 |
+
{proxy.url}
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
{/* Center: health info */}
|
| 236 |
+
{proxy.health && (
|
| 237 |
+
<div class="hidden sm:flex items-center gap-4 px-4 text-xs text-slate-500 dark:text-text-dim">
|
| 238 |
+
{proxy.health.exitIp && (
|
| 239 |
+
<span>
|
| 240 |
+
{t("exitIp")}: <span class="font-mono font-medium text-slate-700 dark:text-text-main">{proxy.health.exitIp}</span>
|
| 241 |
+
</span>
|
| 242 |
+
)}
|
| 243 |
+
<span>
|
| 244 |
+
{proxy.health.latencyMs}ms
|
| 245 |
+
</span>
|
| 246 |
+
{proxy.health.error && (
|
| 247 |
+
<span class="text-red-500 truncate max-w-[200px]" title={proxy.health.error}>
|
| 248 |
+
{proxy.health.error}
|
| 249 |
+
</span>
|
| 250 |
+
)}
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
+
|
| 254 |
+
{/* Right: actions */}
|
| 255 |
+
<div class="flex items-center gap-1 ml-2">
|
| 256 |
+
<button
|
| 257 |
+
onClick={() => handleCheck(proxy.id)}
|
| 258 |
+
disabled={isChecking}
|
| 259 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
|
| 260 |
+
title={t("checkHealth")}
|
| 261 |
+
>
|
| 262 |
+
{isChecking ? "..." : t("checkHealth")}
|
| 263 |
+
</button>
|
| 264 |
+
{proxy.status === "disabled" ? (
|
| 265 |
+
<button
|
| 266 |
+
onClick={() => proxies.enableProxy(proxy.id)}
|
| 267 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-green-50 dark:hover:bg-green-900/20 text-green-600 transition-colors"
|
| 268 |
+
>
|
| 269 |
+
{t("enableProxy")}
|
| 270 |
+
</button>
|
| 271 |
+
) : (
|
| 272 |
+
<button
|
| 273 |
+
onClick={() => proxies.disableProxy(proxy.id)}
|
| 274 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark text-slate-500 transition-colors"
|
| 275 |
+
>
|
| 276 |
+
{t("disableProxy")}
|
| 277 |
+
</button>
|
| 278 |
+
)}
|
| 279 |
+
<button
|
| 280 |
+
onClick={() => handleDelete(proxy.id)}
|
| 281 |
+
class="p-1 text-slate-400 dark:text-text-dim hover:text-red-500 transition-colors rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
| 282 |
+
title={t("deleteProxy")}
|
| 283 |
+
>
|
| 284 |
+
<svg
|
| 285 |
+
class="size-4"
|
| 286 |
+
viewBox="0 0 24 24"
|
| 287 |
+
fill="none"
|
| 288 |
+
stroke="currentColor"
|
| 289 |
+
stroke-width="1.5"
|
| 290 |
+
>
|
| 291 |
+
<path
|
| 292 |
+
stroke-linecap="round"
|
| 293 |
+
stroke-linejoin="round"
|
| 294 |
+
d="M6 18L18 6M6 6l12 12"
|
| 295 |
+
/>
|
| 296 |
+
</svg>
|
| 297 |
+
</button>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
{/* Mobile health info */}
|
| 302 |
+
{proxy.health && (
|
| 303 |
+
<div class="sm:hidden flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-text-dim">
|
| 304 |
+
{proxy.health.exitIp && (
|
| 305 |
+
<span>IP: <span class="font-mono">{proxy.health.exitIp}</span></span>
|
| 306 |
+
)}
|
| 307 |
+
<span>{proxy.health.latencyMs}ms</span>
|
| 308 |
+
</div>
|
| 309 |
+
)}
|
| 310 |
+
</div>
|
| 311 |
+
);
|
| 312 |
+
})}
|
| 313 |
+
</div>
|
| 314 |
+
)}
|
| 315 |
+
</section>
|
| 316 |
+
);
|
| 317 |
+
}
|
packages/electron/desktop/src/components/RuleAssign.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import type { ProxyEntry } from "@shared/types";
|
| 4 |
+
|
| 5 |
+
interface RuleAssignProps {
|
| 6 |
+
proxies: ProxyEntry[];
|
| 7 |
+
selectedCount: number;
|
| 8 |
+
onAssign: (rule: string, targetProxyIds: string[]) => void;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function RuleAssign({ proxies, selectedCount, onAssign, onClose }: RuleAssignProps) {
|
| 13 |
+
const t = useT();
|
| 14 |
+
const [rule, setRule] = useState("round-robin");
|
| 15 |
+
const [targetIds, setTargetIds] = useState<Set<string>>(
|
| 16 |
+
() => new Set(proxies.filter((p) => p.status === "active").map((p) => p.id)),
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
const toggleTarget = useCallback((id: string) => {
|
| 20 |
+
setTargetIds((prev) => {
|
| 21 |
+
const next = new Set(prev);
|
| 22 |
+
if (next.has(id)) {
|
| 23 |
+
next.delete(id);
|
| 24 |
+
} else {
|
| 25 |
+
next.add(id);
|
| 26 |
+
}
|
| 27 |
+
return next;
|
| 28 |
+
});
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const handleApply = useCallback(() => {
|
| 32 |
+
if (targetIds.size === 0) return;
|
| 33 |
+
onAssign(rule, Array.from(targetIds));
|
| 34 |
+
}, [rule, targetIds, onAssign]);
|
| 35 |
+
|
| 36 |
+
// Also include special values
|
| 37 |
+
const allTargets = [
|
| 38 |
+
{ id: "global", label: t("globalDefault"), status: "active" as const },
|
| 39 |
+
{ id: "direct", label: t("directNoProxy"), status: "active" as const },
|
| 40 |
+
...proxies.map((p) => ({ id: p.id, label: p.name, status: p.status })),
|
| 41 |
+
];
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
| 45 |
+
<div
|
| 46 |
+
class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 w-full max-w-md mx-4 shadow-xl"
|
| 47 |
+
onClick={(e) => e.stopPropagation()}
|
| 48 |
+
>
|
| 49 |
+
<h3 class="text-lg font-semibold mb-1">{t("assignRuleTitle")}</h3>
|
| 50 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mb-4">
|
| 51 |
+
{selectedCount} {t("accountsCount")} {t("selected")}
|
| 52 |
+
</p>
|
| 53 |
+
|
| 54 |
+
{/* Rule selection */}
|
| 55 |
+
<div class="mb-4">
|
| 56 |
+
<label class="text-xs font-medium text-slate-600 dark:text-text-dim block mb-1.5">
|
| 57 |
+
{t("assignRuleTitle")}
|
| 58 |
+
</label>
|
| 59 |
+
<select
|
| 60 |
+
value={rule}
|
| 61 |
+
onChange={(e) => setRule((e.target as HTMLSelectElement).value)}
|
| 62 |
+
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 63 |
+
>
|
| 64 |
+
<option value="round-robin">{t("roundRobinRule")}</option>
|
| 65 |
+
</select>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Target proxies */}
|
| 69 |
+
<div class="mb-4">
|
| 70 |
+
<label class="text-xs font-medium text-slate-600 dark:text-text-dim block mb-1.5">
|
| 71 |
+
{t("ruleTarget")}
|
| 72 |
+
</label>
|
| 73 |
+
<div class="max-h-48 overflow-y-auto border border-gray-100 dark:border-border-dark rounded-lg">
|
| 74 |
+
{allTargets.map((target) => (
|
| 75 |
+
<label
|
| 76 |
+
key={target.id}
|
| 77 |
+
class="flex items-center gap-2 px-3 py-2 border-b border-gray-50 dark:border-border-dark/50 hover:bg-slate-50 dark:hover:bg-border-dark/30 cursor-pointer"
|
| 78 |
+
>
|
| 79 |
+
<input
|
| 80 |
+
type="checkbox"
|
| 81 |
+
checked={targetIds.has(target.id)}
|
| 82 |
+
onChange={() => toggleTarget(target.id)}
|
| 83 |
+
class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary"
|
| 84 |
+
/>
|
| 85 |
+
<span class="text-sm text-slate-700 dark:text-text-main">{target.label}</span>
|
| 86 |
+
{target.status !== "active" && (
|
| 87 |
+
<span class="text-[0.6rem] text-slate-400 dark:text-text-dim">({target.status})</span>
|
| 88 |
+
)}
|
| 89 |
+
</label>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
{/* Actions */}
|
| 95 |
+
<div class="flex items-center justify-end gap-2">
|
| 96 |
+
<button
|
| 97 |
+
onClick={onClose}
|
| 98 |
+
class="px-4 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 99 |
+
>
|
| 100 |
+
{t("cancelBtn")}
|
| 101 |
+
</button>
|
| 102 |
+
<button
|
| 103 |
+
onClick={handleApply}
|
| 104 |
+
disabled={targetIds.size === 0}
|
| 105 |
+
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
|
| 106 |
+
>
|
| 107 |
+
{t("applyBtn")}
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
}
|
packages/electron/desktop/src/components/TestConnection.tsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import { useTestConnection } from "@shared/hooks/use-test-connection";
|
| 4 |
+
import type { DiagnosticCheck, DiagnosticStatus } from "@shared/types";
|
| 5 |
+
|
| 6 |
+
const STATUS_COLORS: Record<DiagnosticStatus, string> = {
|
| 7 |
+
pass: "text-green-600 dark:text-green-400",
|
| 8 |
+
fail: "text-red-500 dark:text-red-400",
|
| 9 |
+
skip: "text-slate-400 dark:text-text-dim",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const STATUS_BG: Record<DiagnosticStatus, string> = {
|
| 13 |
+
pass: "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
|
| 14 |
+
fail: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
|
| 15 |
+
skip: "bg-slate-50 dark:bg-[#161b22] border-slate-200 dark:border-border-dark",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const CHECK_NAME_KEYS: Record<string, string> = {
|
| 19 |
+
server: "checkServer",
|
| 20 |
+
accounts: "checkAccounts",
|
| 21 |
+
transport: "checkTransport",
|
| 22 |
+
upstream: "checkUpstream",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const STATUS_KEYS: Record<DiagnosticStatus, string> = {
|
| 26 |
+
pass: "statusPass",
|
| 27 |
+
fail: "statusFail",
|
| 28 |
+
skip: "statusSkip",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
function StatusIcon({ status }: { status: DiagnosticStatus }) {
|
| 32 |
+
if (status === "pass") {
|
| 33 |
+
return (
|
| 34 |
+
<svg class="size-5 text-green-600 dark:text-green-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 35 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
if (status === "fail") {
|
| 40 |
+
return (
|
| 41 |
+
<svg class="size-5 text-red-500 dark:text-red-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 42 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 43 |
+
</svg>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
return (
|
| 47 |
+
<svg class="size-5 text-slate-400 dark:text-text-dim shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 48 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 49 |
+
</svg>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function CheckRow({ check }: { check: DiagnosticCheck }) {
|
| 54 |
+
const t = useT();
|
| 55 |
+
const nameKey = CHECK_NAME_KEYS[check.name] ?? check.name;
|
| 56 |
+
const statusKey = STATUS_KEYS[check.status];
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div class={`flex items-start gap-3 p-3 rounded-lg border ${STATUS_BG[check.status]}`}>
|
| 60 |
+
<StatusIcon status={check.status} />
|
| 61 |
+
<div class="flex-1 min-w-0">
|
| 62 |
+
<div class="flex items-center gap-2">
|
| 63 |
+
<span class="text-sm font-semibold text-slate-700 dark:text-text-main">
|
| 64 |
+
{t(nameKey as Parameters<typeof t>[0])}
|
| 65 |
+
</span>
|
| 66 |
+
<span class={`text-xs font-medium ${STATUS_COLORS[check.status]}`}>
|
| 67 |
+
{t(statusKey as Parameters<typeof t>[0])}
|
| 68 |
+
</span>
|
| 69 |
+
{check.latencyMs > 0 && (
|
| 70 |
+
<span class="text-xs text-slate-400 dark:text-text-dim">{check.latencyMs}ms</span>
|
| 71 |
+
)}
|
| 72 |
+
</div>
|
| 73 |
+
{check.detail && (
|
| 74 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mt-0.5 break-all">{check.detail}</p>
|
| 75 |
+
)}
|
| 76 |
+
{check.error && (
|
| 77 |
+
<p class="text-xs text-red-500 dark:text-red-400 mt-0.5 break-all">{check.error}</p>
|
| 78 |
+
)}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function TestConnection() {
|
| 85 |
+
const t = useT();
|
| 86 |
+
const { testing, result, error, runTest } = useTestConnection();
|
| 87 |
+
const [collapsed, setCollapsed] = useState(true);
|
| 88 |
+
|
| 89 |
+
return (
|
| 90 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl shadow-sm transition-colors">
|
| 91 |
+
{/* Header */}
|
| 92 |
+
<button
|
| 93 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 94 |
+
class="w-full flex items-center justify-between p-5 cursor-pointer select-none"
|
| 95 |
+
>
|
| 96 |
+
<div class="flex items-center gap-2">
|
| 97 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 98 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
| 99 |
+
</svg>
|
| 100 |
+
<h2 class="text-[0.95rem] font-bold">{t("testConnection")}</h2>
|
| 101 |
+
{result && !collapsed && (
|
| 102 |
+
<span class={`text-xs font-medium ml-1 ${result.overall === "pass" ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
|
| 103 |
+
{result.overall === "pass" ? t("testPassed") : t("testFailed")}
|
| 104 |
+
</span>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
<svg class={`size-5 text-slate-400 dark:text-text-dim transition-transform ${collapsed ? "" : "rotate-180"}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 108 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 109 |
+
</svg>
|
| 110 |
+
</button>
|
| 111 |
+
|
| 112 |
+
{/* Content */}
|
| 113 |
+
{!collapsed && (
|
| 114 |
+
<div class="px-5 pb-5 border-t border-slate-100 dark:border-border-dark pt-4">
|
| 115 |
+
{/* Run test button */}
|
| 116 |
+
<button
|
| 117 |
+
onClick={runTest}
|
| 118 |
+
disabled={testing}
|
| 119 |
+
class={`w-full py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
| 120 |
+
testing
|
| 121 |
+
? "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
|
| 122 |
+
: "bg-primary text-white hover:bg-primary/90 cursor-pointer"
|
| 123 |
+
}`}
|
| 124 |
+
>
|
| 125 |
+
{testing ? t("testing") : t("testConnection")}
|
| 126 |
+
</button>
|
| 127 |
+
|
| 128 |
+
{/* Error */}
|
| 129 |
+
{error && (
|
| 130 |
+
<p class="mt-3 text-sm text-red-500">{error}</p>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{/* Results */}
|
| 134 |
+
{result && (
|
| 135 |
+
<div class="mt-4 space-y-2">
|
| 136 |
+
{result.checks.map((check) => (
|
| 137 |
+
<CheckRow key={check.name} check={check} />
|
| 138 |
+
))}
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</section>
|
| 144 |
+
);
|
| 145 |
+
}
|
packages/electron/desktop/src/index.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--primary: 16 162 53;
|
| 7 |
+
--primary-hover: 14 140 46;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.dark {
|
| 11 |
+
--primary: 16 163 127;
|
| 12 |
+
--primary-hover: 14 140 108;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Windows dark mode overrides (macOS values are the Tailwind defaults) */
|
| 16 |
+
.platform-win.dark {
|
| 17 |
+
--bg-dark: #202020;
|
| 18 |
+
--card-dark: #2d2d2d;
|
| 19 |
+
--border-dark: #3d3d3d;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* ── macOS titlebar integration ────────────────────────────────── */
|
| 23 |
+
.platform-mac header {
|
| 24 |
+
-webkit-app-region: drag;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.platform-mac header button,
|
| 28 |
+
.platform-mac header a,
|
| 29 |
+
.platform-mac header input,
|
| 30 |
+
.platform-mac header select {
|
| 31 |
+
-webkit-app-region: no-drag;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Traffic light clearance */
|
| 35 |
+
.platform-mac header > div {
|
| 36 |
+
padding-top: 6px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.platform-mac header > div > div {
|
| 40 |
+
padding-left: 72px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* ── Windows input focus line ──────────────────────────────────── */
|
| 44 |
+
.platform-win input:focus,
|
| 45 |
+
.platform-win select:focus {
|
| 46 |
+
border-bottom: 2px solid rgb(var(--primary));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* ── Scrollbar styling ─────────────────────────────────────────── */
|
| 50 |
+
pre::-webkit-scrollbar { height: 8px; }
|
| 51 |
+
pre::-webkit-scrollbar-track { background: #0d1117; }
|
| 52 |
+
pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
| 53 |
+
pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
|
packages/electron/desktop/src/main.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render } from "preact";
|
| 2 |
+
import { App } from "./App";
|
| 3 |
+
import { applyPlatformClass } from "./platform";
|
| 4 |
+
import "./index.css";
|
| 5 |
+
|
| 6 |
+
applyPlatformClass();
|
| 7 |
+
|
| 8 |
+
render(<App />, document.getElementById("app")!);
|
packages/electron/desktop/src/pages/ProxySettings.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "@shared/i18n/context";
|
| 3 |
+
import { useProxyAssignments } from "@shared/hooks/use-proxy-assignments";
|
| 4 |
+
import { ProxyGroupList } from "../components/ProxyGroupList";
|
| 5 |
+
import { AccountTable } from "../components/AccountTable";
|
| 6 |
+
import { BulkActions } from "../components/BulkActions";
|
| 7 |
+
import { ImportExport } from "../components/ImportExport";
|
| 8 |
+
import { RuleAssign } from "../components/RuleAssign";
|
| 9 |
+
|
| 10 |
+
export function ProxySettings() {
|
| 11 |
+
const t = useT();
|
| 12 |
+
const data = useProxyAssignments();
|
| 13 |
+
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
| 14 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 15 |
+
const [statusFilter, setStatusFilter] = useState("all");
|
| 16 |
+
const [showRuleAssign, setShowRuleAssign] = useState(false);
|
| 17 |
+
|
| 18 |
+
// Single account proxy change (optimistic)
|
| 19 |
+
const handleSingleProxyChange = useCallback(
|
| 20 |
+
async (accountId: string, proxyId: string) => {
|
| 21 |
+
await data.assignBulk([{ accountId, proxyId }]);
|
| 22 |
+
},
|
| 23 |
+
[data.assignBulk],
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
// Bulk assign all selected to a single proxy
|
| 27 |
+
const handleBulkAssign = useCallback(
|
| 28 |
+
async (proxyId: string) => {
|
| 29 |
+
const assignments = Array.from(selectedIds).map((accountId) => ({ accountId, proxyId }));
|
| 30 |
+
await data.assignBulk(assignments);
|
| 31 |
+
setSelectedIds(new Set());
|
| 32 |
+
},
|
| 33 |
+
[selectedIds, data.assignBulk],
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
// Even distribute selected across all active proxies
|
| 37 |
+
const handleEvenDistribute = useCallback(async () => {
|
| 38 |
+
const activeProxies = data.proxies.filter((p) => p.status === "active");
|
| 39 |
+
if (activeProxies.length === 0) return;
|
| 40 |
+
|
| 41 |
+
const ids = Array.from(selectedIds);
|
| 42 |
+
await data.assignRule(ids, "round-robin", activeProxies.map((p) => p.id));
|
| 43 |
+
setSelectedIds(new Set());
|
| 44 |
+
}, [selectedIds, data.proxies, data.assignRule]);
|
| 45 |
+
|
| 46 |
+
// Rule-based assignment
|
| 47 |
+
const handleRuleAssign = useCallback(
|
| 48 |
+
async (rule: string, targetProxyIds: string[]) => {
|
| 49 |
+
const ids = Array.from(selectedIds);
|
| 50 |
+
await data.assignRule(ids, rule, targetProxyIds);
|
| 51 |
+
setSelectedIds(new Set());
|
| 52 |
+
setShowRuleAssign(false);
|
| 53 |
+
},
|
| 54 |
+
[selectedIds, data.assignRule],
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
if (data.loading) {
|
| 58 |
+
return (
|
| 59 |
+
<div class="flex-grow flex items-center justify-center">
|
| 60 |
+
<div class="text-sm text-slate-400 dark:text-text-dim animate-pulse">Loading...</div>
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div class="flex flex-col flex-grow">
|
| 67 |
+
{/* Top bar */}
|
| 68 |
+
<div class="px-4 md:px-8 lg:px-40 py-4 border-b border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark">
|
| 69 |
+
<div class="flex items-center justify-between max-w-[1200px] mx-auto">
|
| 70 |
+
<div>
|
| 71 |
+
<h2 class="text-lg font-bold">{t("proxySettings")}</h2>
|
| 72 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mt-0.5">
|
| 73 |
+
{t("proxySettingsDesc")}
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
<ImportExport
|
| 77 |
+
onExport={data.exportAssignments}
|
| 78 |
+
onImportPreview={data.importPreview}
|
| 79 |
+
onApplyImport={data.applyImport}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Main content */}
|
| 85 |
+
<div class="flex-grow px-4 md:px-8 lg:px-40 py-6">
|
| 86 |
+
<div class="flex gap-6 max-w-[1200px] mx-auto">
|
| 87 |
+
{/* Left sidebar — proxy groups */}
|
| 88 |
+
<div class="w-56 shrink-0 hidden lg:block">
|
| 89 |
+
<ProxyGroupList
|
| 90 |
+
accounts={data.accounts}
|
| 91 |
+
proxies={data.proxies}
|
| 92 |
+
selectedGroup={selectedGroup}
|
| 93 |
+
onSelectGroup={setSelectedGroup}
|
| 94 |
+
/>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Right panel — account table */}
|
| 98 |
+
<div class="flex-1 min-w-0">
|
| 99 |
+
{/* Mobile group filter */}
|
| 100 |
+
<div class="lg:hidden mb-3">
|
| 101 |
+
<select
|
| 102 |
+
value={selectedGroup ?? "__all__"}
|
| 103 |
+
onChange={(e) => {
|
| 104 |
+
const v = (e.target as HTMLSelectElement).value;
|
| 105 |
+
setSelectedGroup(v === "__all__" ? null : v);
|
| 106 |
+
}}
|
| 107 |
+
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 108 |
+
>
|
| 109 |
+
<option value="__all__">{t("allAccounts")} ({data.accounts.length})</option>
|
| 110 |
+
<option value="global">{t("globalDefault")}</option>
|
| 111 |
+
{data.proxies.map((p) => (
|
| 112 |
+
<option key={p.id} value={p.id}>{p.name}</option>
|
| 113 |
+
))}
|
| 114 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 115 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 116 |
+
</select>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<AccountTable
|
| 120 |
+
accounts={data.accounts}
|
| 121 |
+
proxies={data.proxies}
|
| 122 |
+
selectedIds={selectedIds}
|
| 123 |
+
onSelectionChange={setSelectedIds}
|
| 124 |
+
onSingleProxyChange={handleSingleProxyChange}
|
| 125 |
+
filterGroup={selectedGroup}
|
| 126 |
+
statusFilter={statusFilter}
|
| 127 |
+
onStatusFilterChange={setStatusFilter}
|
| 128 |
+
/>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Bulk actions bar */}
|
| 134 |
+
<BulkActions
|
| 135 |
+
selectedCount={selectedIds.size}
|
| 136 |
+
selectedIds={selectedIds}
|
| 137 |
+
proxies={data.proxies}
|
| 138 |
+
onBulkAssign={handleBulkAssign}
|
| 139 |
+
onEvenDistribute={handleEvenDistribute}
|
| 140 |
+
onOpenRuleAssign={() => setShowRuleAssign(true)}
|
| 141 |
+
/>
|
| 142 |
+
|
| 143 |
+
{/* Rule assign modal */}
|
| 144 |
+
{showRuleAssign && (
|
| 145 |
+
<RuleAssign
|
| 146 |
+
proxies={data.proxies}
|
| 147 |
+
selectedCount={selectedIds.size}
|
| 148 |
+
onAssign={handleRuleAssign}
|
| 149 |
+
onClose={() => setShowRuleAssign(false)}
|
| 150 |
+
/>
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
packages/electron/desktop/src/platform.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Platform = "mac" | "win";
|
| 2 |
+
|
| 3 |
+
export function getPlatform(): Platform {
|
| 4 |
+
const ua = navigator.userAgent.toLowerCase();
|
| 5 |
+
if (ua.includes("macintosh") || ua.includes("mac os")) return "mac";
|
| 6 |
+
return "win";
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/** Apply platform class to <html> element on startup */
|
| 10 |
+
export function applyPlatformClass(): void {
|
| 11 |
+
const platform = getPlatform();
|
| 12 |
+
document.documentElement.classList.add(`platform-${platform}`);
|
| 13 |
+
}
|
packages/electron/desktop/tailwind.config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
content: [
|
| 5 |
+
"./index.html",
|
| 6 |
+
"./src/**/*.{ts,tsx}",
|
| 7 |
+
"../shared/**/*.{ts,tsx}",
|
| 8 |
+
],
|
| 9 |
+
darkMode: "class",
|
| 10 |
+
theme: {
|
| 11 |
+
extend: {
|
| 12 |
+
colors: {
|
| 13 |
+
primary: "rgb(var(--primary) / <alpha-value>)",
|
| 14 |
+
"primary-hover": "rgb(var(--primary-hover) / <alpha-value>)",
|
| 15 |
+
"bg-light": "#f6f8f6",
|
| 16 |
+
"bg-dark": "var(--bg-dark, #0d1117)",
|
| 17 |
+
"card-dark": "var(--card-dark, #161b22)",
|
| 18 |
+
"border-dark": "var(--border-dark, #30363d)",
|
| 19 |
+
"text-main": "#e6edf3",
|
| 20 |
+
"text-dim": "rgb(139 148 158 / <alpha-value>)",
|
| 21 |
+
},
|
| 22 |
+
fontFamily: {
|
| 23 |
+
display: [
|
| 24 |
+
"system-ui",
|
| 25 |
+
"-apple-system",
|
| 26 |
+
"BlinkMacSystemFont",
|
| 27 |
+
"Segoe UI",
|
| 28 |
+
"Roboto",
|
| 29 |
+
"sans-serif",
|
| 30 |
+
],
|
| 31 |
+
mono: [
|
| 32 |
+
"ui-monospace",
|
| 33 |
+
"SFMono-Regular",
|
| 34 |
+
"SF Mono",
|
| 35 |
+
"Menlo",
|
| 36 |
+
"Consolas",
|
| 37 |
+
"Liberation Mono",
|
| 38 |
+
"monospace",
|
| 39 |
+
],
|
| 40 |
+
},
|
| 41 |
+
borderRadius: {
|
| 42 |
+
DEFAULT: "0.25rem",
|
| 43 |
+
lg: "0.5rem",
|
| 44 |
+
xl: "0.75rem",
|
| 45 |
+
full: "9999px",
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
plugins: [],
|
| 50 |
+
} satisfies Config;
|
packages/electron/desktop/vite.config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import preact from "@preact/preset-vite";
|
| 3 |
+
import path from "path";
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [preact()],
|
| 7 |
+
resolve: {
|
| 8 |
+
alias: {
|
| 9 |
+
"preact": path.resolve(__dirname, "node_modules/preact"),
|
| 10 |
+
"preact/hooks": path.resolve(__dirname, "node_modules/preact/hooks"),
|
| 11 |
+
"@shared": path.resolve(__dirname, "..", "..", "..", "shared"),
|
| 12 |
+
},
|
| 13 |
+
},
|
| 14 |
+
base: "/desktop/",
|
| 15 |
+
build: {
|
| 16 |
+
outDir: "../public-desktop",
|
| 17 |
+
emptyOutDir: true,
|
| 18 |
+
},
|
| 19 |
+
server: {
|
| 20 |
+
port: 5174,
|
| 21 |
+
proxy: {
|
| 22 |
+
"/v1": "http://localhost:8080",
|
| 23 |
+
"/auth": "http://localhost:8080",
|
| 24 |
+
"/health": "http://localhost:8080",
|
| 25 |
+
"/debug": "http://localhost:8080",
|
| 26 |
+
"/admin": "http://localhost:8080",
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
});
|
packages/electron/electron-builder.yml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
appId: com.codex-proxy.app
|
| 2 |
+
productName: Codex Proxy
|
| 3 |
+
|
| 4 |
+
publish:
|
| 5 |
+
provider: github
|
| 6 |
+
owner: icebear0828
|
| 7 |
+
repo: codex-proxy
|
| 8 |
+
|
| 9 |
+
directories:
|
| 10 |
+
output: release
|
| 11 |
+
|
| 12 |
+
files:
|
| 13 |
+
- dist-electron/**/*
|
| 14 |
+
- electron/assets/**/*
|
| 15 |
+
- package.json
|
| 16 |
+
- "!node_modules"
|
| 17 |
+
- node_modules/koffi/**
|
| 18 |
+
- "!dist"
|
| 19 |
+
# Include root-level runtime files (resolved relative to packages/electron/)
|
| 20 |
+
- from: ../../config
|
| 21 |
+
to: config
|
| 22 |
+
filter:
|
| 23 |
+
- "**/*"
|
| 24 |
+
- from: ../../public
|
| 25 |
+
to: public
|
| 26 |
+
filter:
|
| 27 |
+
- "**/*"
|
| 28 |
+
- from: ../../public-desktop
|
| 29 |
+
to: public-desktop
|
| 30 |
+
filter:
|
| 31 |
+
- "**/*"
|
| 32 |
+
|
| 33 |
+
# Static assets unpacked from asar (filesystem access at runtime)
|
| 34 |
+
asarUnpack:
|
| 35 |
+
- "config/**/*"
|
| 36 |
+
- "public/**/*"
|
| 37 |
+
- "public-desktop/**/*"
|
| 38 |
+
- "node_modules/koffi/**"
|
| 39 |
+
- "**/*.node"
|
| 40 |
+
|
| 41 |
+
# curl-impersonate binaries as extra resources (outside asar)
|
| 42 |
+
extraResources:
|
| 43 |
+
- from: ../../bin/
|
| 44 |
+
to: bin/
|
| 45 |
+
filter:
|
| 46 |
+
- "**/*"
|
| 47 |
+
|
| 48 |
+
win:
|
| 49 |
+
target:
|
| 50 |
+
- target: nsis
|
| 51 |
+
arch: [x64]
|
| 52 |
+
icon: electron/assets/icon.png
|
| 53 |
+
signAndEditExecutable: false
|
| 54 |
+
|
| 55 |
+
nsis:
|
| 56 |
+
oneClick: false
|
| 57 |
+
allowToChangeInstallationDirectory: true
|
| 58 |
+
perMachine: false
|
| 59 |
+
|
| 60 |
+
mac:
|
| 61 |
+
target:
|
| 62 |
+
- target: dmg
|
| 63 |
+
arch: [arm64, x64]
|
| 64 |
+
- target: zip
|
| 65 |
+
arch: [arm64, x64]
|
| 66 |
+
icon: electron/assets/icon.png
|
| 67 |
+
category: public.app-category.developer-tools
|
| 68 |
+
# No Apple Developer certificate — electron-builder auto-falls back to
|
| 69 |
+
# ad-hoc signing (codesign -s -). Users see "unidentified developer"
|
| 70 |
+
# warning and can right-click → Open to bypass.
|
| 71 |
+
|
| 72 |
+
linux:
|
| 73 |
+
target:
|
| 74 |
+
- target: AppImage
|
| 75 |
+
arch: [x64]
|
| 76 |
+
icon: electron/assets/icon.png
|
| 77 |
+
category: Development
|
packages/electron/electron/assets/icon.png
ADDED
|
|
packages/electron/electron/auto-updater.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Electron auto-updater — checks GitHub Releases for new versions,
|
| 3 |
+
* downloads updates, and installs them on quit.
|
| 4 |
+
*
|
| 5 |
+
* Supports Windows (NSIS), macOS (zip), and Linux (AppImage).
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { autoUpdater, type UpdateInfo, type ProgressInfo } from "electron-updater";
|
| 9 |
+
import { BrowserWindow, dialog } from "electron";
|
| 10 |
+
|
| 11 |
+
export interface AutoUpdateState {
|
| 12 |
+
checking: boolean;
|
| 13 |
+
updateAvailable: boolean;
|
| 14 |
+
downloading: boolean;
|
| 15 |
+
downloaded: boolean;
|
| 16 |
+
progress: number;
|
| 17 |
+
version: string | null;
|
| 18 |
+
error: string | null;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface AutoUpdaterOptions {
|
| 22 |
+
getMainWindow: () => BrowserWindow | null;
|
| 23 |
+
rebuildTrayMenu: () => void;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const state: AutoUpdateState = {
|
| 27 |
+
checking: false,
|
| 28 |
+
updateAvailable: false,
|
| 29 |
+
downloading: false,
|
| 30 |
+
downloaded: false,
|
| 31 |
+
progress: 0,
|
| 32 |
+
version: null,
|
| 33 |
+
error: null,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
| 37 |
+
const INITIAL_DELAY_MS = 30_000; // 30 seconds after startup
|
| 38 |
+
|
| 39 |
+
let checkTimer: ReturnType<typeof setInterval> | null = null;
|
| 40 |
+
let initialTimer: ReturnType<typeof setTimeout> | null = null;
|
| 41 |
+
let dismissedVersion: string | null = null;
|
| 42 |
+
|
| 43 |
+
export function getAutoUpdateState(): AutoUpdateState {
|
| 44 |
+
return { ...state };
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function initAutoUpdater(options: AutoUpdaterOptions): void {
|
| 48 |
+
autoUpdater.autoDownload = false;
|
| 49 |
+
autoUpdater.autoInstallOnAppQuit = true;
|
| 50 |
+
autoUpdater.allowPrerelease = false;
|
| 51 |
+
|
| 52 |
+
autoUpdater.on("checking-for-update", () => {
|
| 53 |
+
state.checking = true;
|
| 54 |
+
state.error = null;
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
autoUpdater.on("update-available", (info: UpdateInfo) => {
|
| 58 |
+
state.checking = false;
|
| 59 |
+
state.updateAvailable = true;
|
| 60 |
+
state.version = info.version;
|
| 61 |
+
options.rebuildTrayMenu();
|
| 62 |
+
|
| 63 |
+
// Don't re-prompt if user already dismissed this version
|
| 64 |
+
if (info.version === dismissedVersion) return;
|
| 65 |
+
|
| 66 |
+
const win = options.getMainWindow();
|
| 67 |
+
const msgOptions = {
|
| 68 |
+
type: "info" as const,
|
| 69 |
+
title: "Update Available",
|
| 70 |
+
message: `A new version (v${info.version}) is available.`,
|
| 71 |
+
detail: "Would you like to download it now?",
|
| 72 |
+
buttons: ["Download", "Later"],
|
| 73 |
+
defaultId: 0,
|
| 74 |
+
};
|
| 75 |
+
const promise = win
|
| 76 |
+
? dialog.showMessageBox(win, msgOptions)
|
| 77 |
+
: dialog.showMessageBox(msgOptions);
|
| 78 |
+
promise.then(({ response }) => {
|
| 79 |
+
if (response === 0) {
|
| 80 |
+
autoUpdater.downloadUpdate().catch((err: unknown) => {
|
| 81 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 82 |
+
console.error("[AutoUpdater] Download failed:", msg);
|
| 83 |
+
});
|
| 84 |
+
} else {
|
| 85 |
+
dismissedVersion = info.version;
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
autoUpdater.on("update-not-available", () => {
|
| 91 |
+
state.checking = false;
|
| 92 |
+
state.updateAvailable = false;
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
autoUpdater.on("download-progress", (progress: ProgressInfo) => {
|
| 96 |
+
state.downloading = true;
|
| 97 |
+
const rounded = Math.round(progress.percent);
|
| 98 |
+
// Throttle tray rebuilds to every 10% increment
|
| 99 |
+
if (rounded - state.progress >= 10 || rounded === 100) {
|
| 100 |
+
state.progress = rounded;
|
| 101 |
+
options.rebuildTrayMenu();
|
| 102 |
+
} else {
|
| 103 |
+
state.progress = rounded;
|
| 104 |
+
}
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
autoUpdater.on("update-downloaded", (info: UpdateInfo) => {
|
| 108 |
+
state.downloading = false;
|
| 109 |
+
state.downloaded = true;
|
| 110 |
+
state.progress = 100;
|
| 111 |
+
options.rebuildTrayMenu();
|
| 112 |
+
|
| 113 |
+
const win = options.getMainWindow();
|
| 114 |
+
const readyOptions = {
|
| 115 |
+
type: "info" as const,
|
| 116 |
+
title: "Update Ready",
|
| 117 |
+
message: `Version ${info.version} has been downloaded.`,
|
| 118 |
+
detail: "The update will be installed when you quit the app. Restart now?",
|
| 119 |
+
buttons: ["Restart Now", "Later"],
|
| 120 |
+
defaultId: 0,
|
| 121 |
+
};
|
| 122 |
+
const readyPromise = win
|
| 123 |
+
? dialog.showMessageBox(win, readyOptions)
|
| 124 |
+
: dialog.showMessageBox(readyOptions);
|
| 125 |
+
readyPromise.then(({ response }) => {
|
| 126 |
+
if (response === 0) {
|
| 127 |
+
autoUpdater.quitAndInstall(false, true);
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
autoUpdater.on("error", (err: Error) => {
|
| 133 |
+
state.checking = false;
|
| 134 |
+
state.downloading = false;
|
| 135 |
+
state.error = err.message;
|
| 136 |
+
console.error("[AutoUpdater] Error:", err.message);
|
| 137 |
+
options.rebuildTrayMenu();
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
// Initial check after delay
|
| 141 |
+
initialTimer = setTimeout(() => {
|
| 142 |
+
autoUpdater.checkForUpdates().catch((err: Error) => {
|
| 143 |
+
console.warn("[AutoUpdater] Initial check failed:", err.message);
|
| 144 |
+
});
|
| 145 |
+
}, INITIAL_DELAY_MS);
|
| 146 |
+
|
| 147 |
+
// Periodic check
|
| 148 |
+
checkTimer = setInterval(() => {
|
| 149 |
+
autoUpdater.checkForUpdates().catch((err: Error) => {
|
| 150 |
+
console.warn("[AutoUpdater] Periodic check failed:", err.message);
|
| 151 |
+
});
|
| 152 |
+
}, CHECK_INTERVAL_MS);
|
| 153 |
+
if (checkTimer.unref) checkTimer.unref();
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
export function checkForUpdateManual(): void {
|
| 157 |
+
autoUpdater.checkForUpdates().catch((err: Error) => {
|
| 158 |
+
console.warn("[AutoUpdater] Manual check failed:", err.message);
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export function downloadUpdate(): void {
|
| 163 |
+
autoUpdater.downloadUpdate().catch((err: Error) => {
|
| 164 |
+
console.warn("[AutoUpdater] Download failed:", err.message);
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
export function installUpdate(): void {
|
| 169 |
+
autoUpdater.quitAndInstall(false, true);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
export function stopAutoUpdater(): void {
|
| 173 |
+
if (initialTimer) {
|
| 174 |
+
clearTimeout(initialTimer);
|
| 175 |
+
initialTimer = null;
|
| 176 |
+
}
|
| 177 |
+
if (checkTimer) {
|
| 178 |
+
clearInterval(checkTimer);
|
| 179 |
+
checkTimer = null;
|
| 180 |
+
}
|
| 181 |
+
}
|
packages/electron/electron/build.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* esbuild script — bundles Electron main + backend server.
|
| 3 |
+
*
|
| 4 |
+
* Output:
|
| 5 |
+
* dist-electron/main.cjs — Electron main process (CJS)
|
| 6 |
+
* dist-electron/server.mjs — Backend server bundle (ESM, all deps included)
|
| 7 |
+
*
|
| 8 |
+
* The server bundle eliminates the need for node_modules at runtime,
|
| 9 |
+
* solving the ESM+asar module resolution issue.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { build } from "esbuild";
|
| 13 |
+
|
| 14 |
+
// 1. Electron main process → CJS (loaded by Electron directly)
|
| 15 |
+
await build({
|
| 16 |
+
entryPoints: ["electron/main.ts"],
|
| 17 |
+
bundle: true,
|
| 18 |
+
platform: "node",
|
| 19 |
+
format: "cjs",
|
| 20 |
+
outfile: "dist-electron/main.cjs",
|
| 21 |
+
external: ["electron"],
|
| 22 |
+
target: "node20",
|
| 23 |
+
sourcemap: true,
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
console.log("[esbuild] dist-electron/main.cjs built successfully");
|
| 27 |
+
|
| 28 |
+
// 2. Backend server → ESM (dynamically imported by main.cjs)
|
| 29 |
+
// All npm deps (hono, zod, js-yaml, etc.) are bundled in.
|
| 30 |
+
// Only Node builtins and optional native modules are external.
|
| 31 |
+
await build({
|
| 32 |
+
entryPoints: ["src/electron-entry.ts"],
|
| 33 |
+
bundle: true,
|
| 34 |
+
platform: "node",
|
| 35 |
+
format: "esm",
|
| 36 |
+
outfile: "dist-electron/server.mjs",
|
| 37 |
+
external: ["koffi"],
|
| 38 |
+
target: "node20",
|
| 39 |
+
sourcemap: true,
|
| 40 |
+
// Mark .node files as external (native addons)
|
| 41 |
+
loader: { ".node": "empty" },
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
console.log("[esbuild] dist-electron/server.mjs built successfully");
|
packages/electron/electron/main.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Electron main process for Codex Proxy desktop app.
|
| 3 |
+
*
|
| 4 |
+
* Built by esbuild into dist-electron/main.cjs (CJS format).
|
| 5 |
+
* Loads the backend ESM modules from asarUnpack (real filesystem paths).
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { app, BrowserWindow, Tray, Menu, shell, nativeImage, dialog } from "electron";
|
| 9 |
+
import { resolve, join } from "path";
|
| 10 |
+
import { pathToFileURL } from "url";
|
| 11 |
+
import { existsSync, mkdirSync } from "fs";
|
| 12 |
+
import {
|
| 13 |
+
initAutoUpdater,
|
| 14 |
+
getAutoUpdateState,
|
| 15 |
+
checkForUpdateManual,
|
| 16 |
+
installUpdate,
|
| 17 |
+
downloadUpdate,
|
| 18 |
+
stopAutoUpdater,
|
| 19 |
+
} from "./auto-updater.js";
|
| 20 |
+
|
| 21 |
+
const IS_MAC = process.platform === "darwin";
|
| 22 |
+
|
| 23 |
+
let mainWindow: BrowserWindow | null = null;
|
| 24 |
+
let tray: Tray | null = null;
|
| 25 |
+
let serverHandle: { close: () => Promise<void>; port: number } | null = null;
|
| 26 |
+
let isQuitting = false;
|
| 27 |
+
|
| 28 |
+
// Single instance lock
|
| 29 |
+
const gotLock = app.requestSingleInstanceLock();
|
| 30 |
+
if (!gotLock) {
|
| 31 |
+
app.quit();
|
| 32 |
+
} else {
|
| 33 |
+
app.on("second-instance", () => {
|
| 34 |
+
if (mainWindow) {
|
| 35 |
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
| 36 |
+
mainWindow.show();
|
| 37 |
+
mainWindow.focus();
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// ── macOS application menu ──────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
function setupAppMenu(): void {
|
| 45 |
+
if (!IS_MAC) return;
|
| 46 |
+
|
| 47 |
+
const template: Electron.MenuItemConstructorOptions[] = [
|
| 48 |
+
{
|
| 49 |
+
label: app.name,
|
| 50 |
+
submenu: [
|
| 51 |
+
{ role: "about" },
|
| 52 |
+
{ type: "separator" },
|
| 53 |
+
{ role: "hide" },
|
| 54 |
+
{ role: "hideOthers" },
|
| 55 |
+
{ role: "unhide" },
|
| 56 |
+
{ type: "separator" },
|
| 57 |
+
{ role: "quit" },
|
| 58 |
+
],
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
label: "Edit",
|
| 62 |
+
submenu: [
|
| 63 |
+
{ role: "undo" },
|
| 64 |
+
{ role: "redo" },
|
| 65 |
+
{ type: "separator" },
|
| 66 |
+
{ role: "cut" },
|
| 67 |
+
{ role: "copy" },
|
| 68 |
+
{ role: "paste" },
|
| 69 |
+
{ role: "selectAll" },
|
| 70 |
+
],
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
label: "View",
|
| 74 |
+
submenu: [
|
| 75 |
+
{ role: "reload" },
|
| 76 |
+
{ role: "forceReload" },
|
| 77 |
+
{ role: "toggleDevTools" },
|
| 78 |
+
{ type: "separator" },
|
| 79 |
+
{ role: "resetZoom" },
|
| 80 |
+
{ role: "zoomIn" },
|
| 81 |
+
{ role: "zoomOut" },
|
| 82 |
+
{ type: "separator" },
|
| 83 |
+
{ role: "togglefullscreen" },
|
| 84 |
+
],
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
label: "Window",
|
| 88 |
+
submenu: [
|
| 89 |
+
{ role: "minimize" },
|
| 90 |
+
{ role: "zoom" },
|
| 91 |
+
{ type: "separator" },
|
| 92 |
+
{ role: "front" },
|
| 93 |
+
],
|
| 94 |
+
},
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ── App ready ────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
app.on("ready", async () => {
|
| 103 |
+
setupAppMenu();
|
| 104 |
+
|
| 105 |
+
try {
|
| 106 |
+
// 1. Determine paths — must happen before importing backend
|
| 107 |
+
const userData = app.getPath("userData");
|
| 108 |
+
const dataDir = resolve(userData, "data");
|
| 109 |
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
| 110 |
+
|
| 111 |
+
const appRoot = app.getAppPath();
|
| 112 |
+
const distRoot = app.isPackaged
|
| 113 |
+
? resolve(process.resourcesPath, "app.asar.unpacked")
|
| 114 |
+
: appRoot;
|
| 115 |
+
|
| 116 |
+
const binDir = app.isPackaged
|
| 117 |
+
? resolve(process.resourcesPath, "bin")
|
| 118 |
+
: resolve(appRoot, "bin");
|
| 119 |
+
|
| 120 |
+
// 2. Import the bundled backend server (single ESM file, no node_modules needed)
|
| 121 |
+
const serverUrl = pathToFileURL(resolve(appRoot, "dist-electron", "server.mjs")).href;
|
| 122 |
+
const { setPaths, startServer } = await import(serverUrl);
|
| 123 |
+
|
| 124 |
+
// 3. Set paths before starting the server
|
| 125 |
+
setPaths({
|
| 126 |
+
rootDir: appRoot,
|
| 127 |
+
configDir: resolve(distRoot, "config"),
|
| 128 |
+
dataDir,
|
| 129 |
+
binDir,
|
| 130 |
+
publicDir: resolve(distRoot, "public"),
|
| 131 |
+
desktopPublicDir: resolve(distRoot, "public-desktop"),
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// 4. Start the proxy server (try configured port first, fall back to random if occupied)
|
| 135 |
+
try {
|
| 136 |
+
serverHandle = await startServer({ host: "127.0.0.1" });
|
| 137 |
+
} catch {
|
| 138 |
+
console.warn("[Electron] Default port in use, using random port");
|
| 139 |
+
serverHandle = await startServer({ host: "127.0.0.1", port: 0 });
|
| 140 |
+
}
|
| 141 |
+
console.log(`[Electron] Server started on port ${serverHandle.port}`);
|
| 142 |
+
|
| 143 |
+
// 4. System tray
|
| 144 |
+
createTray();
|
| 145 |
+
|
| 146 |
+
// 5. Main window
|
| 147 |
+
createWindow();
|
| 148 |
+
|
| 149 |
+
// 6. Auto-updater (only in packaged mode)
|
| 150 |
+
if (app.isPackaged) {
|
| 151 |
+
initAutoUpdater({
|
| 152 |
+
getMainWindow: () => mainWindow,
|
| 153 |
+
rebuildTrayMenu,
|
| 154 |
+
});
|
| 155 |
+
}
|
| 156 |
+
} catch (err) {
|
| 157 |
+
console.error("[Electron] Startup failed:", err);
|
| 158 |
+
dialog.showErrorBox(
|
| 159 |
+
"Codex Proxy - Startup Error",
|
| 160 |
+
`Failed to start:\n\n${err instanceof Error ? err.stack ?? err.message : String(err)}`,
|
| 161 |
+
);
|
| 162 |
+
app.quit();
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
// ── Window ───────────────────────────────────────────────────────────
|
| 167 |
+
|
| 168 |
+
function createWindow(): void {
|
| 169 |
+
if (mainWindow) {
|
| 170 |
+
mainWindow.show();
|
| 171 |
+
mainWindow.focus();
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
mainWindow = new BrowserWindow({
|
| 176 |
+
width: 1100,
|
| 177 |
+
height: 750,
|
| 178 |
+
minWidth: 680,
|
| 179 |
+
minHeight: 500,
|
| 180 |
+
title: "Codex Proxy",
|
| 181 |
+
// macOS: native hidden titlebar with traffic lights inset into content
|
| 182 |
+
...(IS_MAC
|
| 183 |
+
? {
|
| 184 |
+
titleBarStyle: "hiddenInset",
|
| 185 |
+
trafficLightPosition: { x: 16, y: 18 },
|
| 186 |
+
}
|
| 187 |
+
: {}),
|
| 188 |
+
webPreferences: {
|
| 189 |
+
nodeIntegration: false,
|
| 190 |
+
contextIsolation: true,
|
| 191 |
+
},
|
| 192 |
+
show: false,
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
const port = serverHandle?.port ?? 8080;
|
| 196 |
+
mainWindow.loadURL(`http://127.0.0.1:${port}/desktop`);
|
| 197 |
+
|
| 198 |
+
// Mark <html> with platform class so frontend CSS can adapt
|
| 199 |
+
mainWindow.webContents.on("did-finish-load", () => {
|
| 200 |
+
const legacy = IS_MAC ? "electron-mac" : "electron-win";
|
| 201 |
+
const platform = IS_MAC ? "platform-mac" : "platform-win";
|
| 202 |
+
mainWindow?.webContents.executeJavaScript(
|
| 203 |
+
`document.documentElement.classList.add("electron","${legacy}","${platform}")`,
|
| 204 |
+
);
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
mainWindow.once("ready-to-show", () => {
|
| 208 |
+
mainWindow?.show();
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// Close → hide to tray instead of quitting (unless app is quitting)
|
| 212 |
+
mainWindow.on("close", (e) => {
|
| 213 |
+
if (!isQuitting) {
|
| 214 |
+
e.preventDefault();
|
| 215 |
+
mainWindow?.hide();
|
| 216 |
+
}
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
// Open external links in the default browser
|
| 220 |
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
| 221 |
+
shell.openExternal(url);
|
| 222 |
+
return { action: "deny" };
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
mainWindow.on("closed", () => {
|
| 226 |
+
mainWindow = null;
|
| 227 |
+
});
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// ── Tray ─────────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
function buildTrayMenu(): Electron.MenuItemConstructorOptions[] {
|
| 233 |
+
const items: Electron.MenuItemConstructorOptions[] = [
|
| 234 |
+
{
|
| 235 |
+
label: "Open Dashboard",
|
| 236 |
+
click: () => createWindow(),
|
| 237 |
+
},
|
| 238 |
+
{ type: "separator" },
|
| 239 |
+
{
|
| 240 |
+
label: `Port: ${serverHandle?.port ?? 8080}`,
|
| 241 |
+
enabled: false,
|
| 242 |
+
},
|
| 243 |
+
{ type: "separator" },
|
| 244 |
+
];
|
| 245 |
+
|
| 246 |
+
// Auto-update menu items
|
| 247 |
+
const updateState = getAutoUpdateState();
|
| 248 |
+
if (updateState.downloaded) {
|
| 249 |
+
items.push({
|
| 250 |
+
label: `Install Update (v${updateState.version})`,
|
| 251 |
+
click: () => installUpdate(),
|
| 252 |
+
});
|
| 253 |
+
} else if (updateState.downloading) {
|
| 254 |
+
items.push({
|
| 255 |
+
label: `Downloading Update... ${updateState.progress}%`,
|
| 256 |
+
enabled: false,
|
| 257 |
+
});
|
| 258 |
+
} else if (updateState.updateAvailable) {
|
| 259 |
+
items.push({
|
| 260 |
+
label: `Download Update (v${updateState.version})`,
|
| 261 |
+
click: () => downloadUpdate(),
|
| 262 |
+
});
|
| 263 |
+
} else {
|
| 264 |
+
items.push({
|
| 265 |
+
label: "Check for Updates",
|
| 266 |
+
click: () => checkForUpdateManual(),
|
| 267 |
+
});
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
items.push(
|
| 271 |
+
{ type: "separator" },
|
| 272 |
+
{
|
| 273 |
+
label: "Quit",
|
| 274 |
+
click: () => {
|
| 275 |
+
isQuitting = true;
|
| 276 |
+
|
| 277 |
+
if (serverHandle) {
|
| 278 |
+
const forceQuit = setTimeout(() => {
|
| 279 |
+
console.error("[Electron] Server close timeout, forcing exit");
|
| 280 |
+
app.exit(0);
|
| 281 |
+
}, 5000);
|
| 282 |
+
|
| 283 |
+
serverHandle.close()
|
| 284 |
+
.then(() => { clearTimeout(forceQuit); app.quit(); })
|
| 285 |
+
.catch((err: unknown) => {
|
| 286 |
+
console.error("[Electron] Server close error:", err);
|
| 287 |
+
clearTimeout(forceQuit);
|
| 288 |
+
app.quit();
|
| 289 |
+
});
|
| 290 |
+
} else {
|
| 291 |
+
app.quit();
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
},
|
| 295 |
+
);
|
| 296 |
+
|
| 297 |
+
return items;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function rebuildTrayMenu(): void {
|
| 301 |
+
if (tray) {
|
| 302 |
+
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenu()));
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
function createTray(): void {
|
| 307 |
+
// In packaged mode: icon is inside asar at {app.asar}/electron/assets/icon.png
|
| 308 |
+
// In dev mode: relative to dist-electron/ → ../electron/assets/icon.png
|
| 309 |
+
const iconPath = app.isPackaged
|
| 310 |
+
? join(app.getAppPath(), "electron", "assets", "icon.png")
|
| 311 |
+
: join(__dirname, "..", "electron", "assets", "icon.png");
|
| 312 |
+
let icon = existsSync(iconPath)
|
| 313 |
+
? nativeImage.createFromPath(iconPath)
|
| 314 |
+
: nativeImage.createEmpty();
|
| 315 |
+
|
| 316 |
+
// macOS: resize to 18x18 and mark as template for automatic dark/light adaptation
|
| 317 |
+
if (IS_MAC && !icon.isEmpty()) {
|
| 318 |
+
icon = icon.resize({ width: 18, height: 18 });
|
| 319 |
+
icon.setTemplateImage(true);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
tray = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
| 323 |
+
tray.setToolTip("Codex Proxy");
|
| 324 |
+
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenu()));
|
| 325 |
+
tray.on("double-click", () => createWindow());
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// macOS: re-create window when dock icon is clicked
|
| 329 |
+
app.on("activate", () => {
|
| 330 |
+
createWindow();
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
// Allow quit from macOS dock/menu bar and system shutdown
|
| 334 |
+
app.on("before-quit", () => {
|
| 335 |
+
isQuitting = true;
|
| 336 |
+
stopAutoUpdater();
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
// Prevent app from quitting when all windows are closed (tray keeps it alive)
|
| 340 |
+
app.on("window-all-closed", () => {
|
| 341 |
+
// Do nothing — tray keeps the app running
|
| 342 |
+
});
|
packages/electron/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@codex-proxy/electron",
|
| 3 |
+
"version": "1.0.57",
|
| 4 |
+
"private": true,
|
| 5 |
+
"main": "dist-electron/main.cjs",
|
| 6 |
+
"author": "codex-proxy",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"build": "node electron/build.mjs",
|
| 9 |
+
"dev": "cd ../.. && npm run build && cd packages/electron && npm run build && electron .",
|
| 10 |
+
"pack": "npx electron-builder --config electron-builder.yml",
|
| 11 |
+
"pack:win": "npx electron-builder --config electron-builder.yml --win",
|
| 12 |
+
"pack:mac": "npx electron-builder --config electron-builder.yml --mac",
|
| 13 |
+
"pack:linux": "npx electron-builder --config electron-builder.yml --linux"
|
| 14 |
+
},
|
| 15 |
+
"dependencies": {
|
| 16 |
+
"electron-updater": "^6.3.0"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"electron": "^35.7.5",
|
| 20 |
+
"electron-builder": "^26.0.0",
|
| 21 |
+
"esbuild": "^0.25.12"
|
| 22 |
+
}
|
| 23 |
+
}
|
packages/electron/src/electron-entry.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Barrel entry point for Electron bundling.
|
| 3 |
+
* esbuild bundles this into a single dist-electron/server.mjs with all deps included.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export { setPaths } from "../../../src/paths.js";
|
| 7 |
+
export { startServer } from "../../../src/index.js";
|
| 8 |
+
export type { ServerHandle, StartOptions } from "../../../src/index.js";
|
scripts/setup-curl.ts
CHANGED
|
@@ -59,10 +59,11 @@ function getPlatformInfo(version: string): PlatformInfo {
|
|
| 59 |
}
|
| 60 |
|
| 61 |
if (platform === "win32") {
|
| 62 |
-
// Windows: download libcurl-impersonate package
|
|
|
|
| 63 |
return {
|
| 64 |
assetPattern: /libcurl-impersonate-.*\.x86_64-win32\.tar\.gz/,
|
| 65 |
-
binaryName: "libcurl.dll",
|
| 66 |
destName: "libcurl.dll",
|
| 67 |
};
|
| 68 |
}
|
|
@@ -70,13 +71,18 @@ function getPlatformInfo(version: string): PlatformInfo {
|
|
| 70 |
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
/** Fetch the latest release tag from GitHub. */
|
| 74 |
async function getLatestVersion(): Promise<string> {
|
| 75 |
const apiUrl = `https://api.github.com/repos/${REPO}/releases/latest`;
|
| 76 |
console.log(`[setup] Checking latest release...`);
|
| 77 |
-
const resp = await fetch(apiUrl, {
|
| 78 |
-
headers: { "Accept": "application/vnd.github+json" },
|
| 79 |
-
});
|
| 80 |
if (!resp.ok) {
|
| 81 |
console.warn(`[setup] Could not fetch latest release (${resp.status}), using fallback ${FALLBACK_VERSION}`);
|
| 82 |
return FALLBACK_VERSION;
|
|
@@ -89,7 +95,7 @@ async function getDownloadUrl(info: PlatformInfo, version: string): Promise<stri
|
|
| 89 |
const apiUrl = `https://api.github.com/repos/${REPO}/releases/tags/${version}`;
|
| 90 |
console.log(`[setup] Fetching release info from ${apiUrl}`);
|
| 91 |
|
| 92 |
-
const resp = await fetch(apiUrl);
|
| 93 |
if (!resp.ok) {
|
| 94 |
throw new Error(`GitHub API returned ${resp.status}: ${await resp.text()}`);
|
| 95 |
}
|
|
@@ -131,11 +137,16 @@ function downloadAndExtract(url: string, info: PlatformInfo): void {
|
|
| 131 |
execSync(`curl -L -o "${archivePath}" "${url}"`, { stdio: "inherit" });
|
| 132 |
|
| 133 |
console.log(`[setup] Extracting...`);
|
| 134 |
-
// Windows: tar interprets D: as remote host; --force-local + forward slashes fix this
|
| 135 |
if (process.platform === "win32") {
|
|
|
|
|
|
|
| 136 |
const tarArchive = archivePath.replaceAll("\\", "/");
|
| 137 |
const tarDest = tmpDir.replaceAll("\\", "/");
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
} else {
|
| 140 |
execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "inherit" });
|
| 141 |
}
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
if (platform === "win32") {
|
| 62 |
+
// Windows: download libcurl-impersonate package
|
| 63 |
+
// v1.5+ renamed libcurl.dll → libcurl-impersonate.dll
|
| 64 |
return {
|
| 65 |
assetPattern: /libcurl-impersonate-.*\.x86_64-win32\.tar\.gz/,
|
| 66 |
+
binaryName: "libcurl-impersonate.dll",
|
| 67 |
destName: "libcurl.dll",
|
| 68 |
};
|
| 69 |
}
|
|
|
|
| 71 |
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
| 72 |
}
|
| 73 |
|
| 74 |
+
function githubHeaders(): Record<string, string> {
|
| 75 |
+
const h: Record<string, string> = { "Accept": "application/vnd.github+json" };
|
| 76 |
+
const token = process.env.GITHUB_TOKEN;
|
| 77 |
+
if (token) h["Authorization"] = `Bearer ${token}`;
|
| 78 |
+
return h;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
/** Fetch the latest release tag from GitHub. */
|
| 82 |
async function getLatestVersion(): Promise<string> {
|
| 83 |
const apiUrl = `https://api.github.com/repos/${REPO}/releases/latest`;
|
| 84 |
console.log(`[setup] Checking latest release...`);
|
| 85 |
+
const resp = await fetch(apiUrl, { headers: githubHeaders() });
|
|
|
|
|
|
|
| 86 |
if (!resp.ok) {
|
| 87 |
console.warn(`[setup] Could not fetch latest release (${resp.status}), using fallback ${FALLBACK_VERSION}`);
|
| 88 |
return FALLBACK_VERSION;
|
|
|
|
| 95 |
const apiUrl = `https://api.github.com/repos/${REPO}/releases/tags/${version}`;
|
| 96 |
console.log(`[setup] Fetching release info from ${apiUrl}`);
|
| 97 |
|
| 98 |
+
const resp = await fetch(apiUrl, { headers: githubHeaders() });
|
| 99 |
if (!resp.ok) {
|
| 100 |
throw new Error(`GitHub API returned ${resp.status}: ${await resp.text()}`);
|
| 101 |
}
|
|
|
|
| 137 |
execSync(`curl -L -o "${archivePath}" "${url}"`, { stdio: "inherit" });
|
| 138 |
|
| 139 |
console.log(`[setup] Extracting...`);
|
|
|
|
| 140 |
if (process.platform === "win32") {
|
| 141 |
+
// Windows bsdtar (default on CI) handles D: paths fine without --force-local
|
| 142 |
+
// GNU tar (e.g. Git Bash) needs --force-local; try without first, fallback with
|
| 143 |
const tarArchive = archivePath.replaceAll("\\", "/");
|
| 144 |
const tarDest = tmpDir.replaceAll("\\", "/");
|
| 145 |
+
try {
|
| 146 |
+
execSync(`tar xzf "${tarArchive}" -C "${tarDest}"`, { stdio: "inherit" });
|
| 147 |
+
} catch {
|
| 148 |
+
execSync(`tar xzf "${tarArchive}" --force-local -C "${tarDest}"`, { stdio: "inherit" });
|
| 149 |
+
}
|
| 150 |
} else {
|
| 151 |
execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "inherit" });
|
| 152 |
}
|