icebear0828 commited on
Commit
178e38e
·
1 Parent(s): 59ed94c

refactor: migrate electron from branch to npm workspace

Browse files

Electron 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)

Files changed (40) hide show
  1. .github/workflows/release.yml +24 -42
  2. .github/workflows/sync-electron.yml +0 -146
  3. CHANGELOG.md +5 -0
  4. package-lock.json +0 -0
  5. package.json +3 -0
  6. packages/electron/desktop/index.html +19 -0
  7. packages/electron/desktop/package-lock.json +0 -0
  8. packages/electron/desktop/package.json +20 -0
  9. packages/electron/desktop/postcss.config.cjs +6 -0
  10. packages/electron/desktop/src/App.tsx +136 -0
  11. packages/electron/desktop/src/components/AccountCard.tsx +254 -0
  12. packages/electron/desktop/src/components/AccountList.tsx +72 -0
  13. packages/electron/desktop/src/components/AccountTable.tsx +279 -0
  14. packages/electron/desktop/src/components/AddAccount.tsx +61 -0
  15. packages/electron/desktop/src/components/AnthropicSetup.tsx +78 -0
  16. packages/electron/desktop/src/components/ApiConfig.tsx +224 -0
  17. packages/electron/desktop/src/components/BulkActions.tsx +88 -0
  18. packages/electron/desktop/src/components/CodeExamples.tsx +232 -0
  19. packages/electron/desktop/src/components/CopyButton.tsx +81 -0
  20. packages/electron/desktop/src/components/Footer.tsx +31 -0
  21. packages/electron/desktop/src/components/Header.tsx +133 -0
  22. packages/electron/desktop/src/components/ImportExport.tsx +168 -0
  23. packages/electron/desktop/src/components/ProxyGroupList.tsx +96 -0
  24. packages/electron/desktop/src/components/ProxyPool.tsx +317 -0
  25. packages/electron/desktop/src/components/RuleAssign.tsx +113 -0
  26. packages/electron/desktop/src/components/TestConnection.tsx +145 -0
  27. packages/electron/desktop/src/index.css +53 -0
  28. packages/electron/desktop/src/main.tsx +8 -0
  29. packages/electron/desktop/src/pages/ProxySettings.tsx +154 -0
  30. packages/electron/desktop/src/platform.ts +13 -0
  31. packages/electron/desktop/tailwind.config.ts +50 -0
  32. packages/electron/desktop/vite.config.ts +29 -0
  33. packages/electron/electron-builder.yml +77 -0
  34. packages/electron/electron/assets/icon.png +0 -0
  35. packages/electron/electron/auto-updater.ts +181 -0
  36. packages/electron/electron/build.mjs +44 -0
  37. packages/electron/electron/main.ts +342 -0
  38. packages/electron/package.json +23 -0
  39. packages/electron/src/electron-entry.ts +8 -0
  40. 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)"
11
  required: true
12
  type: string
13
 
@@ -49,7 +49,7 @@ jobs:
49
  node-version: 20
50
  cache: npm
51
 
52
- - name: Install root dependencies
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 app
67
- run: npm run app:build
68
 
69
- - name: Pack (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
70
- run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }} ${{ matrix.arch && format('--{0}', matrix.arch) || '' }} --publish never
71
 
72
- - name: Upload artifacts
73
- uses: actions/upload-artifact@v4
74
- with:
75
- name: release-${{ matrix.artifact }}
76
- path: |
77
- release/*.exe
78
- release/*.dmg
79
- release/*.AppImage
80
- release/*.yml
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
- echo "NOTES<<EOFNOTES"
125
- echo "$BODY"
126
- echo "EOFNOTES"
127
- } >> "$GITHUB_OUTPUT"
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">&middot;</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 (DLL named libcurl.dll)
 
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
- execSync(`tar xzf "${tarArchive}" --force-local -C "${tarDest}"`, { stdio: "inherit" });
 
 
 
 
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
  }