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

+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;
}