import fs from 'fs'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { getAccountsFilePath, getBackupsDir } from '../../utils/paths'; import { logger } from '../../utils/logger'; import { Account, AccountBackupData, DeviceProfile, DeviceProfilesSnapshot, DeviceProfileVersion, } from '../../types/account'; import { backupAccount as dbBackup, restoreAccount as dbRestore, getCurrentAccountInfo, } from '../database/handler'; import { applyDeviceProfile, ensureGlobalOriginalFromCurrentStorage, generateDeviceProfile, isIdentityProfileApplyEnabled, loadGlobalOriginalProfile, readCurrentDeviceProfile, saveGlobalOriginalProfile, getStorageDirectoryPath, } from '../device/handler'; import { runWithSwitchGuard } from '../switchGuard'; import { executeSwitchFlow } from '../switchFlow'; import { shell } from 'electron'; type AccountIndex = Record; const SWITCH_EXIT_TIMEOUT_MS = 10000; function getDeviceHistory(account: Account): DeviceProfileVersion[] { if (!account.deviceHistory) { account.deviceHistory = []; } return account.deviceHistory; } function bindDeviceProfileToAccount( account: Account, profile: DeviceProfile, label: string, addHistory: boolean, ): void { account.deviceProfile = profile; if (!addHistory) { return; } const history = getDeviceHistory(account); for (const version of history) { version.isCurrent = false; } history.push({ id: uuidv4(), createdAt: Math.floor(Date.now() / 1000), label, profile, isCurrent: true, }); } /** * Loads the accounts index from the file system. * @returns {AccountIndex} The accounts index. */ function loadAccountsIndex(): AccountIndex { const filePath = getAccountsFilePath(); if (!fs.existsSync(filePath)) { return {}; } try { const content = fs.readFileSync(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { logger.error('Failed to load accounts index', error); return {}; } } /** * Saves the accounts index to the file system. * @param accounts {AccountIndex} The accounts index to save. * @throws {Error} If the accounts index cannot be saved. */ function saveAccountsIndex(accounts: AccountIndex): void { const filePath = getAccountsFilePath(); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } try { fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2)); } catch (error) { logger.error('Failed to save accounts index', error); throw error; } } /** * Lists the accounts data. * @returns {Account[]} The list of accounts. * @throws {Error} If the accounts index cannot be loaded. */ export async function listAccountsData(): Promise { const accountsObj = loadAccountsIndex(); const accountsList = Object.values(accountsObj); // NOTE: Sort by last_used descending accountsList.sort((a, b) => { const aTime = a.last_used || ''; const bTime = b.last_used || ''; return bTime.localeCompare(aTime); }); return accountsList; } /** * Adds an account snapshot. * @returns {Account} The added account. * @throws {Error} If the account cannot be added. */ export async function addAccountSnapshot(): Promise { logger.info('Adding account snapshot...'); // NOTE Get current account info from DB const info = getCurrentAccountInfo(); if (!info.isAuthenticated) { const errorMsg = 'No authenticated account found. Please ensure Gemini Nexus is running and you are logged in.'; logger.error(errorMsg); throw new Error(errorMsg); } const accounts = loadAccountsIndex(); const now = new Date().toISOString(); // NOTE Find existing account by email let existingId: string | null = null; for (const [id, acc] of Object.entries(accounts)) { if (acc.email === info.email) { existingId = id; break; } } let account: Account; let backupPath: string; if (existingId) { // NOTE Update existing account account = accounts[existingId]; // NOTE Preserve custom name: only update if we have a name from DB AND it's not the default email prefix // NOTE if not name or name == email.split("@")[0]: name = existing_account.get("name", name) const defaultName = info.email.split('@')[0]; if (!info.name || info.name === defaultName) { // NOTE Keep the existing custom name // (account.name is already set, no change needed) } else { // NOTE We have a non-default name from DB, use it account.name = info.name; } account.last_used = now; // NOTE Use existing backup path if available, otherwise generate new one backupPath = account.backup_file || path.join(getBackupsDir(), `${account.id}.json`); logger.info(`Updating existing account: ${info.email}`); } else { const accountId = uuidv4(); // NOTE Generate name with edge case handling let accountName: string; if (info.name) { accountName = info.name; } else if (info.email && info.email !== 'Unknown') { accountName = info.email.split('@')[0]; } else { // Edge case: email is "Unknown" or invalid accountName = `Account_${Date.now()}`; } backupPath = path.join(getBackupsDir(), `${accountId}.json`); account = { id: accountId, name: accountName, email: info.email, backup_file: backupPath, deviceHistory: [], created_at: now, last_used: now, }; accounts[accountId] = account; logger.info(`Creating new account: ${info.email}`); } // NOTE Backup data from DB const backupData = dbBackup(account); // NOTE Save backup file const backupsDir = getBackupsDir(); if (!fs.existsSync(backupsDir)) { fs.mkdirSync(backupsDir, { recursive: true }); } fs.writeFileSync(backupPath, JSON.stringify(backupData, null, 2)); // NOTE Update backup_file in account object and save account.backup_file = backupPath; saveAccountsIndex(accounts); return account; } /** * Switches to an account. * @param accountId {string} The ID of the account to switch to. * @throws {Error} If the account cannot be found or the backup file cannot be found. */ export async function switchAccount(accountId: string): Promise { await runWithSwitchGuard('local-account-switch', async () => { logger.info(`Switching to account: ${accountId}`); const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } // NOTE Get backup file path from account data const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`); if (!fs.existsSync(backupPath)) { throw new Error(`Backup file not found: ${backupPath}`); } ensureGlobalOriginalFromCurrentStorage(); if (!account.deviceProfile) { const generated = generateDeviceProfile(); saveGlobalOriginalProfile(generated); bindDeviceProfileToAccount(account, generated, 'auto_generated', true); } await executeSwitchFlow({ scope: 'local', targetProfile: account.deviceProfile || null, applyFingerprint: isIdentityProfileApplyEnabled(), processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS, performSwitch: async () => { // NOTE Load backup file const backupContent = fs.readFileSync(backupPath, 'utf-8'); const backupData: AccountBackupData = JSON.parse(backupContent); // NOTE Restore data to DB dbRestore(backupData); // NOTE Update last used account.last_used = new Date().toISOString(); saveAccountsIndex(accounts); }, }); }); } export async function previewGenerateIdentityProfile(): Promise { return generateDeviceProfile(); } export async function getIdentityProfiles(accountId: string): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } let currentStorage: DeviceProfile | undefined; try { currentStorage = readCurrentDeviceProfile(); } catch (error) { logger.warn('Failed to read current storage device profile', error); } return { currentStorage, boundProfile: account.deviceProfile, history: account.deviceHistory || [], baseline: loadGlobalOriginalProfile() || undefined, }; } export async function bindIdentityProfile( accountId: string, mode: 'capture' | 'generate', ): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } let profile: DeviceProfile; if (mode === 'capture') { profile = readCurrentDeviceProfile(); } else { profile = generateDeviceProfile(); } ensureGlobalOriginalFromCurrentStorage(); saveGlobalOriginalProfile(profile); applyDeviceProfile(profile); bindDeviceProfileToAccount(account, profile, mode, true); saveAccountsIndex(accounts); return profile; } export async function bindIdentityProfileWithPayload( accountId: string, profile: DeviceProfile, ): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } ensureGlobalOriginalFromCurrentStorage(); saveGlobalOriginalProfile(profile); applyDeviceProfile(profile); bindDeviceProfileToAccount(account, profile, 'generated', true); saveAccountsIndex(accounts); return profile; } export async function applyBoundIdentityProfile(accountId: string): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } if (!account.deviceProfile) { throw new Error('Account has no bound device profile'); } applyDeviceProfile(account.deviceProfile); account.last_used = new Date().toISOString(); saveAccountsIndex(accounts); return account.deviceProfile; } export async function restoreIdentityProfileRevision( accountId: string, versionId: string, ): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } let targetProfile: DeviceProfile | null = null; if (versionId === 'baseline') { targetProfile = loadGlobalOriginalProfile(); if (!targetProfile) { throw new Error('Global original profile not found'); } for (const version of getDeviceHistory(account)) { version.isCurrent = false; } } else if (versionId === 'current') { targetProfile = account.deviceProfile || null; if (!targetProfile) { throw new Error('No currently bound profile'); } } else { const history = getDeviceHistory(account); const targetVersion = history.find((version) => version.id === versionId); if (!targetVersion) { throw new Error('Device profile version not found'); } targetProfile = targetVersion.profile; for (const version of history) { version.isCurrent = version.id === versionId; } } applyDeviceProfile(targetProfile); account.deviceProfile = targetProfile; saveAccountsIndex(accounts); return targetProfile; } export async function deleteIdentityProfileRevision( accountId: string, versionId: string, ): Promise { if (versionId === 'baseline') { throw new Error('Original profile cannot be deleted'); } const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } const history = getDeviceHistory(account); if (history.some((version) => version.id === versionId && version.isCurrent)) { throw new Error('Currently bound profile cannot be deleted'); } const before = history.length; account.deviceHistory = history.filter((version) => version.id !== versionId); if (account.deviceHistory.length === before) { throw new Error('Historical device profile not found'); } saveAccountsIndex(accounts); } export async function restoreBaselineProfile(accountId: string): Promise { const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } const baseline = loadGlobalOriginalProfile(); if (!baseline) { throw new Error('Global original profile not found'); } account.deviceProfile = baseline; for (const version of getDeviceHistory(account)) { version.isCurrent = false; } saveAccountsIndex(accounts); return baseline; } export async function openIdentityStorageFolder(): Promise { const directory = getStorageDirectoryPath(); const result = await shell.openPath(directory); if (result) { throw new Error(`Failed to open device folder: ${result}`); } } /** * Deletes an account. * @param accountId {string} The ID of the account to delete. * @throws {Error} If the account cannot be found or the backup file cannot be found. */ export async function deleteAccount(accountId: string): Promise { logger.info(`Deleting account: ${accountId}`); const accounts = loadAccountsIndex(); const account = accounts[accountId]; if (!account) { throw new Error(`Account not found: ${accountId}`); } // NOTE Remove backup file using stored path const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`); if (fs.existsSync(backupPath)) { try { fs.unlinkSync(backupPath); logger.info(`Backup file deleted: ${backupPath}`); } catch (error) { logger.warn(`Failed to delete backup file: ${backupPath}`, error); } } // NOTE Remove from index delete accounts[accountId]; saveAccountsIndex(accounts); }