From ed0dd811a8958928a2702839db07444e89f9fdb2 Mon Sep 17 00:00:00 2001 From: vd Date: Mon, 6 Apr 2026 15:32:42 +0300 Subject: [PATCH] feat: account management overhaul + 7 issue resolutions (#147) - Add smart JSON export/import with schema validation and deduplication - Add per-account proxy configurations (Issue #79) - Add advanced account sorting engine (Issues #126 & #99) - Add compact horizontal layout, AI credits, inline model visibility (Issues #145 & #134) - Add desktop push notifications for low quota (Issue #53) - Fix Arch Linux AUR process detection bug (Issue #117) - Default theme set to Dark Mode (it's better) - UI Polishing: Improved sort dropdown and symmetrical dialog layouts - Dev Mode: Resolved port conflicts and single-instance locks - Updated README and README.zh-CN with new features --- .gitignore | 3 + README.md | 7 +- README.zh-CN.md | 7 +- cli/PR_SUMMARY.md | 102 ---- package-lock.json | 11 - package.json | 9 +- src/actions/cloud.ts | 25 + src/components/CloudAccountCard.tsx | 419 +++++++++++++- src/components/CloudAccountList.tsx | 511 +++++++++++++++--- src/components/ProviderGroup.tsx | 6 +- src/components/ui/dropdown-menu.tsx | 47 +- src/hooks/useCloudAccounts.ts | 39 ++ src/ipc/cloud/authServer.ts | 125 +++-- src/ipc/cloud/handler.ts | 205 ++++++- src/ipc/cloud/router.ts | 52 +- src/ipc/database/cloudHandler.ts | 191 ++++--- src/ipc/database/schema.ts | 3 +- src/ipc/process/handler.ts | 43 +- src/lib/antigravity/ModelMapping.ts | 26 +- src/localization/i18n.ts | 128 +++++ src/main.ts | 7 + src/routes/settings.tsx | 82 +++ .../modules/proxy/clients/upstream-error.ts | 18 +- src/server/modules/proxy/proxy.service.ts | 9 +- .../modules/proxy/token-manager.service.ts | 25 +- src/services/CloudMonitorService.ts | 59 +- src/services/GoogleAPIService.ts | 125 ++++- src/tests/unit/parity-rollout-guard.test.ts | 8 +- .../unit/proxy-controller.integration.test.ts | 8 +- src/types/cloudAccount.ts | 24 + src/types/config.ts | 12 +- src/utils/provider-grouping.ts | 5 +- src/utils/quota-display.ts | 28 + src/utils/url.ts | 23 + vite.main.config.mts | 25 +- vite.preload.config.mts | 25 +- vite.renderer.config.mts | 2 +- 37 files changed, 1989 insertions(+), 455 deletions(-) delete mode 100644 cli/PR_SUMMARY.md create mode 100644 src/utils/url.ts diff --git a/.gitignore b/.gitignore index 5288027..c0e3e3d 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ build/ cli/aliases.json *.backup accounts_backup.json + +# Bun lock file +bun.lock \ No newline at end of file diff --git a/README.md b/README.md index aaa7b6c..8acc6a1 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,12 @@ When using Antigravity IDE, have you ever encountered these problems? - **🖥️ System Tray** - Background mode with tray icon and right-click menu - **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb` - **📦 Batch Operations** - Batch refresh and delete multiple accounts -- **🌏 Internationalization** - Multi-language support (English / 中文) +- **📤 JSON Export/Import** - Export and import account pools with schema validation and deduplication +- **🔔 Desktop Notifications** - Configurable alerts when model quota drops below your threshold +- **🌐 Per-Account Proxy** - Route individual accounts through their own HTTP/SOCKS5 proxy +- **📊 Smart Sorting** - Sort accounts by recently used, overall quota, or specific model groups +- **📋 Compact Layout** - Dense horizontal view to maximize visible accounts +- **🌏 Internationalization** - Multi-language support (English / 中文 / Русский) - **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 439e0ff..d3014e4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -158,7 +158,12 @@ - **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单 - **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号 - **📦 批量操作** - 批量刷新和删除多个账号 -- **🌏 国际化** - 多语言支持(English / 中文) +- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重 +- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒 +- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理 +- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序 +- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量 +- **🌏 国际化** - 多语言支持(English / 中文 / Русский) - **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建 --- diff --git a/cli/PR_SUMMARY.md b/cli/PR_SUMMARY.md deleted file mode 100644 index 148bb40..0000000 --- a/cli/PR_SUMMARY.md +++ /dev/null @@ -1,102 +0,0 @@ -# CLI Feature - Pull Request Summary - -## What This PR Adds - -A complete **Command-Line Interface (CLI)** for Antigravity Manager with 18+ commands for power users. - -## Key Features - -- 📊 **Live Quota Monitoring** - Real-time tracking for Gemini & Claude models -- 🔄 **Account Switching** - Seamless switching without manual token management -- ✅ **Token Validation** - Auto-refresh expired tokens -- 🎯 **Smart Auto-Switch** - Automatically pick best account by quota -- 🏷️ **Aliases** - Shortcut names for accounts (`work`, `personal`) -- 💾 **Backup/Restore** - Export/import account configurations -- 🎮 **Interactive Mode** - Keyboard-navigated menus for beginners -- 🩺 **Diagnostics** - System health checks - -## Technical Highlights - -- **Zero Hardcoded Paths**: Intelligently discovers database and executable locations -- **Cross-Platform Ready**: Uses environment variables and checks for Windows/Mac/Linux -- **Secure**: Leverages existing DPAPI + AES-256-GCM encryption (same as GUI) -- **Extensible**: Clean separation between core logic (`core.py`) and commands (`main.py`) - -## File Structure - -``` -cli/ -├── README.md # Full documentation -├── requirements.txt # Python dependencies -├── core.py # Database, encryption, API logic -├── main.py # CLI commands and interactive UI -└── proto_utils.py # Protobuf encoding helpers -``` - -## Usage Examples - -```powershell -# Interactive mode -agm - -# Quick status -agm status - -# Switch accounts -agm switch work - -# Auto-switch to best account -agm auto-switch --model claude --min-quota 50 - -# Live monitoring -agm watch -``` - -## Testing - -- ✅ Tested on Windows 10/11 -- ✅ No hardcoded paths (uses standard Windows env vars) -- ✅ Works with both installed and development builds -- ✅ Database encryption/decryption verified - -## Dependencies - -All Python dependencies are self-contained in `cli/requirements.txt`: -- `typer` - CLI framework -- `rich` - Beautiful terminal output -- `questionary` - Interactive menus -- `pywin32` - Windows DPAPI support -- `cryptography` - AES-256-GCM encryption -- `httpx` - Async HTTP client -- `psutil` - Process management -- `protobuf` - Google API protocol buffers - -## Platform Support - -- **Windows**: Full support (tested) -- **macOS/Linux**: Partial support (no DPAPI, but core features work) - -## Breaking Changes - -None. This is a pure addition—no changes to existing GUI code. - -## Future Enhancements - -- OAuth flow for adding new accounts via CLI -- TUI (Terminal UI) with live dashboard -- Scheduled auto-refresh via Windows Task Scheduler -- Config file for user preferences - -## Checklist - -- [x] Code follows existing style -- [x] All commands have help text -- [x] No hardcoded paths/credentials -- [x] README with examples -- [x] Dependencies in requirements.txt -- [x] .gitignore updated for Python -- [x] Works with fresh installs - ---- - -**Ready to merge!** 🚀 diff --git a/package-lock.json b/package-lock.json index 87732ce..259f583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21015,7 +21015,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21036,7 +21035,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21057,7 +21055,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21078,7 +21075,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21099,7 +21095,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21120,7 +21115,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21141,7 +21135,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21162,7 +21155,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21183,7 +21175,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21204,7 +21195,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -21225,7 +21215,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 2ff22ce..4b58396 100644 --- a/package.json +++ b/package.json @@ -153,5 +153,12 @@ "winston-daily-rotate-file": "^5.0.0", "yaml": "^2.8.2", "zod": "^4.1.12" - } + }, + "trustedDependencies": [ + "@bitdisaster/exe-icon-extractor", + "@nestjs/core", + "@sentry/cli", + "@swc/core", + "protobufjs" + ] } diff --git a/src/actions/cloud.ts b/src/actions/cloud.ts index a65feba..5379bee 100644 --- a/src/actions/cloud.ts +++ b/src/actions/cloud.ts @@ -1,5 +1,6 @@ import { ipc } from '@/ipc/manager'; import type { DeviceProfile } from '@/types/account'; +import { isValidProxyUrl } from '@/utils/url'; export function addGoogleAccount(input: { authCode: string }) { return ipc.client.cloud.addGoogleAccount(input); @@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) { } export function refreshAccountQuota(input: { accountId: string }) { + console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`); return ipc.client.cloud.refreshAccountQuota(input); } @@ -88,3 +90,26 @@ export function deleteCloudIdentityProfileRevision(input: { export function openCloudIdentityStorageFolder() { return ipc.client.cloud.openIdentityStorageFolder(); } + +export function setAccountProxy(input: { accountId: string; proxyUrl: string | null }) { + if (input.proxyUrl && !isValidProxyUrl(input.proxyUrl)) { + throw new Error('Invalid proxy URL format'); + } + return ipc.client.cloud.setAccountProxy(input); +} + +export function exportCloudAccounts(input: { stripTokens?: boolean }) { + return ipc.client.cloud.exportCloudAccounts(input); +} + +export function importCloudAccounts(input: { + jsonContent: string; + strategy?: 'merge' | 'overwrite' | 'skip-existing'; +}) { + try { + JSON.parse(input.jsonContent); + } catch { + throw new Error('Invalid JSON content provided for import'); + } + return ipc.client.cloud.importCloudAccounts(input); +} diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index 43f935a..37af1f8 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -17,8 +17,10 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react'; +import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; @@ -32,6 +34,9 @@ import { getQuotaStatus, type QuotaStatus, } from '@/utils/quota-display'; +import { useState } from 'react'; +import { useSetAccountProxy } from '@/hooks/useCloudAccounts'; +import { isValidProxyUrl } from '@/utils/url'; type ModelQuotaEntry = [string, CloudQuotaModelInfo]; @@ -81,7 +86,9 @@ function isGeminiProFamilyModel(modelName: string): boolean { return normalizedModelName.includes('gemini-3.1-pro'); } -function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record { +function mergeGeminiProQuotaEntries( + entries: ModelQuotaEntry[], +): Record { const mergedModels: Record = {}; const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName)); const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName)); @@ -96,9 +103,18 @@ function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record { const quotaStatus = getQuotaStatus(percentage); @@ -184,6 +203,8 @@ export function CloudAccountCard({ return formatResetTimeTitle(resetTime, t('cloud.card.resetTime')); }; + const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[]; + const visibleModelEntries = Object.entries(account.quota?.models || {}).filter( ([modelName]) => config?.model_visibility?.[modelName] !== false, ) as ModelQuotaEntry[]; @@ -265,7 +286,9 @@ export function CloudAccountCard({
{t('settings.providerGroupings.overall')}
- + {formatQuotaLabel(providerStats.overallPercentage)}
@@ -278,25 +301,40 @@ export function CloudAccountCard({
- {providerStats.providers.map((statsByProvider) => ( - toggleProviderCollapse(account.id, statsByProvider.providerKey)} - getQuotaTextColorClass={getQuotaTextColorClass} - getQuotaBarColorClass={getQuotaBarColorClass} - formatQuotaLabel={formatQuotaLabel} - formatResetTimeLabel={formatResetTimeLabelText} - formatResetTimeTitle={formatResetTimeTitleText} - leftLabel={t('cloud.card.left')} - /> - ))} + +
+ {providerStats.providers.map((group) => ( + toggleProviderCollapse(account.id, group.providerKey)} + getQuotaTextColorClass={getQuotaTextColorClass} + getQuotaBarColorClass={getQuotaBarColorClass} + formatQuotaLabel={formatQuotaLabel} + formatResetTimeLabel={formatResetTimeLabelText} + formatResetTimeTitle={formatResetTimeTitleText} + leftLabel={t('cloud.card.left')} + /> + ))} +
) : ( emptyQuotaState ); + const aiCredits = account.quota?.ai_credits; + + const formatCreditsExpiry = (expiryDate: string) => { + if (!expiryDate) return ''; + try { + const date = new Date(expiryDate); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } catch { + return expiryDate; + } + }; + return ( {account.email} + + {aiCredits && aiCredits.credits > 0 && ( +
+ ${aiCredits.credits.toFixed(2)} + {aiCredits.expiryDate && ( + + ·{' '} + {t('cloud.card.creditsExpiry', { + date: formatCreditsExpiry(aiCredits.expiryDate), + })} + + )} +
+ )} + + {allModelEntries.length > 0 && ( + + + + + + {t('cloud.card.modelVisibility')} +
+ {allModelEntries.map(([modelName]) => { + const isVisible = config?.model_visibility?.[modelName] !== false; + return ( + e.preventDefault()} + className="flex cursor-pointer items-center gap-2" + > + { + if (config) { + const newVisibility = { ...config.model_visibility }; + newVisibility[modelName] = checked as boolean; + saveConfig({ ...config, model_visibility: newVisibility }); + } + }} + /> + + {formatModelDisplayName(modelName)} + + + ); + })} +
+
+
+ )} + + + + {t('cloud.card.modelVisibility')} +
+ {allModelEntries.map(([modelName]) => { + const isVisible = config?.model_visibility?.[modelName] !== false; + return ( + e.preventDefault()} + className="flex cursor-pointer items-center gap-2" + > + { + if (config) { + const newVisibility = { ...config.model_visibility }; + newVisibility[modelName] = checked as boolean; + saveConfig({ ...config, model_visibility: newVisibility }); + } + }} + /> + + {formatModelDisplayName(modelName)} + + + ); + })} +
+
+
+ )} + + {account.is_active ? ( + + ) : ( + + )} + + + + + + + {t('cloud.card.actions')} + onSwitch(account.id)} disabled={isSwitching}> + + {t('cloud.card.useAccount')} + + + onRefresh(account.id)} disabled={isRefreshing}> + + {t('cloud.card.refresh')} + + + onManageIdentity(account.id)}> + + {t('cloud.card.identityProfile')} + + + onDelete(account.id)} + className="text-destructive focus:text-destructive" + disabled={isDeleting} + > + + {t('cloud.card.delete')} + + + + + + ); +} diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index a5c832d..b4bbf1a 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -9,16 +9,23 @@ import { useForcePollCloudMonitor, useSyncLocalAccount, startAuthFlow, + useExportCloudAccounts, + useImportCloudAccounts, } from '@/hooks/useCloudAccounts'; -import { CloudAccountCard } from '@/components/CloudAccountCard'; +import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard'; import { IdentityProfileDialog } from '@/components/IdentityProfileDialog'; import { CloudAccount } from '@/types/cloudAccount'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Plus, - Loader2, Cloud, Zap, RefreshCcw, @@ -31,6 +38,12 @@ import { List, Columns2, Columns3, + SortAsc, + LayoutList, + Upload, + FileDown, + Check, + Loader2, } from 'lucide-react'; import { useToast } from '@/components/ui/use-toast'; import { @@ -49,21 +62,30 @@ import { useTranslation } from 'react-i18next'; import { getLocalizedErrorMessage } from '@/utils/errorMessages'; import { useAppConfig } from '@/hooks/useAppConfig'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es'; import { clampQuotaPercentage, getQuotaStatus, roundQuotaPercentage, + getAccountSortValue, type QuotaStatus, } from '@/utils/quota-display'; -export type GridLayout = 'auto' | '2-col' | '3-col' | 'list'; +export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact'; const GRID_LAYOUT_CLASSES: Record = { auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3', '2-col': 'grid gap-4 grid-cols-2', '3-col': 'grid gap-4 grid-cols-3', list: 'grid gap-4 grid-cols-1', + compact: 'flex flex-col gap-2', }; const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record = { @@ -103,6 +125,43 @@ export function CloudAccountList() { } }; + const sortOptions = [ + 'recently-used', + 'quota-overall', + 'quota-claude', + 'quota-pro3', + 'quota-flash', + ] as const; + type SortOption = (typeof sortOptions)[number]; + const currentSort: SortOption = (config?.account_sort as SortOption) || 'recently-used'; + const sortI18nKeys: Record = { + 'recently-used': 'cloud.sort.recentlyUsed', + 'quota-overall': 'cloud.sort.quotaOverall', + 'quota-claude': 'cloud.sort.quotaClaude', + 'quota-pro3': 'cloud.sort.quotaPro3', + 'quota-flash': 'cloud.sort.quotaFlash', + }; + + const sortedAccounts = useMemo(() => { + if (!accounts || accounts.length === 0) return []; + + const activeAccounts = accounts.filter((a) => a.is_active); + const otherAccounts = accounts.filter((a) => !a.is_active); + + const sortedActive = [...activeAccounts].sort((a, b) => { + return (b.last_used ?? 0) - (a.last_used ?? 0); + }); + + const sortedOthers = [...otherAccounts].sort((a, b) => { + if (currentSort === 'recently-used') { + return (b.last_used ?? 0) - (a.last_used ?? 0); + } + return getAccountSortValue(b, currentSort) - getAccountSortValue(a, currentSort); + }); + + return [...sortedActive, ...sortedOthers]; + }, [accounts, currentSort]); + // Calculate global quota across all accounts const overallQuotaPercentage = useMemo(() => { if (!accounts || accounts.length === 0) { @@ -137,6 +196,16 @@ export function CloudAccountList() { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [authCode, setAuthCode] = useState(''); const [identityAccount, setIdentityAccount] = useState(null); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [importStrategy, setImportStrategy] = useState<'merge' | 'overwrite' | 'skip-existing'>( + 'merge', + ); + const [importFileContent, setImportFileContent] = useState(null); + const [importFileName, setImportFileName] = useState(''); + const fileInputRef = useRef(null); + const exportMutation = useExportCloudAccounts(); + const importMutation = useImportCloudAccounts(); const totalAccounts = size(accounts); const activeAccounts = filter(accounts, (account) => account.is_active).length; const rateLimitedAccounts = filter( @@ -144,29 +213,32 @@ export function CloudAccountList() { (account) => account.status === 'rate_limited', ).length; - const submitAuthCode = useCallback((incomingAuthCode?: string) => { - const codeToUse = incomingAuthCode || authCode; - if (!codeToUse) { - return; - } - addMutation.mutate( - { authCode: codeToUse }, - { - onSuccess: () => { - setIsAddDialogOpen(false); - setAuthCode(''); - toast({ title: t('cloud.toast.addSuccess') }); + const submitAuthCode = useCallback( + (incomingAuthCode?: string) => { + const codeToUse = incomingAuthCode || authCode; + if (!codeToUse) { + return; + } + addMutation.mutate( + { authCode: codeToUse }, + { + onSuccess: () => { + setIsAddDialogOpen(false); + setAuthCode(''); + toast({ title: t('cloud.toast.addSuccess') }); + }, + onError: (err) => { + toast({ + title: t('cloud.toast.addFailed.title'), + description: getLocalizedErrorMessage(err, t), + variant: 'destructive', + }); + }, }, - onError: (err) => { - toast({ - title: t('cloud.toast.addFailed.title'), - description: getLocalizedErrorMessage(err, t), - variant: 'destructive', - }); - }, - }, - ); - }, [addMutation, authCode, t, toast]); + ); + }, + [addMutation, authCode, t, toast], + ); // Listen for Google Auth Code useEffect(() => { if (window.electron?.onGoogleAuthCode) { @@ -204,6 +276,7 @@ export function CloudAccountList() { }, [error, errorUpdatedAt, isError, t, toast]); const handleRefresh = (id: string) => { + console.log(`[Renderer] Triggering refresh for: ${id}`); refreshMutation.mutate( { accountId: id }, { @@ -315,13 +388,119 @@ export function CloudAccountList() { await startAuthFlow(); } catch (e) { toast({ - title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error + title: t('cloud.toast.startAuthFailed'), description: String(e), variant: 'destructive', }); } }; + const handleExport = async (stripTokens: boolean) => { + let url: string | null = null; + try { + const jsonContent: string = await exportMutation.mutateAsync({ stripTokens }); + const blob = new Blob([jsonContent], { type: 'application/json' }); + url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `cloud-accounts-export-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setIsExportDialogOpen(false); + toast({ title: t('cloud.exportImport.exportSuccess') }); + } catch (error) { + toast({ + title: t('cloud.error.loadFailed'), + description: getLocalizedErrorMessage(error, t), + variant: 'destructive', + }); + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } + }; + + const handleImportFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 5 * 1024 * 1024) { + toast({ + title: t('cloud.error.loadFailed'), + description: 'File size exceeds 5MB limit', + variant: 'destructive', + }); + return; + } + + setImportFileName(file.name); + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + JSON.parse(content); + setImportFileContent(content); + } catch (err) { + toast({ + title: t('cloud.error.loadFailed'), + description: 'Invalid JSON file format', + variant: 'destructive', + }); + setImportFileName(''); + setImportFileContent(null); + } + }; + reader.onerror = () => { + toast({ + title: t('cloud.error.loadFailed'), + description: 'Failed to read file', + variant: 'destructive', + }); + }; + reader.readAsText(file); + if (e.target) { + e.target.value = ''; + } + }; + + const handleImport = () => { + if (!importFileContent) return; + importMutation.mutate( + { jsonContent: importFileContent, strategy: importStrategy }, + { + onSuccess: (result) => { + setIsImportDialogOpen(false); + setImportFileContent(null); + setImportFileName(''); + setImportStrategy('merge'); + toast({ + title: t('cloud.exportImport.importSuccess', { + imported: result.imported, + updated: result.updated, + skipped: result.skipped, + }), + }); + if (result.errors.length > 0) { + toast({ + title: t('cloud.exportImport.importErrors', { count: result.errors.length }), + description: result.errors.slice(0, 3).join('\n'), + variant: 'destructive', + }); + } + }, + onError: (err) => { + toast({ + title: t('cloud.error.loadFailed'), + description: getLocalizedErrorMessage(err, t), + variant: 'destructive', + }); + }, + }, + ); + }; + // Batch Selection Handlers const setSelectionState = (id: string, selected: boolean) => { setSelectedIds((prev) => { @@ -343,26 +522,54 @@ export function CloudAccountList() { } }; - const refreshSelectedAccounts = () => { - selectedIds.forEach((id) => { - refreshMutation.mutate({ accountId: id }); - }); - toast({ - title: t('cloud.toast.quotaRefreshed'), - description: `Triggered for ${selectedIds.size} accounts.`, - }); + const refreshSelectedAccounts = async () => { + const ids = Array.from(selectedIds); + const results = await Promise.allSettled( + ids.map((id) => refreshMutation.mutateAsync({ accountId: id })), + ); + + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + if (failed === 0) { + toast({ + title: t('cloud.toast.quotaRefreshed'), + description: `Successfully refreshed ${successful} accounts.`, + }); + } else { + toast({ + title: `Refresh completed with issues`, + description: `Refreshed ${successful} accounts, ${failed} failed.`, + variant: 'destructive', + }); + } + setSelectedIds(new Set()); }; - const deleteSelectedAccounts = () => { + const deleteSelectedAccounts = async () => { if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) { - selectedIds.forEach((id) => { - deleteMutation.mutate({ accountId: id }); - }); - toast({ - title: t('cloud.toast.deleted'), - description: `Deleted ${selectedIds.size} accounts.`, - }); + const ids = Array.from(selectedIds); + const results = await Promise.allSettled( + ids.map((id) => deleteMutation.mutateAsync({ accountId: id })), + ); + + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + if (failed === 0) { + toast({ + title: t('cloud.toast.deleted'), + description: `Successfully deleted ${successful} accounts.`, + }); + } else { + toast({ + title: `Deletion completed with issues`, + description: `Deleted ${successful} accounts, ${failed} failed.`, + variant: 'destructive', + }); + } + setSelectedIds(new Set()); } }; @@ -501,6 +708,117 @@ export function CloudAccountList() { {t('cloud.syncFromIDE')} + + + + + + + {t('cloud.exportImport.exportTitle')} + {t('cloud.exportImport.exportDesc')} + + + + + + + + + { + setIsImportDialogOpen(open); + if (!open) { + setImportFileContent(null); + setImportFileName(''); + setImportStrategy('merge'); + } + }} + > + + + + + + {t('cloud.exportImport.importTitle')} + {t('cloud.exportImport.importDesc')} + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ - + {t('cloud.authDialog.title')} {t('cloud.authDialog.description')} @@ -543,7 +861,35 @@ export function CloudAccountList() { - {/* Layout Selector */} + {/* Sort + Layout Selector */} +
+ + + + + + {sortOptions.map((option) => ( + { + if (config) { + await saveConfig({ ...config, account_sort: option }); + } + }} + > + {currentSort === option && } + + {t(sortI18nKeys[option])} + + + ))} + + +
+
@@ -598,34 +944,67 @@ export function CloudAccountList() { {t('cloud.layout.list')} + + + + + {t('cloud.layout.compact')} +
- {accounts?.map((account) => ( - - ))} + {sortedAccounts.map((account) => + gridLayout === 'compact' ? ( + + ) : ( + + ), + )} - {accounts?.length === 0 && ( + {sortedAccounts.length === 0 && (
{t('cloud.list.noAccounts')}
diff --git a/src/components/ProviderGroup.tsx b/src/components/ProviderGroup.tsx index 50c7d08..5e6e614 100644 --- a/src/components/ProviderGroup.tsx +++ b/src/components/ProviderGroup.tsx @@ -76,10 +76,12 @@ export const ProviderGroup: React.FC = ({ )}
- + {formatQuotaLabel(avgPercentage)} - {avgPercentage > 0 && ( + {Number.isFinite(avgPercentage) && avgPercentage > 0 && ( {t('settings.providerGroupings.avgLabel')} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index af552ea..22bacc7 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); + React.ComponentPropsWithoutRef & { + collisionPadding?: number | Partial>; + } +>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => { + if (typeof collisionPadding === 'number') { + if (collisionPadding < 0) { + throw new Error('collisionPadding must be a non-negative number'); + } + } else if (typeof collisionPadding === 'object' && collisionPadding !== null) { + for (const key of Object.keys(collisionPadding)) { + if ((collisionPadding as any)[key] < 0) { + throw new Error(`collisionPadding.${key} must be a non-negative number`); + } + } + } + + return ( + + + + ); +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< diff --git a/src/hooks/useCloudAccounts.ts b/src/hooks/useCloudAccounts.ts index aba6146..a0ace08 100644 --- a/src/hooks/useCloudAccounts.ts +++ b/src/hooks/useCloudAccounts.ts @@ -4,6 +4,7 @@ import { addGoogleAccount, deleteCloudAccount, refreshAccountQuota, + setAccountProxy, } from '@/actions/cloud'; import { CloudAccount } from '@/types/cloudAccount'; @@ -120,3 +121,41 @@ export function useSyncLocalAccount() { import { startAuthFlow } from '@/actions/cloud'; export { startAuthFlow }; + +export function useSetAccountProxy() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setAccountProxy, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts }); + }, + onError: (error: any) => { + console.error('[Mutation] setAccountProxy failed:', error); + }, + }); +} + +import { exportCloudAccounts, importCloudAccounts } from '@/actions/cloud'; + +export function useExportCloudAccounts() { + return useMutation({ + mutationFn: exportCloudAccounts, + }); +} + +export function useImportCloudAccounts() { + const queryClient = useQueryClient(); + return useMutation< + { imported: number; skipped: number; updated: number; errors: string[] }, + Error, + { jsonContent: string; strategy?: 'merge' | 'overwrite' | 'skip-existing' } + >({ + mutationFn: importCloudAccounts, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts }); + }, + onError: (error: any) => { + console.error('[Mutation] importCloudAccounts failed:', error); + }, + }); +} diff --git a/src/ipc/cloud/authServer.ts b/src/ipc/cloud/authServer.ts index ad22ff6..a8a9444 100644 --- a/src/ipc/cloud/authServer.ts +++ b/src/ipc/cloud/authServer.ts @@ -1,38 +1,79 @@ import http from 'http'; import { logger } from '../../utils/logger'; import { ipcContext } from '../context'; +import { escapeHtml } from '../../utils/url'; export class AuthServer { private static server: http.Server | null = null; private static PORT = 8888; - static start() { + static async start() { if (this.server) { logger.warn('AuthServer: Server already running'); return; } - this.server = http.createServer((req, res) => { - const url = new URL(req.url || '', `http://localhost:${this.PORT}`); + const tryPorts = [8888, 8889, 8890, 8891, 8892]; + let boundPort: number | null = null; - if (url.pathname === '/oauth-callback') { - const code = url.searchParams.get('code'); - const error = url.searchParams.get('error'); + for (const port of tryPorts) { + try { + await new Promise((resolve, reject) => { + const testServer = http.createServer(); + testServer.once('error', reject); + testServer.listen(port, '127.0.0.1', () => { + testServer.close(() => resolve()); + }); + }); + boundPort = port; + break; + } catch { + logger.debug(`AuthServer: Port ${port} is in use, trying next...`); + } + } - if (code) { - logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`); + if (!boundPort) { + logger.error('AuthServer: No available ports found for OAuth callback server'); + return; + } - // Send code to renderer - if (ipcContext.mainWindow) { - logger.info('AuthServer: Sending code to renderer via IPC'); - ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code); - logger.info('AuthServer: Code sent successfully'); - } else { - logger.error('AuthServer: Main window not found, cannot send code'); - } + if (boundPort !== 8888) { + logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`); + } - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + this.PORT = boundPort; + + try { + this.server = http.createServer((req, res) => { + if (req.method !== 'GET') { + res.writeHead(405, { Allow: 'GET' }); + res.end('Method Not Allowed'); + return; + } + + const url = new URL(req.url || '', `http://localhost:${this.PORT}`); + + if (url.pathname === '/oauth-callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (code) { + const escapedCode = escapeHtml(code); + logger.info( + `AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`, + ); + + // Send code to renderer + if (ipcContext.mainWindow) { + logger.info('AuthServer: Sending code to renderer via IPC'); + ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code); + logger.info('AuthServer: Code sent successfully'); + } else { + logger.error('AuthServer: Main window not found, cannot send code'); + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`

Login Successful

@@ -43,34 +84,46 @@ export class AuthServer { `); - } else if (error) { - logger.error(`AuthServer: OAuth error: ${error}`); - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` + } else if (error) { + const escapedError = escapeHtml(error); + logger.error(`AuthServer: OAuth error: ${escapedError}`); + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`

Login Failed

-

Error: ${error}

+

Error: ${escapedError}

`); + } else { + res.writeHead(400); + res.end('Missing code parameter'); + } } else { - res.writeHead(400); - res.end('Missing code parameter'); + res.writeHead(404); + res.end('Not Found'); } - } else { - res.writeHead(404); - res.end('Not Found'); + }); + + this.server.on('error', (err) => { + logger.error('AuthServer: Server error', err); + }); + + this.server.listen(this.PORT, '127.0.0.1', () => { + logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`); + }); + } catch (e) { + logger.error('AuthServer: Failed to create or start server', e); + if (this.server) { + this.server.close(); + this.server = null; } - }); + } + } - this.server.on('error', (err) => { - logger.error('AuthServer: Server error', err); - }); - - this.server.listen(this.PORT, () => { - logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`); - }); + static getRedirectUri(): string { + return `http://localhost:${this.PORT}/oauth-callback`; } static stop() { diff --git a/src/ipc/cloud/handler.ts b/src/ipc/cloud/handler.ts index 2755e7f..b730028 100644 --- a/src/ipc/cloud/handler.ts +++ b/src/ipc/cloud/handler.ts @@ -1,8 +1,9 @@ import { v4 as uuidv4 } from 'uuid'; import { CloudAccountRepo } from '../../ipc/database/cloudHandler'; import { GoogleAPIService } from '../../services/GoogleAPIService'; -import { CloudAccount } from '../../types/cloudAccount'; +import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount'; import { logger } from '../../utils/logger'; +import { AuthServer } from './authServer'; import { shell } from 'electron'; import fs from 'fs'; @@ -22,8 +23,7 @@ import { executeSwitchFlow } from '../../ipc/switchFlow'; import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account'; // Fallback constants if service constants are not available or for direct usage -const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; -const REDIRECT_URI = 'http://localhost:8888/oauth-callback'; +const CLIENT_ID = '1071006060591-tmhssin21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const SCOPE = [ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email', @@ -52,7 +52,13 @@ export async function addGoogleAccount(authCode: string): Promise // 2. Get User Info const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token); - // 3. Construct CloudAccount Object + // 3. Check for existing account + const existing = await CloudAccountRepo.getAccountByEmail(userInfo.email); + if (existing) { + throw new Error(`Account with email ${userInfo.email} already exists.`); + } + + // 4. Construct CloudAccount Object const now = Math.floor(Date.now() / 1000); const account: CloudAccount = { id: uuidv4(), @@ -62,7 +68,7 @@ export async function addGoogleAccount(authCode: string): Promise avatar_url: userInfo.picture, token: { access_token: tokenResp.access_token, - refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely + refresh_token: tokenResp.refresh_token || '', expires_in: tokenResp.expires_in, expiry_timestamp: now + tokenResp.expires_in, token_type: tokenResp.token_type, @@ -76,10 +82,8 @@ export async function addGoogleAccount(authCode: string): Promise logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`); } - // 4. Save to DB await CloudAccountRepo.addAccount(account); - // 5. Initial Quota Check (Async, best effort) try { const quota = await GoogleAPIService.fetchQuota(account.token.access_token); account.quota = quota; @@ -110,40 +114,56 @@ export async function refreshAccountQuota(accountId: string): Promise { // 1. Prepare token refresh promise (start it in parallel with process exit) const tokenRefreshPromise = (async () => { const now = Math.floor(Date.now() / 1000); - if (account.token.expiry_timestamp < now + 1200) { // Increased buffer to 20m + if (account.token.expiry_timestamp < now + 1200) { logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`); try { const newTokenData = await GoogleAPIService.refreshAccessToken( account.token.refresh_token, + account.proxy_url, ); - account.token.access_token = newTokenData.access_token; - account.token.expires_in = newTokenData.expires_in; - account.token.expiry_timestamp = now + newTokenData.expires_in; - await CloudAccountRepo.updateToken(account.id, account.token); + + const updatedToken = { + ...account.token, + access_token: newTokenData.access_token, + expires_in: newTokenData.expires_in, + expiry_timestamp: now + newTokenData.expires_in, + }; + await CloudAccountRepo.updateToken(account.id, updatedToken); + + account.token = updatedToken; logger.info(`Token refreshed for ${account.email}`); } catch (e) { logger.warn('Failed to refresh token in parallel, will try to use existing', e); @@ -375,8 +417,119 @@ export async function forcePollCloudMonitor(): Promise { } export async function startAuthFlow(): Promise { - const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`; + const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${AuthServer.getRedirectUri()}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`; logger.info(`Starting auth flow, opening URL: ${url}`); await shell.openExternal(url); } + +export async function exportCloudAccounts(stripTokens = false): Promise { + const accounts = await CloudAccountRepo.getAccounts(); + const exportData = { + version: '1.0' as const, + exportedAt: Math.floor(Date.now() / 1000), + accounts: accounts.map((acc) => ({ + provider: acc.provider, + email: acc.email, + name: acc.name, + avatar_url: acc.avatar_url, + token: stripTokens ? undefined : acc.token, + quota: acc.quota, + device_profile: acc.device_profile, + device_history: acc.device_history, + proxy_url: acc.proxy_url ?? null, + })), + }; + + CloudAccountExportSchema.parse(exportData); + return JSON.stringify(exportData, null, 2); +} + +export type ImportStrategy = 'merge' | 'overwrite' | 'skip-existing'; + +export async function importCloudAccounts( + jsonContent: string, + strategy: ImportStrategy = 'merge', +): Promise<{ imported: number; skipped: number; updated: number; errors: string[] }> { + const result = { imported: 0, skipped: 0, updated: 0, errors: [] as string[] }; + + let parsed: unknown; + try { + parsed = JSON.parse(jsonContent); + } catch { + throw new Error('Invalid JSON format'); + } + + const validated = CloudAccountExportSchema.safeParse(parsed); + if (!validated.success) { + throw new Error( + `Invalid export file: ${validated.error.issues[0]?.message || 'schema mismatch'}`, + ); + } + + const importEmails = new Set(); + for (const acc of validated.data.accounts) { + const emailLower = acc.email.toLowerCase(); + if (importEmails.has(emailLower)) { + throw new Error(`Duplicate email found in import file: ${acc.email}`); + } + importEmails.add(emailLower); + } + + const existingAccounts = await CloudAccountRepo.getAccounts(); + const existingByEmail = new Map(existingAccounts.map((a) => [a.email.toLowerCase(), a])); + + for (const acc of validated.data.accounts) { + try { + const existing = existingByEmail.get(acc.email.toLowerCase()); + const now = Math.floor(Date.now() / 1000); + + if (existing) { + if (strategy === 'skip-existing') { + result.skipped++; + continue; + } + + const updatedAccount: CloudAccount = { + ...existing, + provider: acc.provider, + name: acc.name ?? existing.name, + avatar_url: acc.avatar_url ?? existing.avatar_url, + token: acc.token, + quota: acc.quota ?? existing.quota, + device_profile: acc.device_profile ?? existing.device_profile, + device_history: acc.device_history ?? existing.device_history, + proxy_url: acc.proxy_url ?? existing.proxy_url, + last_used: now, + }; + + await CloudAccountRepo.addAccount(updatedAccount); + result.updated++; + } else { + const newAccount: CloudAccount = { + id: uuidv4(), + provider: acc.provider, + email: acc.email, + name: acc.name, + avatar_url: acc.avatar_url, + token: acc.token, + quota: acc.quota, + device_profile: acc.device_profile, + device_history: acc.device_history, + proxy_url: acc.proxy_url ?? undefined, + created_at: now, + last_used: now, + status: 'active', + is_active: false, + }; + + await CloudAccountRepo.addAccount(newAccount); + result.imported++; + } + } catch (error: any) { + result.errors.push(`Failed to import ${acc.email}: ${error.message}`); + } + } + + return result; +} diff --git a/src/ipc/cloud/router.ts b/src/ipc/cloud/router.ts index 3df304c..743086d 100644 --- a/src/ipc/cloud/router.ts +++ b/src/ipc/cloud/router.ts @@ -18,10 +18,12 @@ import { setAutoSwitchEnabled, forcePollCloudMonitor, startAuthFlow, + exportCloudAccounts, + importCloudAccounts, } from './handler'; +import { CloudAccountRepo } from '../database/cloudHandler'; import { CloudAccountSchema } from '../../types/cloudAccount'; import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account'; -import { CloudAccountRepo } from '../database/cloudHandler'; import { logger } from '../../utils/logger'; import { getSwitchMetricsSnapshot } from '../switchMetrics'; import { getSwitchGuardSnapshot } from '../switchGuard'; @@ -117,6 +119,18 @@ export const cloudRouter = os.router({ await startAuthFlow(); }), + setAccountProxy: os + .input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() })) + .output(z.void()) + .handler(async ({ input }) => { + try { + await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl); + } catch (error: any) { + logger.error('[ORPC] setAccountProxy error:', error.message, error.stack); + throw error; + } + }), + syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => { try { const result = await CloudAccountRepo.syncFromIDE(); @@ -185,4 +199,40 @@ export const cloudRouter = os.router({ openIdentityStorageFolder: os.output(z.void()).handler(async () => { await openCloudIdentityStorageFolder(); }), + + exportCloudAccounts: os + .input(z.object({ stripTokens: z.boolean().default(false) })) + .output(z.string()) + .handler(async ({ input }) => { + try { + return await exportCloudAccounts(input.stripTokens); + } catch (error: any) { + logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack); + throw error; + } + }), + + importCloudAccounts: os + .input( + z.object({ + jsonContent: z.string(), + strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'), + }), + ) + .output( + z.object({ + imported: z.number(), + skipped: z.number(), + updated: z.number(), + errors: z.array(z.string()), + }), + ) + .handler(async ({ input }) => { + try { + return await importCloudAccounts(input.jsonContent, input.strategy); + } catch (error: any) { + logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack); + throw error; + } + }), }); diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts index 451a2a3..78836ff 100644 --- a/src/ipc/database/cloudHandler.ts +++ b/src/ipc/database/cloudHandler.ts @@ -6,7 +6,12 @@ import { desc, eq } from 'drizzle-orm'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths'; import { logger } from '../../utils/logger'; -import { CloudAccount } from '../../types/cloudAccount'; +import { + CloudAccount, + CloudAccountSchema, + CloudQuotaDataSchema, + CloudTokenDataSchema, +} from '../../types/cloudAccount'; import { type DeviceProfile, type DeviceProfileVersion } from '../../types/account'; import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db'; import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security'; @@ -90,6 +95,7 @@ function ensureDatabaseInitialized(dbPath: string): void { const hasIsActive = tableInfo.some((col) => col.name === 'is_active'); const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json'); const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json'); + const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url'); if (!hasIsActive) { db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0'); } @@ -99,6 +105,9 @@ function ensureDatabaseInitialized(dbPath: string): void { if (!hasDeviceHistoryJson) { db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT'); } + if (!hasProxyUrl) { + db.exec('ALTER TABLE accounts ADD COLUMN proxy_url TEXT'); + } // Create index on email for faster lookups // Create index on email for faster lookups @@ -450,6 +459,9 @@ export class CloudAccountRepo { } static async addAccount(account: CloudAccount): Promise { + // Validate account data before processing + CloudAccountSchema.parse(account); + const { raw, orm } = getCloudDb(); try { const tokenEncrypted = await encrypt(JSON.stringify(account.token)); @@ -468,6 +480,7 @@ export class CloudAccountRepo { lastUsed: account.last_used, status: account.status || 'active', isActive: account.is_active ? 1 : 0, + proxyUrl: account.proxy_url ?? null, }; orm.transaction((tx) => { @@ -509,79 +522,86 @@ export class CloudAccountRepo { const cloudAccounts: CloudAccount[] = []; for (const normalizedRow of rows) { - let tokenResult: DecryptFieldResult; try { - tokenResult = await decryptAndMigrateField( - orm, - normalizedRow.id, - 'tokenJson', - normalizedRow.tokenJson, - ); - } catch (error) { - migrationStats.failedFields += 1; - logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error); - throw error; - } + let tokenResult: DecryptFieldResult; + try { + tokenResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'tokenJson', + normalizedRow.tokenJson, + ); + } catch (error) { + migrationStats.failedFields += 1; + logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error); + continue; // Skip corrupted account + } - let quotaResult: DecryptFieldResult; - try { - quotaResult = await decryptAndMigrateField( - orm, - normalizedRow.id, - 'quotaJson', - normalizedRow.quotaJson, - ); - } catch (error) { - migrationStats.failedFields += 1; - logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error); - throw error; - } + let quotaResult: DecryptFieldResult; + try { + quotaResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'quotaJson', + normalizedRow.quotaJson, + ); + } catch (error) { + migrationStats.failedFields += 1; + logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error); + quotaResult = { value: null, migrated: false }; // Quota is optional, proceed + } - if (!tokenResult.value) { - throw new Error(`Missing token data for account ${normalizedRow.id}`); - } + if (!tokenResult.value) { + logger.warn(`Missing token data for account ${normalizedRow.id}`); + continue; + } - if (tokenResult.value) { - migrationStats.totalFields += 1; - } - if (tokenResult.usedFallback) { - migrationStats.fallbackUsedFields += 1; - } - if (tokenResult.migrated) { - migrationStats.migratedFields += 1; + if (tokenResult.value) { + migrationStats.totalFields += 1; + } if (tokenResult.usedFallback) { - migrationStats.migratedBySource[tokenResult.usedFallback] += 1; + migrationStats.fallbackUsedFields += 1; + } + if (tokenResult.migrated) { + migrationStats.migratedFields += 1; + if (tokenResult.usedFallback) { + migrationStats.migratedBySource[tokenResult.usedFallback] += 1; + } } - } - if (quotaResult.value) { - migrationStats.totalFields += 1; - } - if (quotaResult.usedFallback) { - migrationStats.fallbackUsedFields += 1; - } - if (quotaResult.migrated) { - migrationStats.migratedFields += 1; + if (quotaResult.value) { + migrationStats.totalFields += 1; + } if (quotaResult.usedFallback) { - migrationStats.migratedBySource[quotaResult.usedFallback] += 1; + migrationStats.fallbackUsedFields += 1; + } + if (quotaResult.migrated) { + migrationStats.migratedFields += 1; + if (quotaResult.usedFallback) { + migrationStats.migratedBySource[quotaResult.usedFallback] += 1; + } } - } - cloudAccounts.push({ - id: normalizedRow.id, - provider: normalizedRow.provider as CloudAccount['provider'], - email: normalizedRow.email, - name: normalizedRow.name ?? undefined, - avatar_url: normalizedRow.avatarUrl ?? undefined, - token: JSON.parse(tokenResult.value), - quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, - device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson), - device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson), - created_at: normalizedRow.createdAt, - last_used: normalizedRow.lastUsed, - status: (normalizedRow.status as CloudAccount['status']) ?? undefined, - is_active: Boolean(normalizedRow.isActive), - }); + cloudAccounts.push({ + id: normalizedRow.id, + provider: normalizedRow.provider as CloudAccount['provider'], + email: normalizedRow.email, + name: normalizedRow.name ?? undefined, + avatar_url: normalizedRow.avatarUrl ?? undefined, + token: JSON.parse(tokenResult.value), + quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, + device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson), + device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson), + created_at: normalizedRow.createdAt, + last_used: normalizedRow.lastUsed, + status: (normalizedRow.status as CloudAccount['status']) ?? undefined, + is_active: Boolean(normalizedRow.isActive), + proxy_url: normalizedRow.proxyUrl ?? undefined, + }); + } catch (rowError) { + logger.error(`Unexpected error processing row for account ${normalizedRow.id}`, rowError); + continue; + } } return cloudAccounts; @@ -649,6 +669,7 @@ export class CloudAccountRepo { last_used: normalizedRow.lastUsed, status: (normalizedRow.status as CloudAccount['status']) ?? undefined, is_active: Boolean(normalizedRow.isActive), + proxy_url: normalizedRow.proxyUrl ?? undefined, }; } finally { raw.close(); @@ -666,22 +687,42 @@ export class CloudAccountRepo { } static async updateToken(id: string, token: any): Promise { + // Validate token data before encryption + CloudTokenDataSchema.parse(token); + const { raw, orm } = getCloudDb(); try { const encrypted = await encrypt(JSON.stringify(token)); - orm.update(accounts).set({ tokenJson: encrypted }).where(eq(accounts.id, id)).run(); + const result = orm + .update(accounts) + .set({ tokenJson: encrypted }) + .where(eq(accounts.id, id)) + .run(); + if (result.changes === 0) { + logger.warn(`updateToken: No account found with ID ${id}`); + } } finally { raw.close(); } } static async updateQuota(id: string, quota: any): Promise { + // Validate quota data before encryption + CloudQuotaDataSchema.parse(quota); + const { raw, orm } = getCloudDb(); try { const encrypted = await encrypt(JSON.stringify(quota)); - orm.update(accounts).set({ quotaJson: encrypted }).where(eq(accounts.id, id)).run(); + const result = orm + .update(accounts) + .set({ quotaJson: encrypted }) + .where(eq(accounts.id, id)) + .run(); + if (result.changes === 0) { + logger.warn(`updateQuota: No account found with ID ${id}`); + } } finally { raw.close(); } @@ -911,6 +952,24 @@ export class CloudAccountRepo { } } + static setAccountProxy(id: string, proxyUrl: string | null): void { + const { raw, orm } = getCloudDb(); + try { + orm.update(accounts).set({ proxyUrl }).where(eq(accounts.id, id)).run(); + logger.info(`Updated proxy for account ${id}: ${proxyUrl ?? 'none'}`); + } catch (error) { + logger.error(`Failed to update proxy for account ${id}`, error); + throw error; + } finally { + raw.close(); + } + } + + static async getAccountByEmail(email: string): Promise { + const all = await this.getAccounts(); + return all.find((a) => a.email.toLowerCase() === email.toLowerCase()) || null; + } + private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void { db.insert(itemTable) .values({ key, value }) diff --git a/src/ipc/database/schema.ts b/src/ipc/database/schema.ts index 8c8ddbd..c7c04e9 100644 --- a/src/ipc/database/schema.ts +++ b/src/ipc/database/schema.ts @@ -12,8 +12,9 @@ export const accounts = sqliteTable('accounts', { deviceHistoryJson: text('device_history_json'), createdAt: integer('created_at').notNull(), lastUsed: integer('last_used').notNull(), - status: text('status'), + status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'), isActive: integer('is_active').notNull().default(0), + proxyUrl: text('proxy_url'), }); export const settings = sqliteTable('settings', { diff --git a/src/ipc/process/handler.ts b/src/ipc/process/handler.ts index 680484f..997541f 100644 --- a/src/ipc/process/handler.ts +++ b/src/ipc/process/handler.ts @@ -73,19 +73,17 @@ export async function isProcessRunning(): Promise { const currentPid = process.pid; // Use find-process to search for Antigravity processes - // 'name' search type matches process name - const processMap = new Map(); + const allMatches: ProcessInfo[] = []; const searchNames = ['Antigravity', 'antigravity']; + if (platform === 'linux') { + searchNames.push('electron'); + } let sawNoMatch = false; for (const searchName of searchNames) { try { - const matches = await findProcess('name', searchName, true); - for (const proc of matches) { - if (typeof proc.pid === 'number') { - processMap.set(proc.pid, proc); - } - } + const matches = await findProcess('name', searchName, false); + allMatches.push(...matches); } catch (error) { if (isPgrepNoMatchError(error)) { sawNoMatch = true; @@ -95,6 +93,13 @@ export async function isProcessRunning(): Promise { } } + const processMap = new Map(); + for (const proc of allMatches) { + if (typeof proc.pid === 'number') { + processMap.set(proc.pid, proc); + } + } + const processes = Array.from(processMap.values()); if (processes.length === 0 && sawNoMatch) { logger.debug('No Antigravity process found (pgrep returned 1)'); @@ -145,7 +150,27 @@ export async function isProcessRunning(): Promise { return true; } } else { - // Linux: Check for antigravity in name or path (but not tools) + const nameLower = name.toLowerCase(); + const cmdLower = cmd.toLowerCase(); + + if (nameLower === 'electron') { + // Stricter check for AUR/Electron wrapper: + // Must include antigravity in command line but NOT manager or tools + const isAntigravityApp = + (cmdLower.includes('/antigravity') || + cmdLower.includes(' antigravity') || + cmdLower.endsWith('antigravity')) && + !cmdLower.includes('manager') && + !cmdLower.includes('tools'); + + if (isAntigravityApp) { + logger.debug( + `Found Antigravity (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`, + ); + return true; + } + } + if ( (name.includes('antigravity') || cmd.includes('/antigravity')) && !name.includes('tools') diff --git a/src/lib/antigravity/ModelMapping.ts b/src/lib/antigravity/ModelMapping.ts index bd8b341..5c2d470 100644 --- a/src/lib/antigravity/ModelMapping.ts +++ b/src/lib/antigravity/ModelMapping.ts @@ -70,11 +70,7 @@ const CLAUDE_TO_GEMINI: Record = { const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image'; const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k']; const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9']; -const EXTRA_DYNAMIC_MODELS = [ - 'gemini-3-flash', - 'gemini-3.1-pro-high', - 'gemini-3.1-pro-low', -]; +const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low']; const DYNAMIC_MODEL_FORWARDING_RULES = new Map(); @@ -95,13 +91,18 @@ export function getSupportedModels(): string[] { } export function updateDynamicForwardingRules(oldModel: string, newModel: string): void { + if (typeof oldModel !== 'string' || typeof newModel !== 'string') { + return; + } const normalizedOld = oldModel.trim(); const normalizedNew = newModel.trim(); if (!normalizedOld || !normalizedNew) { return; } if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) { - logger.info(`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`); + logger.info( + `[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`, + ); } DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew); } @@ -142,12 +143,13 @@ export function getAllDynamicModels( } } - return [...modelIds] - .filter((id) => !shouldHideDeprecatedModelFromList(id)) - .sort(); + return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort(); } export function mapClaudeModelToGemini(input: string): string { + if (!input || typeof input !== 'string') { + return ''; + } const mappedModel = CLAUDE_TO_GEMINI[input]; if (mappedModel) { return mappedModel; @@ -172,8 +174,10 @@ export function resolveModelRoute( anthropicMapping: Record, ): string { const dynamicForwarded = getDynamicForwardingTarget(originalModel); - if (dynamicForwarded) { - logger.info(`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`); + if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) { + logger.info( + `[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`, + ); return dynamicForwarded; } diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 71968a5..e135091 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -118,6 +118,14 @@ i18n restart_note: 'Changes to error reporting will take effect after restarting the application.', }, + notifications: { + title: 'Notifications', + description: 'Configure desktop alerts for account events.', + quotaAlert: 'Low Quota Alerts', + quotaAlertDesc: 'Get notified when a model quota drops below the set threshold', + quotaThreshold: 'Alert Threshold', + quotaThresholdDesc: 'Percentage below which to trigger an alert', + }, proxy: { title: 'Upstream Proxy', description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.', @@ -258,6 +266,7 @@ i18n twoCol: '2 Columns', threeCol: '3 Columns', list: 'List', + compact: 'Compact', }, authDialog: { title: 'Add Google Account', @@ -289,6 +298,13 @@ i18n gemini3Ready: 'Gemini 3 Ready', groupGoogleGemini: 'Google Gemini', groupAnthropicClaude: 'Anthropic Claude', + proxy: 'Proxy', + proxyPlaceholder: 'e.g. http://127.0.0.1:7890', + proxySaved: 'Proxy saved', + noProxy: 'No proxy', + aiCredits: 'AI Credits', + creditsExpiry: 'expires {{date}}', + modelVisibility: 'Model Visibility', }, identity: { title: 'Identity Profile', @@ -357,6 +373,32 @@ i18n clear: 'Clear Selection', confirmDelete: 'Are you sure you want to delete {{count}} accounts?', }, + sort: { + recentlyUsed: 'Recently Used', + quotaOverall: 'Overall Quota', + quotaClaude: 'Claude Quota', + quotaPro3: 'Pro3 Quota', + quotaFlash: 'Flash Quota', + }, + exportImport: { + export: 'Export', + import: 'Import', + exportTitle: 'Export Accounts', + exportDesc: 'Choose whether to include authentication tokens in the export file.', + includeTokens: 'Include tokens (less secure)', + stripTokens: 'Strip tokens (safer for sharing)', + exportSuccess: 'Accounts exported successfully', + importTitle: 'Import Accounts', + importDesc: 'Select a previously exported JSON file.', + importStrategy: 'Import Strategy', + strategyMerge: 'Merge - Update existing, add new', + strategyOverwrite: 'Overwrite - Replace all existing data', + strategySkip: 'Skip - Only add new accounts', + importSuccess: 'Imported {{imported}}, updated {{updated}}, skipped {{skipped}}', + importErrors: 'Import completed with {{count}} error(s)', + selectFile: 'Select File', + importing: 'Importing...', + }, }, proxy: { title: 'API Proxy', @@ -504,6 +546,14 @@ i18n error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。', restart_note: '错误上报设置会在重启应用后生效。', }, + notifications: { + title: '通知', + description: '配置账号事件的桌面提醒。', + quotaAlert: '低配额提醒', + quotaAlertDesc: '当模型配额低于设定阈值时收到提醒', + quotaThreshold: '提醒阈值', + quotaThresholdDesc: '触发提醒的百分比阈值', + }, proxy: { title: '上游代理', description: '为发往 Google/Gemini API 的请求配置代理。', @@ -642,6 +692,7 @@ i18n twoCol: '2 列', threeCol: '3 列', list: '列表', + compact: '紧凑', }, authDialog: { title: '添加 Google 账号', @@ -672,6 +723,13 @@ i18n gemini3Ready: 'Gemini 3 就绪', groupGoogleGemini: 'Google Gemini', groupAnthropicClaude: 'Anthropic Claude', + proxy: '代理', + proxyPlaceholder: '例如 http://127.0.0.1:7890', + proxySaved: '代理已保存', + noProxy: '无代理', + aiCredits: 'AI 积分', + creditsExpiry: '{{date}} 过期', + modelVisibility: '模型可见性', }, identity: { title: '身份配置', @@ -740,6 +798,32 @@ i18n clear: '取消选择', confirmDelete: '确定要删除选中的 {{count}} 个账号吗?', }, + sort: { + recentlyUsed: '最近使用', + quotaOverall: '总体配额', + quotaClaude: 'Claude 配额', + quotaPro3: 'Pro3 配额', + quotaFlash: 'Flash 配额', + }, + exportImport: { + export: '导出', + import: '导入', + exportTitle: '导出账号', + exportDesc: '选择是否在导出文件中包含认证令牌。', + includeTokens: '包含令牌(安全性较低)', + stripTokens: '移除令牌(适合分享)', + exportSuccess: '账号导出成功', + importTitle: '导入账号', + importDesc: '选择之前导出的 JSON 文件。', + importStrategy: '导入策略', + strategyMerge: '合并 - 更新已有,添加新账号', + strategyOverwrite: '覆盖 - 替换所有已有数据', + strategySkip: '跳过 - 仅添加新账号', + importSuccess: '导入 {{imported}},更新 {{updated}},跳过 {{skipped}}', + importErrors: '导入完成,但有 {{count}} 个错误', + selectFile: '选择文件', + importing: '导入中...', + }, }, proxy: { title: 'API 反代', @@ -889,6 +973,15 @@ i18n restart_note: 'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.', }, + notifications: { + title: 'Уведомления', + description: 'Настройте уведомления о событиях аккаунтов.', + quotaAlert: 'Уведомления о низкой квоте', + quotaAlertDesc: + 'Получайте уведомления, когда квота модели падает ниже установленного порога', + quotaThreshold: 'Порог уведомления', + quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление', + }, proxy: { title: 'Верхнеуровневый прокси', description: 'Настроить прокси для исходящих запросов к Google/Gemini API.', @@ -1028,6 +1121,7 @@ i18n twoCol: '2 колонки', threeCol: '3 колонки', list: 'Список', + compact: 'Компактный', }, authDialog: { title: 'Добавить Google Аккаунт', @@ -1059,6 +1153,13 @@ i18n gemini3Ready: 'Gemini 3 готов', groupGoogleGemini: 'Google Gemini', groupAnthropicClaude: 'Anthropic Claude', + proxy: 'Прокси', + proxyPlaceholder: 'напр. http://127.0.0.1:7890', + proxySaved: 'Прокси сохранён', + noProxy: 'Без прокси', + aiCredits: 'AI Кредиты', + creditsExpiry: 'истекает {{date}}', + modelVisibility: 'Видимость моделей', }, identity: { title: 'Профиль идентичности', @@ -1127,6 +1228,33 @@ i18n clear: 'Снять выделение', confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?', }, + sort: { + recentlyUsed: 'Недавние', + quotaOverall: 'Общая квота', + quotaClaude: 'Квота Claude', + quotaPro3: 'Квота Pro3', + quotaFlash: 'Квота Flash', + }, + exportImport: { + export: 'Экспорт', + import: 'Импорт', + exportTitle: 'Экспорт аккаунтов', + exportDesc: 'Выберите, включать ли токены аутентификации в файл экспорта.', + includeTokens: 'Включить токены (менее безопасно)', + stripTokens: 'Удалить токены (безопаснее для обмена)', + exportSuccess: 'Аккаунты успешно экспортированы', + importTitle: 'Импорт аккаунтов', + importDesc: 'Выберите ранее экспортированный JSON файл.', + importStrategy: 'Стратегия импорта', + strategyMerge: 'Объединить - Обновить существующие, добавить новые', + strategyOverwrite: 'Перезаписать - Заменить все существующие данные', + strategySkip: 'Пропустить - Добавить только новые аккаунты', + importSuccess: + 'Импортировано {{imported}}, обновлено {{updated}}, пропущено {{skipped}}', + importErrors: 'Импорт завершён с {{count}} ошибкой(ами)', + selectFile: 'Выбрать файл', + importing: 'Импорт...', + }, }, proxy: { title: 'API Прокси', diff --git a/src/main.ts b/src/main.ts index e47a850..23b084c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -122,10 +122,17 @@ function showWindowsInstallNoticeIfNeeded() { }); } +const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; + +if (isDev) { + app.setName('Antigravity Manager Dev'); +} + const gotSingleInstanceLock = app.requestSingleInstanceLock(); if (!gotSingleInstanceLock) { app.quit(); + process.exit(0); } else { app.on('second-instance', () => { logger.info('Second instance detected, focusing existing window'); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 72c15b0..fbfdb13 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -17,6 +17,7 @@ import { getAppVersion, getPlatform } from '@/actions/app'; import { useTranslation } from 'react-i18next'; import { setAppLanguage } from '@/actions/language'; import { useAppConfig } from '@/hooks/useAppConfig'; +import { useToast } from '@/components/ui/use-toast'; import { Loader2, FolderOpen } from 'lucide-react'; import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings'; import { useEffect, useState } from 'react'; @@ -27,6 +28,7 @@ function SettingsPage() { const { theme, setTheme } = useTheme(); const { t, i18n } = useTranslation(); const { config, isLoading, saveConfig } = useAppConfig(); + const { toast } = useToast(); // Local state for configuration editing const [proxyConfig, setProxyConfig] = useState(undefined); @@ -297,6 +299,86 @@ function SettingsPage() {

+ + {/* Notifications Card */} + + + {t('settings.notifications.title', 'Notifications')} + + {t( + 'settings.notifications.description', + 'Configure desktop alerts for account events.', + )} + + + +
+
+ +

+ {t( + 'settings.notifications.quotaAlertDesc', + 'Get notified when a model quota drops below the set threshold', + )} +

+
+ { + if (config) { + try { + await saveConfig({ ...config, quota_alert_enabled: checked }); + } catch (err) { + toast({ + title: t('common.error'), + description: 'Failed to save notification settings', + variant: 'destructive', + }); + } + } + }} + /> +
+
+
+ +

+ {t( + 'settings.notifications.quotaThresholdDesc', + 'Percentage below which to trigger an alert', + )} +

+
+
+ { + const rawValue = e.target.value; + const parsed = parseInt(rawValue, 10); + if (isNaN(parsed) || parsed < 0 || parsed > 100) return; + + if (config) { + try { + await saveConfig({ ...config, quota_alert_threshold: parsed }); + } catch (err) { + toast({ + title: t('common.error'), + description: 'Failed to save threshold setting', + variant: 'destructive', + }); + } + } + }} + className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm" + /> + % +
+
+
+
{/* --- MODELS TAB --- */} diff --git a/src/server/modules/proxy/clients/upstream-error.ts b/src/server/modules/proxy/clients/upstream-error.ts index e637762..f515f3a 100644 --- a/src/server/modules/proxy/clients/upstream-error.ts +++ b/src/server/modules/proxy/clients/upstream-error.ts @@ -15,9 +15,23 @@ export class UpstreamRequestError extends Error { }) { super(params.message); this.name = 'UpstreamRequestError'; + + if (params.status && typeof params.status !== 'number') { + throw new TypeError('status must be a number'); + } + if (params.headers && typeof params.headers !== 'object') { + throw new TypeError('headers must be an object'); + } + this.status = params.status; this.headers = params.headers; - this.body = params.body; + + // Sanitize and limit body size + if (typeof params.body === 'string') { + const sanitized = params.body.replace(/<[^>]*>?/gm, ''); + this.body = sanitized.substring(0, 1000); + } else { + this.body = undefined; + } } } - diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index e9fbb1a..bee81bf 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -29,7 +29,10 @@ import { AnthropicContent, } from './interfaces/request-interfaces'; import { getServerConfig } from '../../server-config'; -import { normalizeGeminiModelAlias, resolveModelRoute } from '../../../lib/antigravity/ModelMapping'; +import { + normalizeGeminiModelAlias, + resolveModelRoute, +} from '../../../lib/antigravity/ModelMapping'; import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/antigravity/ModelSpecs'; import { resolveRequestUserAgent } from './request-user-agent'; import { UpstreamRequestError } from './clients/upstream-error'; @@ -679,7 +682,9 @@ export class ProxyService { for (let i = 0; i < maxRetries; i++) { if (i > 0) { const delay = calculateRetryDelay(i - 1); - this.logger.log(`OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`); + this.logger.log( + `OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`, + ); await sleep(delay); } diff --git a/src/server/modules/proxy/token-manager.service.ts b/src/server/modules/proxy/token-manager.service.ts index 8302cff..10df5ee 100644 --- a/src/server/modules/proxy/token-manager.service.ts +++ b/src/server/modules/proxy/token-manager.service.ts @@ -435,6 +435,10 @@ export class TokenManagerService implements OnModuleInit { return candidates.find(([accountId]) => accountId === stickyBinding.accountId) ?? null; } + public resetSelectionState(): void { + this.currentIndex = 0; + } + private pickRoundRobinEntry(candidates: TokenEntry[]): TokenEntry | null { if (candidates.length === 0) { return null; @@ -507,10 +511,7 @@ export class TokenManagerService implements OnModuleInit { const stickyBinding = this.getValidSessionBinding(sessionKey, now); if (stickyBinding && mode === 'cache-first') { - const waitSec = this.rateLimitTracker.getRemainingWaitSeconds( - stickyBinding.accountId, - model, - ); + const waitSec = this.rateLimitTracker.getRemainingWaitSeconds(stickyBinding.accountId, model); const waitMs = waitSec * 1000; const maxWaitMs = this.getMaxWaitDurationMs(); if (waitMs > 0 && waitMs <= maxWaitMs) { @@ -586,18 +587,18 @@ export class TokenManagerService implements OnModuleInit { ): void { const now = Date.now(); const legacyAccountId = this.predictLegacyAccountCandidateId(allTokens, sessionKey, now); - const parityAccountId = this.predictParityAccountCandidateId( - allTokens, - sessionKey, - model, - now, - ); + const parityAccountId = this.predictParityAccountCandidateId(allTokens, sessionKey, model, now); + + this.updateShadowStats(legacyAccountId, parityAccountId); + } + + private updateShadowStats(legacyId: string | null, parityId: string | null): void { this.shadowComparisonCount++; - if (legacyAccountId !== parityAccountId) { + if (legacyId !== parityId) { this.shadowMismatchCount++; this.logger.warn( - `Parity shadow mismatch detected: legacy=${legacyAccountId ?? 'n/a'}, parity=${parityAccountId ?? 'n/a'}`, + `Parity shadow mismatch detected: legacy=${legacyId ?? 'n/a'}, parity=${parityId ?? 'n/a'}`, ); } diff --git a/src/services/CloudMonitorService.ts b/src/services/CloudMonitorService.ts index 4aa2303..53e980e 100644 --- a/src/services/CloudMonitorService.ts +++ b/src/services/CloudMonitorService.ts @@ -1,3 +1,4 @@ +import { Notification } from 'electron'; import { CloudAccountRepo } from '../ipc/database/cloudHandler'; import { GoogleAPIService } from './GoogleAPIService'; import { AutoSwitchService } from './AutoSwitchService'; @@ -101,18 +102,33 @@ export class CloudMonitorService { let accessToken = account.token.access_token; if (account.token.expiry_timestamp < now + 600) { logger.info(`Monitor: Refreshing token for ${account.email}`); - const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token); - account.token.access_token = newToken.access_token; - account.token.expires_in = newToken.expires_in; - account.token.expiry_timestamp = now + newToken.expires_in; - await CloudAccountRepo.updateToken(account.id, account.token); - accessToken = newToken.access_token; + try { + const newToken = await GoogleAPIService.refreshAccessToken( + account.token.refresh_token, + account.proxy_url, + ); + account.token.access_token = newToken.access_token; + account.token.expires_in = newToken.expires_in; + account.token.expiry_timestamp = now + newToken.expires_in; + await CloudAccountRepo.updateToken(account.id, account.token); + accessToken = newToken.access_token; + } catch (refreshError) { + logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError); + continue; + } } - // 2. Fetch Quota - // We delay slightly between requests to act human/avoid spike await new Promise((r) => setTimeout(r, 1000)); - const quota = await GoogleAPIService.fetchQuota(accessToken); + const quota = await GoogleAPIService.fetchQuota(accessToken, account.proxy_url); + + try { + const aiCredits = await GoogleAPIService.fetchAICredits(accessToken, account.proxy_url); + if (aiCredits) { + quota.ai_credits = aiCredits; + } + } catch (creditError) { + logger.warn(`Monitor: Failed to fetch credits for ${account.email}`, creditError); + } // 3. Update DB await CloudAccountRepo.updateQuota(account.id, quota); @@ -123,7 +139,30 @@ export class CloudMonitorService { } } - // 4. Check for Auto-Switch + // 4. Check for Quota Alerts + const alertEnabled = CloudAccountRepo.getSetting('quota_alert_enabled', false); + const alertThreshold = CloudAccountRepo.getSetting('quota_alert_threshold', 20); + + if (alertEnabled) { + for (const account of accounts) { + if (!account.quota?.models) continue; + const lowQuotaModels = Object.entries(account.quota.models) + .filter(([_, info]) => info.percentage <= alertThreshold && info.percentage > 0) + .map(([name, info]) => { + return info.display_name || name.replace('models/', '').replace(/-/g, ' '); + }); + + if (lowQuotaModels.length > 0) { + new Notification({ + title: 'Low Quota Alert', + body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`, + silent: false, + }).show(); + } + } + } + + // 5. Check for Auto-Switch await AutoSwitchService.checkAndSwitchIfNeeded(); } finally { this.isPolling = false; diff --git a/src/services/GoogleAPIService.ts b/src/services/GoogleAPIService.ts index 3d656bb..9b4a36b 100644 --- a/src/services/GoogleAPIService.ts +++ b/src/services/GoogleAPIService.ts @@ -1,4 +1,6 @@ +import { z } from 'zod'; import { ConfigManager } from '../ipc/config/manager'; +import { AuthServer } from '../ipc/cloud/authServer'; import { ProxyAgent } from 'undici'; import { logger } from '../utils/logger'; import { @@ -22,7 +24,6 @@ const URLS = { // Internal API masquerading const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; -const REDIRECT_URI = 'http://localhost:8888/oauth-callback'; // Request timeout in milliseconds (30 seconds) const REQUEST_TIMEOUT_MS = 30000; @@ -56,11 +57,22 @@ export interface UserInfo { picture: string; } +export const UserInfoSchema = z.object({ + id: z.string(), + email: z.string(), + verified_email: z.boolean(), + name: z.string(), + given_name: z.string(), + family_name: z.string(), + picture: z.string(), +}); + export interface QuotaData { models: Record; model_forwarding_rules?: Record; subscription_tier?: string; is_forbidden?: boolean; + ai_credits?: { credits: number; expiryDate: string }; } export interface ModelQuotaInfo { @@ -219,17 +231,25 @@ function toModelForwardingRules( // --- Service Implementation --- export class GoogleAPIService { - private static getFetchOptions() { + private static getFetchOptions(proxyUrl?: string) { + if (proxyUrl && proxyUrl.length > 0) { + return { + dispatcher: new ProxyAgent(proxyUrl), + }; + } try { const config = ConfigManager.loadConfig(); - if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) { + if (config.proxy?.upstream_proxy?.enabled) { + if (!config.proxy.upstream_proxy.url) { + throw new Error('Upstream proxy is enabled but URL is not configured'); + } return { dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url), }; } } catch (e) { - // Fallback or log if config load fails (shouldn't happen usually) - logger.warn('[GoogleAPIService] Failed to load proxy config', e); + logger.error('[GoogleAPIService] Proxy configuration error', e); + throw e; } return {}; } @@ -242,13 +262,13 @@ export class GoogleAPIService { 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/cclog', - 'https://www.googleapis.com/auth/experimentsandconfigs', ].join(' '); + const redirectUri = AuthServer.getRedirectUri(); + const params = new URLSearchParams({ client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, + redirect_uri: redirectUri, response_type: 'code', scope: scopes, access_type: 'offline', @@ -262,12 +282,14 @@ export class GoogleAPIService { /** * Exchanges an authorization code for tokens. */ - static async exchangeCode(code: string): Promise { + static async exchangeCode(code: string, proxyUrl?: string): Promise { + const redirectUri = AuthServer.getRedirectUri(); + const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: code, - redirect_uri: REDIRECT_URI, + redirect_uri: redirectUri, grant_type: 'authorization_code', }); @@ -276,7 +298,7 @@ export class GoogleAPIService { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), - ...this.getFetchOptions(), + ...this.getFetchOptions(proxyUrl), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { @@ -299,7 +321,7 @@ export class GoogleAPIService { /** * Refreshes an access token using a refresh token. */ - static async refreshAccessToken(refreshToken: string): Promise { + static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise { const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, @@ -312,7 +334,7 @@ export class GoogleAPIService { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), - ...this.getFetchOptions(), + ...this.getFetchOptions(proxyUrl), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { @@ -337,11 +359,11 @@ export class GoogleAPIService { /** * Fetches user profile information. */ - static async getUserInfo(accessToken: string): Promise { + static async getUserInfo(accessToken: string, proxyUrl?: string): Promise { const response = await fetch(URLS.USER_INFO, { headers: { Authorization: `Bearer ${accessToken}` }, signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), - ...this.getFetchOptions(), + ...this.getFetchOptions(proxyUrl), }).catch((err: unknown) => { if (err instanceof Error) { if (err.name === 'AbortError') { @@ -358,16 +380,26 @@ export class GoogleAPIService { throw new Error(`Failed to fetch user info: ${text}`); } - return response.json() as Promise; + const data = await response.json(); + try { + return UserInfoSchema.parse(data); + } catch (err) { + logger.error('[GoogleAPIService] Malformed user info response:', err); + throw new Error('Received malformed user info from Google APIs'); + } } - public static async fetchProjectContext(accessToken: string): Promise { + public static async fetchProjectContext( + accessToken: string, + proxyUrl?: string, + ): Promise { const body = { metadata: { ideType: 'ANTIGRAVITY' }, }; let projectId: string | undefined; let subscriptionTier: string | undefined; + let lastError: any; for (let i = 0; i < 2; i++) { try { @@ -376,7 +408,7 @@ export class GoogleAPIService { headers: buildInternalApiHeaders(accessToken), body: JSON.stringify(body), signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), - ...this.getFetchOptions(), + ...this.getFetchOptions(proxyUrl), }); if (response.ok) { @@ -386,29 +418,62 @@ export class GoogleAPIService { } subscriptionTier = resolveSubscriptionTier(data); break; + } else { + lastError = new Error(`HTTP ${response.status}: ${await response.text()}`); } } catch (error) { + lastError = error; logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, error); await sleep(500); } } + if (!projectId && !subscriptionTier) { + throw lastError || new Error('Failed to fetch project context after multiple attempts.'); + } + return { projectId, subscriptionTier, }; } - public static async fetchProjectId(accessToken: string): Promise { - const context = await this.fetchProjectContext(accessToken); + public static async fetchProjectId( + accessToken: string, + proxyUrl?: string, + ): Promise { + const context = await this.fetchProjectContext(accessToken, proxyUrl); return context.projectId ?? null; } + static async fetchAICredits( + accessToken: string, + proxyUrl?: string, + ): Promise<{ credits: number; expiryDate: string } | null> { + try { + const response = await fetch('https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', { + method: 'POST', + headers: buildInternalApiHeaders(accessToken), + body: JSON.stringify({}), + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), + ...this.getFetchOptions(proxyUrl), + }); + if (!response.ok) return null; + const data = await response.json(); + return { + credits: data?.credits ?? data?.remainingCredits ?? 0, + expiryDate: data?.expiryDate ?? data?.expirationDate ?? '', + }; + } catch { + return null; + } + } + /** * Core logic: Fetches detailed model quota information. */ - static async fetchQuota(accessToken: string): Promise { - const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken); + static async fetchQuota(accessToken: string, proxyUrl?: string): Promise { + const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl); const payload: Record = {}; if (projectId) { @@ -417,7 +482,7 @@ export class GoogleAPIService { const maxRetries = 3; let lastError: Error | null = null; - const fetchOptions = this.getFetchOptions(); + const fetchOptions = this.getFetchOptions(proxyUrl); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -446,7 +511,8 @@ export class GoogleAPIService { ); if (attempt < maxRetries) { - await sleep(1000); + const backoffMs = 1000 * Math.pow(2, attempt - 1); + await sleep(backoffMs); continue; } else { throw new Error(errorMsg); @@ -474,14 +540,23 @@ export class GoogleAPIService { return result; } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); if (error instanceof Error) { logger.warn( `[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`, ); } lastError = error instanceof Error ? error : new Error(String(error)); + + // Abort retries for auth errors + if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') { + throw error; + } + if (attempt < maxRetries) { - await sleep(1000); + const backoffMs = 1000 * Math.pow(2, attempt - 1); + await sleep(backoffMs); + continue; } } } diff --git a/src/tests/unit/parity-rollout-guard.test.ts b/src/tests/unit/parity-rollout-guard.test.ts index f8aa26b..6b62d38 100644 --- a/src/tests/unit/parity-rollout-guard.test.ts +++ b/src/tests/unit/parity-rollout-guard.test.ts @@ -72,9 +72,11 @@ describe('Parity rollout guard replay', () => { preferred_account_id: 'acc-2', }), ); - (service as any).currentIndex = 0; + service.resetSelectionState(); const rollbackSelected = await service.getNextToken({ model: 'gemini-2.5-flash' }); expect(rollbackSelected?.id).toBe('acc-1'); + // Verify that shadow comparison was bypassed due to kill-switch + expect((service as any).shadowComparisonCount).toBe(0); }); it('enforces no-go threshold after shadow mismatch and blocks parity enablement', async () => { @@ -98,9 +100,9 @@ describe('Parity rollout guard replay', () => { preferred_account_id: 'acc-2', }), ); - (service as any).currentIndex = 0; + service.resetSelectionState(); const blockedSelection = await service.getNextToken({ model: 'gemini-2.5-flash' }); expect(blockedSelection?.id).toBe('acc-1'); + expect((service as any).noGoBlocked).toBe(true); }); }); - diff --git a/src/tests/unit/proxy-controller.integration.test.ts b/src/tests/unit/proxy-controller.integration.test.ts index d466537..653bcc2 100644 --- a/src/tests/unit/proxy-controller.integration.test.ts +++ b/src/tests/unit/proxy-controller.integration.test.ts @@ -260,8 +260,12 @@ describe('ProxyController Integration', () => { ); const callArg = proxyService.handleChatCompletions.mock.calls[0][0]; - const assistantMessage = callArg.messages.find((message: { role: string }) => message.role === 'assistant'); - const toolMessage = callArg.messages.find((message: { role: string }) => message.role === 'tool'); + const assistantMessage = callArg.messages.find( + (message: { role: string }) => message.role === 'assistant', + ); + const toolMessage = callArg.messages.find( + (message: { role: string }) => message.role === 'tool', + ); expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search'); expect(toolMessage?.name).toBe('builtin_web_search'); diff --git a/src/types/cloudAccount.ts b/src/types/cloudAccount.ts index 270bea3..3c2dc78 100644 --- a/src/types/cloudAccount.ts +++ b/src/types/cloudAccount.ts @@ -37,6 +37,7 @@ export interface CloudQuotaData { subscription_tier?: string; is_forbidden?: boolean; isForbidden?: boolean; + ai_credits?: { credits: number; expiryDate: string }; } export interface CloudAccount { @@ -53,6 +54,7 @@ export interface CloudAccount { last_used: number; // Unix timestamp status?: 'active' | 'rate_limited' | 'expired'; is_active?: boolean; + proxy_url?: string; } // Zod Schemas @@ -87,6 +89,7 @@ export const CloudQuotaDataSchema = z.object({ subscription_tier: z.string().optional(), is_forbidden: z.boolean().optional(), isForbidden: z.boolean().optional(), + ai_credits: z.object({ credits: z.number(), expiryDate: z.string() }).optional(), }); export const CloudAccountSchema = z.object({ @@ -103,4 +106,25 @@ export const CloudAccountSchema = z.object({ last_used: z.number(), status: z.enum(['active', 'rate_limited', 'expired']).optional(), is_active: z.boolean().optional(), + proxy_url: z.string().optional(), }); + +export const CloudAccountExportSchema = z.object({ + version: z.literal('1.0'), + exportedAt: z.number(), + accounts: z.array( + z.object({ + provider: z.enum(['google', 'anthropic']), + email: z.string(), + name: z.string().optional().nullable(), + avatar_url: z.string().optional().nullable(), + token: CloudTokenDataSchema, + quota: CloudQuotaDataSchema.optional(), + device_profile: z.any().optional(), + device_history: z.any().optional(), + proxy_url: z.string().optional().nullable(), + }), + ), +}); + +export type CloudAccountExport = z.infer; diff --git a/src/types/config.ts b/src/types/config.ts index b2a32e8..50ecaba 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -40,7 +40,12 @@ export const AppConfigSchema = z.object({ default_export_path: z.string().nullable().optional(), // Export path model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI - grid_layout: z.enum(['auto', '2-col', '3-col', 'list']).default('auto'), // Account card grid layout + grid_layout: z.enum(['auto', '2-col', '3-col', 'list', 'compact']).default('auto'), // Account card grid layout + account_sort: z + .enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash']) + .default('recently-used'), + quota_alert_enabled: z.boolean().default(false), + quota_alert_threshold: z.number().default(20), proxy: ProxyConfigSchema, }); @@ -50,7 +55,7 @@ export type AppConfig = z.infer; export const DEFAULT_APP_CONFIG: AppConfig = { language: 'zh-CN', - theme: 'system', + theme: 'dark', auto_refresh: false, refresh_interval: 15, auto_sync: false, @@ -62,6 +67,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = { model_visibility: {}, // Model visibility preferences provider_groupings_enabled: false, // Enable provider groupings UI grid_layout: 'auto' as const, // Account card grid layout + account_sort: 'recently-used' as const, + quota_alert_enabled: false, + quota_alert_threshold: 20, proxy: { enabled: false, port: 8045, diff --git a/src/utils/provider-grouping.ts b/src/utils/provider-grouping.ts index 3f938b9..aab2279 100644 --- a/src/utils/provider-grouping.ts +++ b/src/utils/provider-grouping.ts @@ -187,9 +187,8 @@ export function groupModelsByProvider( // Sort: known providers first (claude-, gemini-), then others const providerDisplayOrder = keys(PROVIDER_REGISTRY); - const sortedProviders = sortBy( - providerStatsList, - (providerStats) => providerDisplayOrder.indexOf(providerStats.providerKey), + const sortedProviders = sortBy(providerStatsList, (providerStats) => + providerDisplayOrder.indexOf(providerStats.providerKey), ); const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels); diff --git a/src/utils/quota-display.ts b/src/utils/quota-display.ts index 66a322a..afec1e9 100644 --- a/src/utils/quota-display.ts +++ b/src/utils/quota-display.ts @@ -1,4 +1,5 @@ import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns'; +import { CloudAccount } from '@/types/cloudAccount'; const HIGH_QUOTA_PERCENTAGE = 80; const MEDIUM_QUOTA_PERCENTAGE = 20; @@ -83,3 +84,30 @@ export function formatResetTimeTitle( return `${resetTimeLabel}: ${resetDate.toLocaleString()}`; } + +export function getAccountSortValue(account: CloudAccount, sortKey: string): number { + if (!account.quota?.models) return 0; + const models = Object.values(account.quota.models); + if (models.length === 0) return 0; + + switch (sortKey) { + case 'quota-overall': + return models.reduce((sum, m) => sum + m.percentage, 0) / models.length; + case 'quota-claude': { + const claude = models.filter((m) => m.percentage > 0); + return claude.length > 0 + ? claude.reduce((sum, m) => sum + m.percentage, 0) / claude.length + : 0; + } + case 'quota-pro3': { + const pro3 = models.filter((m) => m.percentage > 0 && /pro/i.test(m.display_name || '')); + return pro3.length > 0 ? pro3.reduce((sum, m) => sum + m.percentage, 0) / pro3.length : 0; + } + case 'quota-flash': { + const flash = models.filter((m) => m.percentage > 0 && /flash/i.test(m.display_name || '')); + return flash.length > 0 ? flash.reduce((sum, m) => sum + m.percentage, 0) / flash.length : 0; + } + default: + return 0; + } +} diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..be1036e --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,23 @@ +export function isValidProxyUrl(url: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return ['http:', 'https:', 'socks:', 'socks4:', 'socks5:'].includes(parsed.protocol); + } catch { + return false; + } +} + +export function escapeHtml(str: string): string { + return str.replace( + /[&<>"']/g, + (c) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[c] || c, + ); +} diff --git a/vite.main.config.mts b/vite.main.config.mts index 7a173b0..23882e8 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -9,19 +9,18 @@ export default defineConfig(({ mode }) => { const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken); return { - plugins: - shouldEnableSentry - ? [ - sentryVitePlugin({ - org: process.env.SENTRY_ORG || env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT, - authToken: sentryAuthToken, - release: { - name: `${process.env.npm_package_name}@${process.env.npm_package_version}`, - }, - }), - ] - : [], + plugins: shouldEnableSentry + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG || env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT, + authToken: sentryAuthToken, + release: { + name: `${process.env.npm_package_name}@${process.env.npm_package_version}`, + }, + }), + ] + : [], define: { 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN), }, diff --git a/vite.preload.config.mts b/vite.preload.config.mts index d01f839..e00d0e2 100644 --- a/vite.preload.config.mts +++ b/vite.preload.config.mts @@ -8,19 +8,18 @@ export default defineConfig(({ mode }) => { const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken); return { - plugins: - shouldEnableSentry - ? [ - sentryVitePlugin({ - org: process.env.SENTRY_ORG || env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT, - authToken: sentryAuthToken, - release: { - name: `${process.env.npm_package_name}@${process.env.npm_package_version}`, - }, - }), - ] - : [], + plugins: shouldEnableSentry + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG || env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT, + authToken: sentryAuthToken, + release: { + name: `${process.env.npm_package_name}@${process.env.npm_package_version}`, + }, + }), + ] + : [], resolve: { alias: { '@': path.resolve(process.cwd(), './src'), diff --git a/vite.renderer.config.mts b/vite.renderer.config.mts index 474351a..f73c75a 100644 --- a/vite.renderer.config.mts +++ b/vite.renderer.config.mts @@ -32,7 +32,7 @@ export default defineConfig(({ mode }) => { }, }, }), - ] + ] : []), tanstackRouter({ target: 'react',