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
This commit is contained in:
vd authored and GitHub committed 2026-04-06 20:32:42 +08:00
1 parent 50a325be11
commit ed0dd811a8
37 files changed
+1989 -455

No files matched your search

+3
View File
@@ -123,3 +123,6 @@ build/
cli/aliases.json
*.backup
accounts_backup.json
# Bun lock file
bun.lock
+6 -1
View File
@@ -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
---
+6 -1
View File
@@ -158,7 +158,12 @@
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
- **📦 批量操作** - 批量刷新和删除多个账号
- **🌏 国际化** - 多语言支持(English / 中文)
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
---
-102
View File
@@ -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!** 🚀
-11
View File
@@ -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": [
+8 -1
View File
@@ -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"
]
}
+25
View File
@@ -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);
}
+401 -18
View File
@@ -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<string, CloudQuotaModelInfo> {
function mergeGeminiProQuotaEntries(
entries: ModelQuotaEntry[],
): Record<string, CloudQuotaModelInfo> {
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
@@ -96,9 +103,18 @@ function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string,
const mergedPercentage = proLowModelInfo
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
: modelInfo.percentage;
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
...modelInfo,
...proLowModelInfo,
percentage: mergedPercentage,
display_name: 'Gemini 3.1 Pro',
resetTime:
modelInfo.resetTime && proLowModelInfo?.resetTime
? modelInfo.resetTime < proLowModelInfo.resetTime
? modelInfo.resetTime
: proLowModelInfo.resetTime
: modelInfo.resetTime || proLowModelInfo?.resetTime || '',
};
continue;
}
@@ -148,13 +164,16 @@ export function CloudAccountCard({
isSwitching,
}: CloudAccountCardProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const { config, saveConfig } = useAppConfig();
const {
enabled: providerGroupingsEnabled,
getAccountStats,
isProviderCollapsed,
toggleProviderCollapse,
} = useProviderGrouping();
const setAccountProxy = useSetAccountProxy();
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
const [proxySaved, setProxySaved] = useState(false);
const getQuotaTextColorClass = (percentage: number) => {
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({
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="flex items-center gap-2">
<span className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}>
<span
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
>
{formatQuotaLabel(providerStats.overallPercentage)}
</span>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
@@ -278,25 +301,40 @@ export function CloudAccountCard({
</div>
</div>
</div>
{providerStats.providers.map((statsByProvider) => (
<ProviderGroup
key={statsByProvider.providerKey}
stats={statsByProvider}
isCollapsed={isProviderCollapsed(account.id, statsByProvider.providerKey)}
onToggleCollapse={() => toggleProviderCollapse(account.id, statsByProvider.providerKey)}
getQuotaTextColorClass={getQuotaTextColorClass}
getQuotaBarColorClass={getQuotaBarColorClass}
formatQuotaLabel={formatQuotaLabel}
formatResetTimeLabel={formatResetTimeLabelText}
formatResetTimeTitle={formatResetTimeTitleText}
leftLabel={t('cloud.card.left')}
/>
))}
<div className="mt-3 space-y-2">
{providerStats.providers.map((group) => (
<ProviderGroup
key={group.providerKey}
stats={group}
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
getQuotaTextColorClass={getQuotaTextColorClass}
getQuotaBarColorClass={getQuotaBarColorClass}
formatQuotaLabel={formatQuotaLabel}
formatResetTimeLabel={formatResetTimeLabelText}
formatResetTimeTitle={formatResetTimeTitleText}
leftLabel={t('cloud.card.left')}
/>
))}
</div>
</>
) : (
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 (
<Card
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
@@ -330,7 +368,71 @@ export function CloudAccountCard({
{account.name || t('cloud.card.unknown')}
</CardTitle>
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
{aiCredits && aiCredits.credits > 0 && (
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
<span>${aiCredits.credits.toFixed(2)}</span>
{aiCredits.expiryDate && (
<span className="text-muted-foreground opacity-70">
·{' '}
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
)}
</div>
)}
</div>
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="text-muted-foreground h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
);
})()}
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end">
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
<div className="max-h-64 overflow-auto px-2 py-1">
{allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false;
return (
<DropdownMenuItem
key={modelName}
onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isVisible}
onCheckedChange={(checked) => {
if (config) {
const newVisibility = { ...config.model_visibility };
newVisibility[modelName] = checked as boolean;
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
/>
<span className="truncate text-xs" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
@@ -440,7 +542,288 @@ export function CloudAccountCard({
{t('cloud.card.used')}{' '}
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
</span>
<div className="flex items-center gap-2">
<Input
value={proxyUrl}
onChange={(e) => {
setProxyUrl(e.target.value);
setProxySaved(false);
}}
onBlur={() => {
const trimmed = proxyUrl.trim();
if (trimmed && !isValidProxyUrl(trimmed)) {
setProxyUrl(account.proxy_url || '');
return;
}
if (trimmed !== (account.proxy_url || '')) {
setAccountProxy.mutate({
accountId: account.id,
proxyUrl: trimmed || null,
});
setProxySaved(true);
setTimeout(() => setProxySaved(false), 2000);
}
}}
placeholder={t('cloud.card.proxyPlaceholder')}
className="h-6 w-40 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
{proxySaved && (
<span className="text-[10px] text-green-500">{t('cloud.card.proxySaved')}</span>
)}
</div>
</CardFooter>
</Card>
);
}
interface CompactCloudAccountCardProps {
account: CloudAccount;
onRefresh: (id: string) => void;
onDelete: (id: string) => void;
onSwitch: (id: string) => void;
onManageIdentity: (id: string) => void;
isRefreshing?: boolean;
isDeleting?: boolean;
isSwitching?: boolean;
}
export function CompactCloudAccountCard({
account,
onRefresh,
onDelete,
onSwitch,
onManageIdentity,
isRefreshing,
isDeleting,
isSwitching,
}: CompactCloudAccountCardProps) {
const { t } = useTranslation();
const { config, saveConfig } = useAppConfig();
const getQuotaBarColorClass = (percentage: number) => {
const quotaStatus = getQuotaStatus(percentage);
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
};
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
([modelName]) => config?.model_visibility?.[modelName] !== false,
) as ModelQuotaEntry[];
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
const compactModels = Object.entries(mergedModelQuotas).sort(
(a, b) => b[1].percentage - a[1].percentage,
);
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 (
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.name || ''}
className="bg-muted h-7 w-7 rounded-full border"
/>
) : (
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
{account.name?.[0]?.toUpperCase() || 'A'}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
{account.name || t('cloud.card.unknown')}
</span>
<Badge
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
className="shrink-0 text-[10px]"
>
{account.provider.toUpperCase()}
</Badge>
{account.is_active && (
<Badge
variant="default"
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
>
{t('cloud.card.active')}
</Badge>
)}
</div>
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<span className="truncate">{account.email}</span>
{aiCredits && aiCredits.credits > 0 && (
<span className="shrink-0 text-amber-500">
${aiCredits.credits.toFixed(2)}
{aiCredits.expiryDate && (
<span className="text-muted-foreground">
{' '}
·{' '}
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
)}
</span>
)}
</div>
{compactModels.length > 0 && (
<div className="mt-1 flex items-center gap-1">
{compactModels.map(([modelName, info]) => (
<TooltipProvider key={modelName}>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{formatModelDisplayName(modelName)}: {info.percentage}%
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
);
})()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end">
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
<div className="max-h-64 overflow-auto px-2 py-1">
{allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false;
return (
<DropdownMenuItem
key={modelName}
onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isVisible}
onCheckedChange={(checked) => {
if (config) {
const newVisibility = { ...config.model_visibility };
newVisibility[modelName] = checked as boolean;
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
/>
<span className="truncate text-xs" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{account.is_active ? (
<Button
variant="ghost"
size="sm"
disabled
className="h-7 text-xs text-green-600 opacity-100"
>
<Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')}
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="h-7 cursor-pointer text-xs"
>
{isSwitching ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
<Power className="mr-1 h-3 w-3" />
)}
{t('cloud.card.use')}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
<MoreVertical className="h-3.5 w-3.5" />
<span className="sr-only">Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
<Power className="mr-2 h-4 w-4" />
{t('cloud.card.useAccount')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('cloud.card.refresh')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
<Fingerprint className="mr-2 h-4 w-4" />
{t('cloud.card.identityProfile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-destructive focus:text-destructive"
disabled={isDeleting}
>
<Trash className="mr-2 h-4 w-4" />
{t('cloud.card.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
+445 -66
View File
@@ -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<GridLayout, string> = {
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<QuotaStatus, string> = {
@@ -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<SortOption, string> = {
'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<CloudAccount | null>(null);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importStrategy, setImportStrategy] = useState<'merge' | 'overwrite' | 'skip-existing'>(
'merge',
);
const [importFileContent, setImportFileContent] = useState<string | null>(null);
const [importFileName, setImportFileName] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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')}
</Button>
<Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<FileDown className="mr-2 h-4 w-4" />
{t('cloud.exportImport.export')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
</DialogHeader>
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
<Button
variant="outline"
onClick={() => handleExport(false)}
disabled={exportMutation.isPending}
className="w-full cursor-pointer sm:flex-1"
>
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.includeTokens')}
</Button>
<Button
onClick={() => handleExport(true)}
disabled={exportMutation.isPending}
className="w-full cursor-pointer sm:flex-1"
>
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.stripTokens')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isImportDialogOpen}
onOpenChange={(open) => {
setIsImportDialogOpen(open);
if (!open) {
setImportFileContent(null);
setImportFileName('');
setImportStrategy('merge');
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<Upload className="mr-2 h-4 w-4" />
{t('cloud.exportImport.import')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>{t('cloud.exportImport.selectFile')}</Label>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportFileSelect}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer"
>
{importFileName || t('cloud.exportImport.selectFile')}
</Button>
</div>
<div className="grid gap-2">
<Label>{t('cloud.exportImport.importStrategy')}</Label>
<Select
value={importStrategy}
onValueChange={(v: 'merge' | 'overwrite' | 'skip-existing') =>
setImportStrategy(v)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
<SelectItem value="overwrite">
{t('cloud.exportImport.strategyOverwrite')}
</SelectItem>
<SelectItem value="skip-existing">
{t('cloud.exportImport.strategySkip')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={handleImport}
disabled={!importFileContent || importMutation.isPending}
>
{importMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{importMutation.isPending
? t('cloud.exportImport.importing')
: t('cloud.exportImport.import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="cursor-pointer">
@@ -508,7 +826,7 @@ export function CloudAccountList() {
{t('cloud.addAccount')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
@@ -543,7 +861,35 @@ export function CloudAccountList() {
</DialogContent>
</Dialog>
{/* Layout Selector */}
{/* Sort + Layout Selector */}
<div className="flex items-center gap-1 rounded-md border p-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer">
<SortAsc className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56" side="bottom" sideOffset={8}>
{sortOptions.map((option) => (
<DropdownMenuItem
key={option}
className="cursor-pointer"
onClick={async () => {
if (config) {
await saveConfig({ ...config, account_sort: option });
}
}}
>
{currentSort === option && <Check className="mr-2 h-4 w-4" />}
<span className={currentSort === option ? '' : 'ml-6'}>
{t(sortI18nKeys[option])}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="ml-auto flex items-center gap-1 rounded-md border p-1">
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -598,34 +944,67 @@ export function CloudAccountList() {
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer"
onClick={() => updateGridLayout('compact')}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
{accounts?.map((account) => (
<CloudAccountCard
key={account.id}
account={account}
onRefresh={handleRefresh}
onDelete={handleDelete}
onSwitch={handleSwitch}
onManageIdentity={handleManageIdentity}
isSelected={selectedIds.has(account.id)}
onToggleSelection={setSelectionState}
isRefreshing={
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
}
isDeleting={
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
}
isSwitching={
switchMutation.isPending && switchMutation.variables?.accountId === account.id
}
/>
))}
{sortedAccounts.map((account) =>
gridLayout === 'compact' ? (
<CompactCloudAccountCard
key={account.id}
account={account}
onRefresh={handleRefresh}
onDelete={handleDelete}
onSwitch={handleSwitch}
onManageIdentity={handleManageIdentity}
isRefreshing={
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
}
isDeleting={
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
}
isSwitching={
switchMutation.isPending && switchMutation.variables?.accountId === account.id
}
/>
) : (
<CloudAccountCard
key={account.id}
account={account}
onRefresh={handleRefresh}
onDelete={handleDelete}
onSwitch={handleSwitch}
onManageIdentity={handleManageIdentity}
isSelected={selectedIds.has(account.id)}
onToggleSelection={setSelectionState}
isRefreshing={
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
}
isDeleting={
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
}
isSwitching={
switchMutation.isPending && switchMutation.variables?.accountId === account.id
}
/>
),
)}
{accounts?.length === 0 && (
{sortedAccounts.length === 0 && (
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
+4 -2
View File
@@ -76,10 +76,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
</span>
)}
<div className="flex items-baseline gap-1">
<span className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}>
<span
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
>
{formatQuotaLabel(avgPercentage)}
</span>
{avgPercentage > 0 && (
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
<span className="text-muted-foreground text-[10px]">
{t('settings.providerGroupings.avgLabel')}
</span>
+32 -15
View File
@@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
}
>(({ 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 (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
+39
View File
@@ -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<string, Error, { stripTokens?: boolean }>({
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);
},
});
}
+89 -36
View File
@@ -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<void>((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(`
<html>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
<h1>Login Successful</h1>
@@ -43,34 +84,46 @@ export class AuthServer {
</body>
</html>
`);
} 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(`
<html>
<body>
<h1>Login Failed</h1>
<p>Error: ${error}</p>
<p>Error: ${escapedError}</p>
</body>
</html>
`);
} 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() {
+179 -26
View File
@@ -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<CloudAccount>
// 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<CloudAccount>
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<CloudAccount>
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<CloudAccou
throw new Error(`Account not found: ${accountId}`);
}
// Check if token needs refresh
let now = Math.floor(Date.now() / 1000);
if (account.token.expiry_timestamp < now + 300) {
// 5 minutes buffer
logger.info(`Token for ${account.email} near expiry, refreshing...`);
try {
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token,
account.proxy_url,
);
// Update token in memory object
account.token.access_token = newTokenData.access_token;
account.token.expires_in = newTokenData.expires_in;
account.token.expiry_timestamp = now + newTokenData.expires_in;
// Save to DB
await CloudAccountRepo.updateToken(account.id, account.token);
} catch (e) {
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`);
}
}
try {
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
const quota = await GoogleAPIService.fetchQuota(account.token.access_token, account.proxy_url);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
try {
const aiCredits = await GoogleAPIService.fetchAICredits(
account.token.access_token,
account.proxy_url,
);
if (aiCredits) {
account.quota = { ...quota, ai_credits: aiCredits };
await CloudAccountRepo.updateQuota(account.id, account.quota);
}
} catch (e) {
logger.warn('Failed to fetch AI credits', e);
}
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
account.last_used = Math.floor(Date.now() / 1000);
notifyTrayUpdate(account);
return account;
} catch (error: any) {
if (error.message === 'UNAUTHORIZED') {
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
try {
// Force Refresh
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token,
account.proxy_url,
);
now = Math.floor(Date.now() / 1000);
account.token.access_token = newTokenData.access_token;
@@ -152,12 +172,28 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
await CloudAccountRepo.updateToken(account.id, account.token);
// Retry Quota
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
const quota = await GoogleAPIService.fetchQuota(
account.token.access_token,
account.proxy_url,
);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
try {
const aiCredits = await GoogleAPIService.fetchAICredits(
account.token.access_token,
account.proxy_url,
);
if (aiCredits) {
account.quota = { ...quota, ai_credits: aiCredits };
await CloudAccountRepo.updateQuota(account.id, account.quota);
}
} catch (e) {
logger.warn('Failed to fetch AI credits after token refresh', e);
}
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
account.last_used = Math.floor(Date.now() / 1000);
return account;
} catch (refreshError) {
logger.error(
@@ -170,7 +206,6 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
logger.warn(
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
);
// Return existing account to avoid crash
return account;
}
@@ -200,16 +235,23 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
// 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<void> {
}
export async function startAuthFlow(): Promise<void> {
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<string> {
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<string>();
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;
}
+51 -1
View File
@@ -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;
}
}),
});
+125 -66
View File
@@ -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<void> {
// 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<void> {
// 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<void> {
// 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<CloudAccount | null> {
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 })
+2 -1
View File
@@ -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', {
+34 -9
View File
@@ -73,19 +73,17 @@ export async function isProcessRunning(): Promise<boolean> {
const currentPid = process.pid;
// Use find-process to search for Antigravity processes
// 'name' search type matches process name
const processMap = new Map<number, ProcessInfo>();
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<boolean> {
}
}
const processMap = new Map<number, ProcessInfo>();
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<boolean> {
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')
+15 -11
View File
@@ -70,11 +70,7 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
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<string, string>();
@@ -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, string>,
): 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;
}
+128
View File
@@ -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 Прокси',
+7
View File
@@ -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');
+82
View File
@@ -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<ProxyConfig | undefined>(undefined);
@@ -297,6 +299,86 @@ function SettingsPage() {
</p>
</CardContent>
</Card>
{/* Notifications Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle>
<CardDescription>
{t(
'settings.notifications.description',
'Configure desktop alerts for account events.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaAlert', 'Low Quota Alerts')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaAlertDesc',
'Get notified when a model quota drops below the set threshold',
)}
</p>
</div>
<Switch
checked={config?.quota_alert_enabled || false}
onCheckedChange={async (checked) => {
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',
});
}
}
}}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaThreshold', 'Alert Threshold')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaThresholdDesc',
'Percentage below which to trigger an alert',
)}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={100}
value={config?.quota_alert_threshold ?? 20}
onChange={async (e) => {
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"
/>
<span className="text-muted-foreground text-sm">%</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* --- MODELS TAB --- */}
@@ -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;
}
}
}
+7 -2
View File
@@ -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);
}
@@ -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'}`,
);
}
+49 -10
View File
@@ -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<boolean>('quota_alert_enabled', false);
const alertThreshold = CloudAccountRepo.getSetting<number>('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;
+100 -25
View File
@@ -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<string, ModelQuotaInfo>;
model_forwarding_rules?: Record<string, string>;
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<TokenResponse> {
static async exchangeCode(code: string, proxyUrl?: string): Promise<TokenResponse> {
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<TokenResponse> {
static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise<TokenResponse> {
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<UserInfo> {
static async getUserInfo(accessToken: string, proxyUrl?: string): Promise<UserInfo> {
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<UserInfo>;
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<ProjectContext> {
public static async fetchProjectContext(
accessToken: string,
proxyUrl?: string,
): Promise<ProjectContext> {
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<string | null> {
const context = await this.fetchProjectContext(accessToken);
public static async fetchProjectId(
accessToken: string,
proxyUrl?: string,
): Promise<string | null> {
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<QuotaData> {
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken);
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
const payload: Record<string, unknown> = {};
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;
}
}
}
+5 -3
View File
@@ -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);
});
});
@@ -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');
+24
View File
@@ -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<typeof CloudAccountExportSchema>;
+10 -2
View File
@@ -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<typeof AppConfigSchema>;
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,
+2 -3
View File
@@ -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);
+28
View File
@@ -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;
}
}
+23
View File
@@ -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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[c] || c,
);
}
+12 -13
View File
@@ -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),
},
+12 -13
View File
@@ -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'),
+1 -1
View File
@@ -32,7 +32,7 @@ export default defineConfig(({ mode }) => {
},
},
}),
]
]
: []),
tanstackRouter({
target: 'react',