- 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
1378 lines
41 KiB
TypeScript
1378 lines
41 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
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,
|
|
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';
|
|
import { ProtobufUtils } from '../../utils/protobuf';
|
|
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
|
import { getAntigravityVersion, isNewVersion } from '../../utils/antigravityVersion';
|
|
import { parseRow, parseRows } from '../../utils/sqlite';
|
|
import { configureDatabase, openDrizzleConnection } from './dbConnection';
|
|
import { accounts, itemTable, settings } from './schema';
|
|
import * as drizzleSchema from './schema';
|
|
|
|
const SQLITE_BUSY_CODES = new Set(['SQLITE_BUSY', 'SQLITE_LOCKED']);
|
|
const SQLITE_BUSY_TIMEOUT_MS = 3000;
|
|
const SQLITE_RETRY_DELAY_MS = 150;
|
|
const SQLITE_MAX_RETRIES = 3;
|
|
const DEVICE_PAYLOAD_SCHEMA_VERSION = 1;
|
|
|
|
type DrizzleExecutor = Pick<
|
|
BetterSQLite3Database<typeof drizzleSchema>,
|
|
'insert' | 'update' | 'delete' | 'select'
|
|
>;
|
|
|
|
function isSqliteBusyError(error: unknown): boolean {
|
|
if (!error || typeof error !== 'object') {
|
|
return false;
|
|
}
|
|
const err = error as { code?: string; message?: string };
|
|
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
|
|
return true;
|
|
}
|
|
if (typeof err.message === 'string') {
|
|
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sleepSync(ms: number): void {
|
|
const buffer = new SharedArrayBuffer(4);
|
|
const array = new Int32Array(buffer);
|
|
Atomics.wait(array, 0, 0, ms);
|
|
}
|
|
|
|
/**
|
|
* Ensures that the cloud database file and schema exist.
|
|
* @param dbPath {string} The path to the database file.
|
|
*/
|
|
function ensureDatabaseInitialized(dbPath: string): void {
|
|
const dir = path.dirname(dbPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
let db: Database.Database | null = null;
|
|
try {
|
|
db = new Database(dbPath);
|
|
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
|
|
|
|
// Create accounts table
|
|
// Storing complex objects (token, quota) as JSON strings for simplicity
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
name TEXT,
|
|
avatar_url TEXT,
|
|
token_json TEXT NOT NULL,
|
|
quota_json TEXT,
|
|
device_profile_json TEXT,
|
|
device_history_json TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
last_used INTEGER NOT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
is_active INTEGER DEFAULT 0
|
|
);
|
|
`);
|
|
|
|
// Migration: Check if is_active column exists
|
|
const tableInfoRaw = db.pragma('table_info(accounts)') as any[];
|
|
const tableInfo = parseRows(TableInfoRowSchema, tableInfoRaw, 'cloud.accounts.tableInfo');
|
|
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');
|
|
}
|
|
if (!hasDeviceProfileJson) {
|
|
db.exec('ALTER TABLE accounts ADD COLUMN device_profile_json TEXT');
|
|
}
|
|
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
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);`);
|
|
|
|
// Create settings table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
} catch (error) {
|
|
logger.error('Failed to initialize cloud database schema', error);
|
|
throw error;
|
|
} finally {
|
|
if (db) db.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a connection to the cloud accounts database.
|
|
*/
|
|
function getCloudDb(): {
|
|
raw: Database.Database;
|
|
orm: BetterSQLite3Database<typeof drizzleSchema>;
|
|
} {
|
|
const dbPath = getCloudAccountsDbPath();
|
|
ensureDatabaseInitialized(dbPath);
|
|
return openDrizzleConnection(
|
|
dbPath,
|
|
{ readonly: false, fileMustExist: false },
|
|
{ busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS },
|
|
);
|
|
}
|
|
|
|
function getIdeDb(
|
|
dbPath: string,
|
|
readOnly: boolean,
|
|
): { raw: Database.Database; orm: BetterSQLite3Database<typeof drizzleSchema> } {
|
|
return openDrizzleConnection(
|
|
dbPath,
|
|
{ readonly: readOnly },
|
|
{ readOnly, busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS },
|
|
);
|
|
}
|
|
|
|
interface MigrationStats {
|
|
totalFields: number;
|
|
fallbackUsedFields: number;
|
|
migratedFields: number;
|
|
migratedBySource: Record<KeySource, number>;
|
|
failedFields: number;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function readStringCandidate(
|
|
source: Record<string, unknown>,
|
|
...keys: string[]
|
|
): string | undefined {
|
|
for (const key of keys) {
|
|
const candidate = source[key];
|
|
if (typeof candidate === 'string' && candidate.length > 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
|
|
if (!isRecord(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
|
|
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
|
|
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
|
|
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
|
|
|
|
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
machineId,
|
|
macMachineId,
|
|
devDeviceId,
|
|
sqmId,
|
|
};
|
|
}
|
|
|
|
function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): boolean {
|
|
return (
|
|
left.machineId === right.machineId &&
|
|
left.macMachineId === right.macMachineId &&
|
|
left.devDeviceId === right.devDeviceId &&
|
|
left.sqmId === right.sqmId
|
|
);
|
|
}
|
|
|
|
function readVersionedProfilePayload(value: unknown): unknown {
|
|
if (!isRecord(value)) {
|
|
return value;
|
|
}
|
|
if (!('schemaVersion' in value)) {
|
|
return value;
|
|
}
|
|
|
|
const schemaVersion = value.schemaVersion;
|
|
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
|
throw new Error('invalid_device_profile_schema_version');
|
|
}
|
|
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
|
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
|
|
}
|
|
if (!('profile' in value)) {
|
|
throw new Error('invalid_device_profile_payload');
|
|
}
|
|
return value.profile;
|
|
}
|
|
|
|
function readVersionedHistoryPayload(value: unknown): unknown {
|
|
if (!isRecord(value)) {
|
|
return value;
|
|
}
|
|
if (!('schemaVersion' in value)) {
|
|
return value;
|
|
}
|
|
|
|
const schemaVersion = value.schemaVersion;
|
|
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
|
throw new Error('invalid_device_history_schema_version');
|
|
}
|
|
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
|
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
|
|
}
|
|
if (!('history' in value)) {
|
|
throw new Error('invalid_device_history_payload');
|
|
}
|
|
return value.history;
|
|
}
|
|
|
|
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
|
|
if (!profile) {
|
|
return null;
|
|
}
|
|
return JSON.stringify({
|
|
schemaVersion: DEVICE_PAYLOAD_SCHEMA_VERSION,
|
|
profile,
|
|
});
|
|
}
|
|
|
|
function serializeDeviceHistory(history: DeviceProfileVersion[] | undefined): string | null {
|
|
if (!history || history.length === 0) {
|
|
return null;
|
|
}
|
|
return JSON.stringify({
|
|
schemaVersion: DEVICE_PAYLOAD_SCHEMA_VERSION,
|
|
history,
|
|
});
|
|
}
|
|
|
|
function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefined {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized: DeviceProfileVersion[] = [];
|
|
for (const item of value) {
|
|
if (!isRecord(item)) {
|
|
continue;
|
|
}
|
|
|
|
const profile = normalizeDeviceProfile(item.profile);
|
|
if (!profile) {
|
|
continue;
|
|
}
|
|
|
|
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
|
|
const createdAtCandidate = item.createdAt;
|
|
const createdAt =
|
|
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
|
|
? Math.floor(createdAtCandidate)
|
|
: Math.floor(Date.now() / 1000);
|
|
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
|
|
const isCurrent = item.isCurrent === true;
|
|
|
|
normalized.push({
|
|
id,
|
|
createdAt,
|
|
label,
|
|
profile,
|
|
isCurrent,
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function parseDeviceProfileColumn(value: string | null | undefined): DeviceProfile | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
throw new Error('invalid_device_profile_json');
|
|
}
|
|
const normalized = normalizeDeviceProfile(readVersionedProfilePayload(parsed));
|
|
if (!normalized) {
|
|
throw new Error('invalid_device_profile_json');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function parseDeviceHistoryColumn(
|
|
value: string | null | undefined,
|
|
): DeviceProfileVersion[] | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
throw new Error('invalid_device_history_json');
|
|
}
|
|
const payload = readVersionedHistoryPayload(parsed);
|
|
if (!Array.isArray(payload)) {
|
|
throw new Error('invalid_device_history_json');
|
|
}
|
|
const normalized = normalizeDeviceHistory(payload);
|
|
if (!normalized) {
|
|
throw new Error('invalid_device_history_json');
|
|
}
|
|
if (normalized.length !== payload.length) {
|
|
throw new Error('invalid_device_history_entry');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function createMigrationStats(): MigrationStats {
|
|
return {
|
|
totalFields: 0,
|
|
fallbackUsedFields: 0,
|
|
migratedFields: 0,
|
|
migratedBySource: {
|
|
safeStorage: 0,
|
|
keytar: 0,
|
|
file: 0,
|
|
},
|
|
failedFields: 0,
|
|
};
|
|
}
|
|
|
|
async function decryptAndMigrateField(
|
|
orm: DrizzleExecutor,
|
|
accountId: string,
|
|
field: 'tokenJson' | 'quotaJson',
|
|
value: string | null,
|
|
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
|
|
if (!value) {
|
|
return { value: null, migrated: false };
|
|
}
|
|
|
|
const result = await decryptWithMigration(value);
|
|
if (result.reencrypted) {
|
|
if (field === 'tokenJson') {
|
|
orm
|
|
.update(accounts)
|
|
.set({ tokenJson: result.reencrypted })
|
|
.where(eq(accounts.id, accountId))
|
|
.run();
|
|
} else {
|
|
orm
|
|
.update(accounts)
|
|
.set({ quotaJson: result.reencrypted })
|
|
.where(eq(accounts.id, accountId))
|
|
.run();
|
|
}
|
|
logger.info(
|
|
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
value: result.value,
|
|
migrated: Boolean(result.reencrypted),
|
|
usedFallback: result.usedFallback,
|
|
};
|
|
}
|
|
|
|
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
|
|
|
|
export class CloudAccountRepo {
|
|
private static versionFailureLogged = false;
|
|
|
|
static async init(): Promise<void> {
|
|
const dbPath = getCloudAccountsDbPath();
|
|
ensureDatabaseInitialized(dbPath);
|
|
await this.migrateToEncrypted();
|
|
}
|
|
|
|
static async migrateToEncrypted(): Promise<void> {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({
|
|
id: accounts.id,
|
|
tokenJson: accounts.tokenJson,
|
|
quotaJson: accounts.quotaJson,
|
|
})
|
|
.from(accounts)
|
|
.all();
|
|
|
|
for (const row of rows) {
|
|
let changed = false;
|
|
let newToken = row.tokenJson;
|
|
let newQuota = row.quotaJson;
|
|
|
|
// Check if plain text (starts with {)
|
|
if (newToken && newToken.startsWith('{')) {
|
|
newToken = await encrypt(newToken);
|
|
changed = true;
|
|
}
|
|
if (newQuota && newQuota.startsWith('{')) {
|
|
newQuota = await encrypt(newQuota);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
orm
|
|
.update(accounts)
|
|
.set({ tokenJson: newToken, quotaJson: newQuota })
|
|
.where(eq(accounts.id, row.id))
|
|
.run();
|
|
logger.info(`Migrated account ${row.id} to encrypted storage`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logger.error('Failed to migrate data', e);
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
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));
|
|
const quotaEncrypted = account.quota ? await encrypt(JSON.stringify(account.quota)) : null;
|
|
const values = {
|
|
id: account.id,
|
|
provider: account.provider,
|
|
email: account.email,
|
|
name: account.name ?? null,
|
|
avatarUrl: account.avatar_url ?? null,
|
|
tokenJson: tokenEncrypted,
|
|
quotaJson: quotaEncrypted,
|
|
deviceProfileJson: serializeDeviceProfile(account.device_profile),
|
|
deviceHistoryJson: serializeDeviceHistory(account.device_history),
|
|
createdAt: account.created_at,
|
|
lastUsed: account.last_used,
|
|
status: account.status || 'active',
|
|
isActive: account.is_active ? 1 : 0,
|
|
proxyUrl: account.proxy_url ?? null,
|
|
};
|
|
|
|
orm.transaction((tx) => {
|
|
// If this account is being set to active, deactivate all others first
|
|
if (account.is_active) {
|
|
logger.info(
|
|
`[DEBUG] addAccount: Deactivating all other accounts because ${account.email} is active`,
|
|
);
|
|
const info = tx.update(accounts).set({ isActive: 0 }).run();
|
|
logger.info(`[DEBUG] addAccount: Deactivation changed ${info.changes} rows`);
|
|
}
|
|
tx.insert(accounts)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: accounts.id,
|
|
set: values,
|
|
})
|
|
.run();
|
|
});
|
|
logger.info(`Added/Updated cloud account: ${account.email}`);
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static async getAccounts(): Promise<CloudAccount[]> {
|
|
const { raw, orm } = getCloudDb();
|
|
const migrationStats = createMigrationStats();
|
|
|
|
try {
|
|
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
|
|
|
// DEBUG LOGS
|
|
const activeRows = rows.filter((r) => r.isActive);
|
|
logger.info(
|
|
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
|
);
|
|
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
|
|
|
const cloudAccounts: CloudAccount[] = [];
|
|
for (const normalizedRow of rows) {
|
|
try {
|
|
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);
|
|
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
|
}
|
|
|
|
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.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.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),
|
|
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
|
});
|
|
} catch (rowError) {
|
|
logger.error(`Unexpected error processing row for account ${normalizedRow.id}`, rowError);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return cloudAccounts;
|
|
} finally {
|
|
if (
|
|
migrationStats.migratedFields > 0 ||
|
|
migrationStats.fallbackUsedFields > 0 ||
|
|
migrationStats.failedFields > 0
|
|
) {
|
|
const summary = {
|
|
totalFields: migrationStats.totalFields,
|
|
fallbackUsedFields: migrationStats.fallbackUsedFields,
|
|
migratedFields: migrationStats.migratedFields,
|
|
migratedBySource: migrationStats.migratedBySource,
|
|
failedFields: migrationStats.failedFields,
|
|
};
|
|
if (migrationStats.failedFields > 0) {
|
|
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
|
|
} else {
|
|
logger.info('CloudAccountRepo migration summary', summary);
|
|
}
|
|
}
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static async getAccount(id: string): Promise<CloudAccount | undefined> {
|
|
const { raw, orm } = getCloudDb();
|
|
|
|
try {
|
|
const rows = orm.select().from(accounts).where(eq(accounts.id, id)).all();
|
|
const normalizedRow = rows[0];
|
|
if (!normalizedRow) {
|
|
return undefined;
|
|
}
|
|
|
|
const tokenResult = await decryptAndMigrateField(
|
|
orm,
|
|
normalizedRow.id,
|
|
'tokenJson',
|
|
normalizedRow.tokenJson,
|
|
);
|
|
const quotaResult = await decryptAndMigrateField(
|
|
orm,
|
|
normalizedRow.id,
|
|
'quotaJson',
|
|
normalizedRow.quotaJson,
|
|
);
|
|
|
|
if (!tokenResult.value) {
|
|
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
|
}
|
|
|
|
return {
|
|
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,
|
|
};
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static async removeAccount(id: string): Promise<void> {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
orm.delete(accounts).where(eq(accounts.id, id)).run();
|
|
logger.info(`Removed cloud account: ${id}`);
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
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));
|
|
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));
|
|
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();
|
|
}
|
|
}
|
|
|
|
static updateLastUsed(id: string): void {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
orm
|
|
.update(accounts)
|
|
.set({ lastUsed: Math.floor(Date.now() / 1000) })
|
|
.where(eq(accounts.id, id))
|
|
.run();
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static setDeviceBinding(id: string, profile: DeviceProfile, label: string): void {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({
|
|
deviceProfileJson: accounts.deviceProfileJson,
|
|
deviceHistoryJson: accounts.deviceHistoryJson,
|
|
})
|
|
.from(accounts)
|
|
.where(eq(accounts.id, id))
|
|
.all();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new Error(`Account not found: ${id}`);
|
|
}
|
|
|
|
const boundProfile = parseDeviceProfileColumn(row.deviceProfileJson);
|
|
if (boundProfile && areDeviceProfilesEqual(boundProfile, profile)) {
|
|
logger.info(
|
|
`Skipping duplicate device profile binding for account ${id} (bound profile match)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const historyRaw = parseDeviceHistoryColumn(row.deviceHistoryJson) || [];
|
|
const currentVersion = historyRaw.find((version) => version.isCurrent);
|
|
const latestVersion = historyRaw.length > 0 ? historyRaw[historyRaw.length - 1] : undefined;
|
|
if (currentVersion && areDeviceProfilesEqual(currentVersion.profile, profile)) {
|
|
logger.info(
|
|
`Skipping duplicate device profile binding for account ${id} (history current match)`,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
!currentVersion &&
|
|
latestVersion &&
|
|
areDeviceProfilesEqual(latestVersion.profile, profile)
|
|
) {
|
|
logger.info(
|
|
`Skipping duplicate device profile binding for account ${id} (history latest match)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const history = historyRaw.map((version) => ({
|
|
...version,
|
|
isCurrent: false,
|
|
}));
|
|
|
|
history.push({
|
|
id: uuidv4(),
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
label,
|
|
profile,
|
|
isCurrent: true,
|
|
});
|
|
|
|
orm
|
|
.update(accounts)
|
|
.set({
|
|
deviceProfileJson: serializeDeviceProfile(profile),
|
|
deviceHistoryJson: serializeDeviceHistory(history),
|
|
})
|
|
.where(eq(accounts.id, id))
|
|
.run();
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static getDeviceBinding(id: string): {
|
|
profile?: DeviceProfile;
|
|
history: DeviceProfileVersion[];
|
|
} {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({
|
|
deviceProfileJson: accounts.deviceProfileJson,
|
|
deviceHistoryJson: accounts.deviceHistoryJson,
|
|
})
|
|
.from(accounts)
|
|
.where(eq(accounts.id, id))
|
|
.all();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new Error(`Account not found: ${id}`);
|
|
}
|
|
|
|
return {
|
|
profile: parseDeviceProfileColumn(row.deviceProfileJson),
|
|
history: parseDeviceHistoryColumn(row.deviceHistoryJson) || [],
|
|
};
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static restoreDeviceVersion(
|
|
id: string,
|
|
versionId: string,
|
|
baseline: DeviceProfile | null,
|
|
): DeviceProfile {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({
|
|
deviceProfileJson: accounts.deviceProfileJson,
|
|
deviceHistoryJson: accounts.deviceHistoryJson,
|
|
})
|
|
.from(accounts)
|
|
.where(eq(accounts.id, id))
|
|
.all();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new Error(`Account not found: ${id}`);
|
|
}
|
|
|
|
const currentProfile = parseDeviceProfileColumn(row.deviceProfileJson);
|
|
const history = parseDeviceHistoryColumn(row.deviceHistoryJson) || [];
|
|
|
|
let targetProfile: DeviceProfile;
|
|
if (versionId === 'baseline') {
|
|
if (!baseline) {
|
|
throw new Error('Global original profile not found');
|
|
}
|
|
targetProfile = baseline;
|
|
} else if (versionId === 'current') {
|
|
if (!currentProfile) {
|
|
throw new Error('No currently bound profile');
|
|
}
|
|
targetProfile = currentProfile;
|
|
} else {
|
|
const targetVersion = history.find((version) => version.id === versionId);
|
|
if (!targetVersion) {
|
|
throw new Error('Device profile version not found');
|
|
}
|
|
targetProfile = targetVersion.profile;
|
|
}
|
|
|
|
const nextHistory = history.map((version) => ({
|
|
...version,
|
|
isCurrent: version.id === versionId,
|
|
}));
|
|
|
|
orm
|
|
.update(accounts)
|
|
.set({
|
|
deviceProfileJson: serializeDeviceProfile(targetProfile),
|
|
deviceHistoryJson: serializeDeviceHistory(nextHistory),
|
|
})
|
|
.where(eq(accounts.id, id))
|
|
.run();
|
|
|
|
return targetProfile;
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static deleteDeviceVersion(id: string, versionId: string): void {
|
|
if (versionId === 'baseline') {
|
|
throw new Error('Original profile cannot be deleted');
|
|
}
|
|
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({ deviceHistoryJson: accounts.deviceHistoryJson })
|
|
.from(accounts)
|
|
.where(eq(accounts.id, id))
|
|
.all();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new Error(`Account not found: ${id}`);
|
|
}
|
|
|
|
const history = parseDeviceHistoryColumn(row.deviceHistoryJson) || [];
|
|
if (history.some((version) => version.id === versionId && version.isCurrent)) {
|
|
throw new Error('Currently bound profile cannot be deleted');
|
|
}
|
|
|
|
const nextHistory = history.filter((version) => version.id !== versionId);
|
|
if (nextHistory.length === history.length) {
|
|
throw new Error('Historical device profile not found');
|
|
}
|
|
|
|
orm
|
|
.update(accounts)
|
|
.set({ deviceHistoryJson: serializeDeviceHistory(nextHistory) })
|
|
.where(eq(accounts.id, id))
|
|
.run();
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static setActive(id: string): void {
|
|
const { raw, orm } = getCloudDb();
|
|
|
|
try {
|
|
orm.transaction((tx) => {
|
|
tx.update(accounts).set({ isActive: 0 }).run();
|
|
tx.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, id)).run();
|
|
});
|
|
logger.info(`Set account ${id} as active`);
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
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 })
|
|
.onConflictDoUpdate({
|
|
target: itemTable.key,
|
|
set: { value },
|
|
})
|
|
.run();
|
|
}
|
|
|
|
private static writeAuthStatusAndCleanup(db: DrizzleExecutor, account: CloudAccount): void {
|
|
const authStatus = {
|
|
name: account.name || account.email,
|
|
email: account.email,
|
|
apiKey: account.token.access_token,
|
|
};
|
|
|
|
this.upsertItemValue(db, 'antigravityAuthStatus', JSON.stringify(authStatus));
|
|
this.upsertItemValue(db, 'antigravityOnboarding', 'true');
|
|
db.delete(itemTable).where(eq(itemTable.key, 'google.antigravity')).run();
|
|
}
|
|
|
|
private static getItemValue(db: DrizzleExecutor, key: string, context: string): string | null {
|
|
const rows = db
|
|
.select({ value: itemTable.value })
|
|
.from(itemTable)
|
|
.where(eq(itemTable.key, key))
|
|
.all();
|
|
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
private static injectNewFormat(
|
|
orm: BetterSQLite3Database<typeof drizzleSchema>,
|
|
account: CloudAccount,
|
|
): void {
|
|
const oauthToken = ProtobufUtils.createUnifiedOAuthToken(
|
|
account.token.access_token,
|
|
account.token.refresh_token,
|
|
account.token.expiry_timestamp,
|
|
);
|
|
|
|
orm.transaction((tx) => {
|
|
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
|
this.writeAuthStatusAndCleanup(tx, account);
|
|
});
|
|
}
|
|
|
|
private static injectOldFormat(
|
|
orm: BetterSQLite3Database<typeof drizzleSchema>,
|
|
account: CloudAccount,
|
|
): void {
|
|
const value = this.getItemValue(
|
|
orm,
|
|
'jetskiStateSync.agentManagerInitState',
|
|
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
|
);
|
|
|
|
orm.transaction((tx) => {
|
|
if (!value) {
|
|
logger.warn(
|
|
'jetskiStateSync.agentManagerInitState not found. ' +
|
|
'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.',
|
|
);
|
|
|
|
this.writeAuthStatusAndCleanup(tx, account);
|
|
|
|
logger.info(
|
|
`Injected minimal auth state for ${account.email} (no protobuf state available)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const buffer = Buffer.from(value, 'base64');
|
|
const data = new Uint8Array(buffer);
|
|
const cleanData = ProtobufUtils.removeField(data, 6);
|
|
const newField = ProtobufUtils.createOAuthTokenInfo(
|
|
account.token.access_token,
|
|
account.token.refresh_token,
|
|
account.token.expiry_timestamp,
|
|
);
|
|
|
|
const finalData = new Uint8Array(cleanData.length + newField.length);
|
|
finalData.set(cleanData, 0);
|
|
finalData.set(newField, cleanData.length);
|
|
|
|
const finalB64 = Buffer.from(finalData).toString('base64');
|
|
|
|
tx.update(itemTable)
|
|
.set({ value: finalB64 })
|
|
.where(eq(itemTable.key, 'jetskiStateSync.agentManagerInitState'))
|
|
.run();
|
|
|
|
this.writeAuthStatusAndCleanup(tx, account);
|
|
});
|
|
}
|
|
|
|
private static detectFormatCapability(db: DrizzleExecutor): 'new' | 'old' | null {
|
|
const unifiedValue = this.getItemValue(
|
|
db,
|
|
'antigravityUnifiedStateSync.oauthToken',
|
|
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
|
);
|
|
if (unifiedValue) {
|
|
return 'new';
|
|
}
|
|
|
|
const oldValue = this.getItemValue(
|
|
db,
|
|
'jetskiStateSync.agentManagerInitState',
|
|
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
|
);
|
|
if (oldValue) {
|
|
return 'old';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static resolveInjectionStrategy(db: DrizzleExecutor): {
|
|
name: 'new' | 'old' | 'dual';
|
|
reason: string;
|
|
} {
|
|
try {
|
|
const version = getAntigravityVersion();
|
|
return {
|
|
name: isNewVersion(version) ? 'new' : 'old',
|
|
reason: `version:${version.shortVersion}`,
|
|
};
|
|
} catch (error) {
|
|
if (!this.versionFailureLogged) {
|
|
logger.warn('Version detection failed, falling back to capability detection', error);
|
|
this.versionFailureLogged = true;
|
|
}
|
|
}
|
|
|
|
const capability = this.detectFormatCapability(db);
|
|
if (capability) {
|
|
return { name: capability, reason: 'capability' };
|
|
}
|
|
|
|
return { name: 'dual', reason: 'fallback' };
|
|
}
|
|
|
|
private static getStrategy(name: 'new' | 'old'): {
|
|
name: 'new' | 'old';
|
|
inject: (db: BetterSQLite3Database<typeof drizzleSchema>, account: CloudAccount) => void;
|
|
} {
|
|
if (name === 'new') {
|
|
return { name, inject: (db, account) => this.injectNewFormat(db, account) };
|
|
}
|
|
return { name, inject: (db, account) => this.injectOldFormat(db, account) };
|
|
}
|
|
|
|
private static injectWithRetry(
|
|
dbPath: string,
|
|
account: CloudAccount,
|
|
): { strategy: string; attempts: number } {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
|
const { raw, orm } = getIdeDb(dbPath, false);
|
|
try {
|
|
const { name, reason } = this.resolveInjectionStrategy(orm);
|
|
if (name === 'dual') {
|
|
let newInjected = false;
|
|
let oldInjected = false;
|
|
|
|
try {
|
|
this.injectNewFormat(orm, account);
|
|
newInjected = true;
|
|
} catch (newError) {
|
|
logger.warn('Failed to inject new format', newError);
|
|
}
|
|
|
|
try {
|
|
this.injectOldFormat(orm, account);
|
|
oldInjected = true;
|
|
} catch (oldError) {
|
|
logger.warn('Failed to inject old format', oldError);
|
|
}
|
|
|
|
if (!newInjected && !oldInjected) {
|
|
throw new Error('Token injection failed for both formats');
|
|
}
|
|
|
|
return { strategy: `dual:${reason}`, attempts: attempt };
|
|
}
|
|
|
|
const strategy = this.getStrategy(name);
|
|
strategy.inject(orm, account);
|
|
return { strategy: `${strategy.name}:${reason}`, attempts: attempt };
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (isSqliteBusyError(error) && attempt < SQLITE_MAX_RETRIES) {
|
|
logger.warn(`SQLite busy, retrying injection (attempt ${attempt})`, error);
|
|
sleepSync(SQLITE_RETRY_DELAY_MS);
|
|
continue;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
static injectCloudToken(account: CloudAccount): void {
|
|
const dbPaths = getAntigravityDbPaths();
|
|
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
|
|
|
if (!dbPath) {
|
|
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
|
}
|
|
|
|
const result = this.injectWithRetry(dbPath, account);
|
|
logger.info(
|
|
`Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
|
);
|
|
}
|
|
|
|
static getSetting<T>(key: string, defaultValue: T): T {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const rows = orm
|
|
.select({ value: settings.value })
|
|
.from(settings)
|
|
.where(eq(settings.key, key))
|
|
.all();
|
|
const row = rows[0];
|
|
if (!row) {
|
|
return defaultValue;
|
|
}
|
|
return JSON.parse(row.value) as T;
|
|
} catch (e) {
|
|
logger.error(`Failed to get setting ${key}`, e);
|
|
return defaultValue;
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
static setSetting(key: string, value: any): void {
|
|
const { raw, orm } = getCloudDb();
|
|
try {
|
|
const stringValue = JSON.stringify(value);
|
|
orm
|
|
.insert(settings)
|
|
.values({ key, value: stringValue })
|
|
.onConflictDoUpdate({
|
|
target: settings.key,
|
|
set: { value: stringValue },
|
|
})
|
|
.run();
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
|
|
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
} {
|
|
const unifiedValue = this.getItemValue(
|
|
db,
|
|
'antigravityUnifiedStateSync.oauthToken',
|
|
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
|
);
|
|
|
|
let tokenInfo: { accessToken: string; refreshToken: string } | null = null;
|
|
if (unifiedValue) {
|
|
try {
|
|
const unifiedBuffer = Buffer.from(unifiedValue, 'base64');
|
|
const unifiedData = new Uint8Array(unifiedBuffer);
|
|
tokenInfo = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(unifiedData);
|
|
} catch (error) {
|
|
logger.warn('SyncLocal: Failed to parse unified OAuth token', error);
|
|
}
|
|
}
|
|
|
|
if (!tokenInfo) {
|
|
const value = this.getItemValue(
|
|
db,
|
|
'jetskiStateSync.agentManagerInitState',
|
|
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
|
);
|
|
|
|
if (!value) {
|
|
const errorMsg =
|
|
'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.';
|
|
logger.warn(`SyncLocal: ${errorMsg}`);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const buffer = Buffer.from(value, 'base64');
|
|
const data = new Uint8Array(buffer);
|
|
tokenInfo = ProtobufUtils.extractOAuthTokenInfo(data);
|
|
}
|
|
|
|
if (!tokenInfo) {
|
|
const errorMsg =
|
|
'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.';
|
|
logger.warn(`SyncLocal: ${errorMsg}`);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
return tokenInfo;
|
|
}
|
|
|
|
private static readTokenInfoWithRetry(dbPath: string): {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
} {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
|
const { raw, orm } = getIdeDb(dbPath, true);
|
|
try {
|
|
return this.readTokenInfoFromDb(orm);
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (isSqliteBusyError(error) && attempt < SQLITE_MAX_RETRIES) {
|
|
logger.warn(`SQLite busy, retrying IDE read (attempt ${attempt})`, error);
|
|
sleepSync(SQLITE_RETRY_DELAY_MS);
|
|
continue;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
raw.close();
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
static async syncFromIDE(): Promise<CloudAccount | null> {
|
|
// Try all possible database paths
|
|
const dbPaths = getAntigravityDbPaths();
|
|
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
|
|
|
const dbPath =
|
|
dbPaths.find((p) => {
|
|
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
|
return fs.existsSync(p);
|
|
}) ?? null;
|
|
|
|
if (!dbPath) {
|
|
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
|
logger.error(errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
|
try {
|
|
const tokenInfo = this.readTokenInfoWithRetry(dbPath);
|
|
|
|
// 3. Fetch User Info
|
|
// We need to fetch user info to know who this token belongs to
|
|
let userInfo;
|
|
try {
|
|
userInfo = await GoogleAPIService.getUserInfo(tokenInfo.accessToken);
|
|
} catch (apiError: any) {
|
|
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE. Error: ${apiError.message}`;
|
|
logger.error(`SyncLocal: ${errorMsg}`, apiError);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
// 4. Check Duplicate & Construct Account
|
|
// We use existing addAccount logic which does UPSERT (REPLACE)
|
|
// Construct CloudAccount object
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const account: CloudAccount = {
|
|
id: uuidv4(), // Generate new ID if new, but check existing email
|
|
provider: 'google',
|
|
email: userInfo.email,
|
|
name: userInfo.name,
|
|
avatar_url: userInfo.picture,
|
|
token: {
|
|
access_token: tokenInfo.accessToken,
|
|
refresh_token: tokenInfo.refreshToken,
|
|
expires_in: 3600, // Unknown, assume 1 hour validity or let it refresh
|
|
expiry_timestamp: now + 3600,
|
|
token_type: 'Bearer',
|
|
email: userInfo.email,
|
|
},
|
|
created_at: now,
|
|
last_used: now,
|
|
status: 'active',
|
|
is_active: true, // It is the active one in IDE
|
|
};
|
|
|
|
// Check if email already exists to preserve ID
|
|
const accounts = await this.getAccounts();
|
|
const existing = accounts.find((a) => a.email === account.email);
|
|
if (existing) {
|
|
account.id = existing.id; // Keep existing ID
|
|
account.created_at = existing.created_at;
|
|
}
|
|
|
|
await this.addAccount(account);
|
|
return account;
|
|
} catch (error) {
|
|
logger.error('SyncLocal: Failed to sync account from IDE', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|