+
+
+
+
{t('cloud.title')}
+
{t('cloud.description')}
+
+
+
+
{t('cloud.card.actions')}
+
{totalAccounts}
-
+
+
{t('cloud.card.active')}
+
{activeAccounts}
+
+
+
{t('cloud.card.rateLimited')}
+
{rateLimitedAccounts}
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{accounts?.map((account) => (
- {t('cloud.list.noAccounts')}
+
+
+
{t('cloud.list.noAccounts')}
)}
{/* Batch Action Bar */}
{selectedIds.size > 0 && (
-
+
{t('cloud.batch.selected', { count: selectedIds.size })}
@@ -452,6 +483,16 @@ export function CloudAccountList() {
)}
+
+
{
+ if (!nextOpen) {
+ setIdentityAccount(null);
+ }
+ }}
+ />
);
}
diff --git a/src/components/IdentityProfileDialog.tsx b/src/components/IdentityProfileDialog.tsx
new file mode 100644
index 0000000..97014cb
--- /dev/null
+++ b/src/components/IdentityProfileDialog.tsx
@@ -0,0 +1,428 @@
+import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Fingerprint,
+ FolderOpen,
+ RotateCcw,
+ Wand2,
+ Trash2,
+ History,
+ ShieldCheck,
+ Cpu,
+} from 'lucide-react';
+import type { CloudAccount } from '@/types/cloudAccount';
+import type { DeviceProfile, DeviceProfilesSnapshot, DeviceProfileVersion } from '@/types/account';
+import {
+ bindCloudIdentityProfile,
+ bindCloudIdentityProfileWithPayload,
+ deleteCloudIdentityProfileRevision,
+ getCloudIdentityProfiles,
+ openCloudIdentityStorageFolder,
+ previewGenerateCloudIdentityProfile,
+ restoreCloudIdentityProfileRevision,
+ restoreCloudBaselineProfile,
+} from '@/actions/cloud';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { useToast } from '@/components/ui/use-toast';
+
+interface IdentityProfileDialogProps {
+ account: CloudAccount | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+function formatError(error: unknown): string {
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return String(error);
+}
+
+function renderProfile(profile?: DeviceProfile): JSX.Element {
+ if (!profile) {
+ return (
+
+ N/A
+
+ );
+ }
+
+ const rows: Array<{ label: string; value: string }> = [
+ { label: 'machineId', value: profile.machineId },
+ { label: 'macMachineId', value: profile.macMachineId },
+ { label: 'devDeviceId', value: profile.devDeviceId },
+ { label: 'sqmId', value: profile.sqmId },
+ ];
+
+ return (
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+ );
+}
+
+function sortHistory(history: DeviceProfileVersion[]): DeviceProfileVersion[] {
+ return [...history].sort((a, b) => b.createdAt - a.createdAt);
+}
+
+export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityProfileDialogProps) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const [snapshot, setSnapshot] = useState
(null);
+ const [initialLoading, setInitialLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [actionKey, setActionKey] = useState(null);
+ const [previewProfile, setPreviewProfile] = useState(null);
+ const actionLockRef = useRef(false);
+
+ const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
+ const showLoadingPlaceholder = initialLoading && !snapshot;
+
+ const refreshProfiles = async (options?: { silent?: boolean }) => {
+ if (!account) {
+ setSnapshot(null);
+ return;
+ }
+ const silent = options?.silent === true;
+ if (silent) {
+ setRefreshing(true);
+ } else {
+ setInitialLoading(true);
+ }
+ try {
+ const result = await getCloudIdentityProfiles({ accountId: account.id });
+ setSnapshot(result);
+ } catch (error) {
+ toast({
+ title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
+ description: formatError(error),
+ variant: 'destructive',
+ });
+ } finally {
+ if (silent) {
+ setRefreshing(false);
+ } else {
+ setInitialLoading(false);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ refreshProfiles();
+ }, [open, account?.id]);
+
+ const runAction = async (key: string, action: () => Promise) => {
+ if (actionLockRef.current) {
+ return;
+ }
+ actionLockRef.current = true;
+ setActionKey(key);
+ try {
+ await action();
+ await refreshProfiles({ silent: true });
+ } catch (error) {
+ toast({
+ title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
+ description: formatError(error),
+ variant: 'destructive',
+ });
+ } finally {
+ actionLockRef.current = false;
+ setActionKey(null);
+ }
+ };
+
+ if (!account) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..fd2d1ad
--- /dev/null
+++ b/src/components/ui/toaster.tsx
@@ -0,0 +1,31 @@
+import { useToast } from '@/components/ui/use-toast';
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from '@/components/ui/toast';
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title ? {title} : null}
+ {description ? {description} : null}
+
+ {action}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/ipc/account/handler.ts b/src/ipc/account/handler.ts
index e2ea745..5089cee 100644
--- a/src/ipc/account/handler.ts
+++ b/src/ipc/account/handler.ts
@@ -3,15 +3,66 @@ import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
import { logger } from '../../utils/logger';
-import { Account, AccountBackupData } from '../../types/account';
+import {
+ Account,
+ AccountBackupData,
+ DeviceProfile,
+ DeviceProfilesSnapshot,
+ DeviceProfileVersion,
+} from '../../types/account';
import {
backupAccount as dbBackup,
restoreAccount as dbRestore,
getCurrentAccountInfo,
} from '../database/handler';
-import { closeAntigravity, startAntigravity } from '../process/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.
@@ -141,6 +192,7 @@ export async function addAccountSnapshot(): Promise {
name: accountName,
email: info.email,
backup_file: backupPath,
+ deviceHistory: [],
created_at: now,
last_used: now,
};
@@ -171,7 +223,184 @@ export async function addAccountSnapshot(): Promise {
* @throws {Error} If the account cannot be found or the backup file cannot be found.
*/
export async function switchAccount(accountId: string): Promise {
- logger.info(`Switching to account: ${accountId}`);
+ 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];
@@ -179,34 +408,47 @@ export async function switchAccount(accountId: string): Promise {
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}`);
+ const history = getDeviceHistory(account);
+ if (history.some((version) => version.id === versionId && version.isCurrent)) {
+ throw new Error('Currently bound profile cannot be deleted');
}
- // NOTE Close Antigravity (with error handling)
- try {
- await closeAntigravity();
- } catch (error) {
- // NOTE Continue even if close fails
- logger.warn('Unable to close Antigravity, attempting forced restore...', error);
+ 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');
}
- // NOTE Load backup file
- const backupContent = fs.readFileSync(backupPath, 'utf-8');
- const backupData: AccountBackupData = JSON.parse(backupContent);
+ saveAccountsIndex(accounts);
+}
- // NOTE Restore data to DB
- dbRestore(backupData);
+export async function restoreBaselineProfile(accountId: string): Promise {
+ const accounts = loadAccountsIndex();
+ const account = accounts[accountId];
+ if (!account) {
+ throw new Error(`Account not found: ${accountId}`);
+ }
- // NOTE Update last used
- account.last_used = new Date().toISOString();
+ 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);
- // NOTE Start Antigravity
- await startAntigravity();
+ 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}`);
+ }
}
/**
diff --git a/src/ipc/account/router.ts b/src/ipc/account/router.ts
index f329c8b..79d725d 100644
--- a/src/ipc/account/router.ts
+++ b/src/ipc/account/router.ts
@@ -1,7 +1,25 @@
import { z } from 'zod';
import { os } from '@orpc/server';
-import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
-import { AccountSchema } from '../../types/account';
+import {
+ applyBoundIdentityProfile,
+ addAccountSnapshot,
+ bindIdentityProfile,
+ bindIdentityProfileWithPayload,
+ deleteAccount,
+ deleteIdentityProfileRevision,
+ getIdentityProfiles,
+ listAccountsData,
+ previewGenerateIdentityProfile,
+ restoreBaselineProfile,
+ restoreIdentityProfileRevision,
+ switchAccount,
+ openIdentityStorageFolder,
+} from './handler';
+import {
+ AccountSchema,
+ DeviceProfileSchema,
+ DeviceProfilesSnapshotSchema,
+} from '../../types/account';
export const accountRouter = os.router({
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
@@ -25,4 +43,61 @@ export const accountRouter = os.router({
.handler(async ({ input }) => {
await deleteAccount(input.accountId);
}),
+
+ previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
+ return previewGenerateIdentityProfile();
+ }),
+
+ getIdentityProfiles: os
+ .input(z.object({ accountId: z.string() }))
+ .output(DeviceProfilesSnapshotSchema)
+ .handler(async ({ input }) => {
+ return getIdentityProfiles(input.accountId);
+ }),
+
+ bindIdentityProfile: os
+ .input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return bindIdentityProfile(input.accountId, input.mode);
+ }),
+
+ bindIdentityProfileWithPayload: os
+ .input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return bindIdentityProfileWithPayload(input.accountId, input.profile);
+ }),
+
+ applyBoundIdentityProfile: os
+ .input(z.object({ accountId: z.string() }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return applyBoundIdentityProfile(input.accountId);
+ }),
+
+ restoreIdentityProfileRevision: os
+ .input(z.object({ accountId: z.string(), versionId: z.string() }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return restoreIdentityProfileRevision(input.accountId, input.versionId);
+ }),
+
+ deleteIdentityProfileRevision: os
+ .input(z.object({ accountId: z.string(), versionId: z.string() }))
+ .output(z.void())
+ .handler(async ({ input }) => {
+ await deleteIdentityProfileRevision(input.accountId, input.versionId);
+ }),
+
+ restoreBaselineProfile: os
+ .input(z.object({ accountId: z.string() }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return restoreBaselineProfile(input.accountId);
+ }),
+
+ openIdentityStorageFolder: os.output(z.void()).handler(async () => {
+ await openIdentityStorageFolder();
+ }),
});
diff --git a/src/ipc/cloud/handler.ts b/src/ipc/cloud/handler.ts
index 9ae0b8b..7a8d36d 100644
--- a/src/ipc/cloud/handler.ts
+++ b/src/ipc/cloud/handler.ts
@@ -6,9 +6,20 @@ import { logger } from '../../utils/logger';
import { shell } from 'electron';
import fs from 'fs';
-import { closeAntigravity, startAntigravity, _waitForProcessExit } from '../../ipc/process/handler';
import { updateTrayMenu } from '../../ipc/tray/handler';
+import {
+ ensureGlobalOriginalFromCurrentStorage,
+ generateDeviceProfile,
+ getStorageDirectoryPath,
+ isIdentityProfileApplyEnabled,
+ loadGlobalOriginalProfile,
+ readCurrentDeviceProfile,
+ saveGlobalOriginalProfile,
+} from '../../ipc/device/handler';
import { getAntigravityDbPaths } from '../../utils/paths';
+import { runWithSwitchGuard } from '../../ipc/switchGuard';
+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';
@@ -169,74 +180,176 @@ export async function refreshAccountQuota(accountId: string): Promise {
- try {
- const account = await CloudAccountRepo.getAccount(accountId);
- if (!account) {
- throw new Error(`Account not found: ${accountId}`);
- }
-
- logger.info(`Switching to cloud account: ${account.email} (${account.id})`);
-
- // 1. Ensure token is fresh before injecting
- const now = Math.floor(Date.now() / 1000);
- if (account.token.expiry_timestamp < now + 300) {
- logger.info(`Token for ${account.email} near expiry, refreshing before switch...`);
- try {
- const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
- 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);
- } catch (e) {
- logger.warn('Failed to refresh token before switch, trying with existing token', e);
- }
- }
-
- // 2. Stop Antigravity Process
- await closeAntigravity();
+ await runWithSwitchGuard('cloud-account-switch', async () => {
try {
- await _waitForProcessExit(10000); // Wait up to 10s for it to truly vanish
- } catch (e) {
- logger.warn('Process did not exit cleanly within timeout, but proceeding...', e);
- }
-
- // 3. Backup Database (New Logic)
- const dbPaths = getAntigravityDbPaths();
- // Find the valid DB path
- let dbPath: string | null = null;
- for (const p of dbPaths) {
- if (fs.existsSync(p)) {
- dbPath = p;
- break;
+ const account = await CloudAccountRepo.getAccount(accountId);
+ if (!account) {
+ throw new Error(`Account not found: ${accountId}`);
}
- }
- if (dbPath) {
- try {
- const backupPath = `${dbPath}.backup`;
- fs.copyFileSync(dbPath, backupPath);
- logger.info(`Backed up database to ${backupPath}`);
- } catch (e) {
- logger.error('Failed to backup database', e);
+ logger.info(`Switching to cloud account: ${account.email} (${account.id})`);
+
+ ensureGlobalOriginalFromCurrentStorage();
+ if (!account.device_profile) {
+ const generated = generateDeviceProfile();
+ CloudAccountRepo.setDeviceBinding(account.id, generated, 'auto_generated');
+ saveGlobalOriginalProfile(generated);
+ account.device_profile = generated;
}
+
+ // 1. Ensure token is fresh before injecting
+ const now = Math.floor(Date.now() / 1000);
+ if (account.token.expiry_timestamp < now + 300) {
+ logger.info(`Token for ${account.email} near expiry, refreshing before switch...`);
+ try {
+ const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
+ 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);
+ } catch (e) {
+ logger.warn('Failed to refresh token before switch, trying with existing token', e);
+ }
+ }
+
+ await executeSwitchFlow({
+ scope: 'cloud',
+ targetProfile: account.device_profile || null,
+ applyFingerprint: isIdentityProfileApplyEnabled(),
+ processExitTimeoutMs: 10000,
+ performSwitch: async () => {
+ // 3. Backup Database (New Logic)
+ const dbPaths = getAntigravityDbPaths();
+ // Find the valid DB path
+ let dbPath: string | null = null;
+ for (const p of dbPaths) {
+ if (fs.existsSync(p)) {
+ dbPath = p;
+ break;
+ }
+ }
+
+ if (dbPath) {
+ try {
+ const backupPath = `${dbPath}.backup`;
+ fs.copyFileSync(dbPath, backupPath);
+ logger.info(`Backed up database to ${backupPath}`);
+ } catch (e) {
+ logger.error('Failed to backup database', e);
+ }
+ }
+
+ // 4. Inject Token
+ // injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
+ CloudAccountRepo.injectCloudToken(account);
+
+ // 5. Update usage and active status
+ CloudAccountRepo.updateLastUsed(account.id);
+ CloudAccountRepo.setActive(account.id);
+
+ logger.info(`Successfully switched to cloud account: ${account.email}`);
+ notifyTrayUpdate(account);
+ },
+ });
+ } catch (err: any) {
+ logger.error('Failed to switch cloud account', err);
+ throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
}
+ });
+}
- // 3. Inject Token
- // injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
- CloudAccountRepo.injectCloudToken(account);
+export async function getCloudIdentityProfiles(accountId: string): Promise {
+ const account = await CloudAccountRepo.getAccount(accountId);
+ if (!account) {
+ throw new Error(`Account not found: ${accountId}`);
+ }
- // 4. Update usage and active status
- CloudAccountRepo.updateLastUsed(account.id);
- CloudAccountRepo.setActive(account.id);
+ let currentStorage: DeviceProfile | undefined;
+ try {
+ currentStorage = readCurrentDeviceProfile();
+ } catch (error) {
+ logger.warn('Failed to read current storage device profile', error);
+ }
- // 5. Restart Process
- await startAntigravity();
+ return {
+ currentStorage,
+ boundProfile: account.device_profile,
+ history: account.device_history || [],
+ baseline: loadGlobalOriginalProfile() || undefined,
+ };
+}
- logger.info(`Successfully switched to cloud account: ${account.email}`);
- notifyTrayUpdate(account);
- } catch (err: any) {
- logger.error('Failed to switch cloud account', err);
- throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
+export async function previewGenerateCloudIdentityProfile(): Promise {
+ return generateDeviceProfile();
+}
+
+export async function bindCloudIdentityProfile(
+ accountId: string,
+ mode: 'capture' | 'generate',
+): Promise {
+ const account = await CloudAccountRepo.getAccount(accountId);
+ if (!account) {
+ throw new Error(`Account not found: ${accountId}`);
+ }
+
+ let profile: DeviceProfile;
+ if (mode === 'capture') {
+ profile = readCurrentDeviceProfile();
+ } else {
+ profile = generateDeviceProfile();
+ }
+
+ ensureGlobalOriginalFromCurrentStorage();
+ saveGlobalOriginalProfile(profile);
+ CloudAccountRepo.setDeviceBinding(account.id, profile, mode);
+
+ return profile;
+}
+
+export async function bindCloudIdentityProfileWithPayload(
+ accountId: string,
+ profile: DeviceProfile,
+): Promise {
+ const account = await CloudAccountRepo.getAccount(accountId);
+ if (!account) {
+ throw new Error(`Account not found: ${accountId}`);
+ }
+
+ ensureGlobalOriginalFromCurrentStorage();
+ saveGlobalOriginalProfile(profile);
+ CloudAccountRepo.setDeviceBinding(account.id, profile, 'generated');
+
+ return profile;
+}
+
+export async function restoreCloudIdentityProfileRevision(
+ accountId: string,
+ versionId: string,
+): Promise {
+ const baseline = loadGlobalOriginalProfile();
+ return CloudAccountRepo.restoreDeviceVersion(accountId, versionId, baseline);
+}
+
+export async function restoreCloudBaselineProfile(accountId: string): Promise {
+ const baseline = loadGlobalOriginalProfile();
+ if (!baseline) {
+ throw new Error('Global original profile not found');
+ }
+ return CloudAccountRepo.restoreDeviceVersion(accountId, 'baseline', baseline);
+}
+
+export async function deleteCloudIdentityProfileRevision(
+ accountId: string,
+ versionId: string,
+): Promise {
+ CloudAccountRepo.deleteDeviceVersion(accountId, versionId);
+}
+
+export async function openCloudIdentityStorageFolder(): Promise {
+ const directory = getStorageDirectoryPath();
+ const result = await shell.openPath(directory);
+ if (result) {
+ throw new Error(`Failed to open identity storage: ${result}`);
}
}
diff --git a/src/ipc/cloud/router.ts b/src/ipc/cloud/router.ts
index e39e0cd..274afd6 100644
--- a/src/ipc/cloud/router.ts
+++ b/src/ipc/cloud/router.ts
@@ -2,9 +2,17 @@ import { z } from 'zod';
import { os } from '@orpc/server';
import {
addGoogleAccount,
+ bindCloudIdentityProfile,
+ bindCloudIdentityProfileWithPayload,
+ deleteCloudIdentityProfileRevision,
listCloudAccounts,
deleteCloudAccount,
+ getCloudIdentityProfiles,
+ openCloudIdentityStorageFolder,
+ previewGenerateCloudIdentityProfile,
refreshAccountQuota,
+ restoreCloudIdentityProfileRevision,
+ restoreCloudBaselineProfile,
switchCloudAccount,
getAutoSwitchEnabled,
setAutoSwitchEnabled,
@@ -12,8 +20,53 @@ import {
startAuthFlow,
} from './handler';
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';
+import { getDeviceHardeningSnapshot } from '../device/handler';
+
+const switchOwnerSchema = z.enum(['local-account-switch', 'cloud-account-switch']);
+const switchMetricBucketSchema = z.object({
+ switchSuccess: z.number(),
+ switchFailure: z.number(),
+ rollbackAttempt: z.number(),
+ rollbackSuccess: z.number(),
+ rollbackFailure: z.number(),
+ failureReasons: z.record(z.string(), z.number()),
+ lastFailure: z
+ .object({
+ reason: z.string(),
+ message: z.string(),
+ occurredAt: z.number(),
+ })
+ .nullable(),
+});
+const switchMetricsSnapshotSchema = z.object({
+ local: switchMetricBucketSchema,
+ cloud: switchMetricBucketSchema,
+});
+const switchGuardSnapshotSchema = z.object({
+ activeOwner: switchOwnerSchema.nullable(),
+ pendingOwners: z.array(switchOwnerSchema),
+ pendingCount: z.number(),
+});
+const switchStatusSnapshotSchema = z.object({
+ metrics: switchMetricsSnapshotSchema,
+ guard: switchGuardSnapshotSchema,
+ hardening: z.object({
+ consecutiveApplyFailures: z.number(),
+ safeModeActive: z.boolean(),
+ safeModeUntil: z.number().nullable(),
+ lastFailureReason: z.string().nullable(),
+ lastFailureStage: z.string().nullable(),
+ lastFailureAt: z.number().nullable(),
+ }),
+});
export const cloudRouter = os.router({
addGoogleAccount: os
@@ -77,4 +130,62 @@ export const cloudRouter = os.router({
throw error;
}
}),
+
+ getSwitchStatus: os.output(switchStatusSnapshotSchema).handler(async () => {
+ return {
+ metrics: getSwitchMetricsSnapshot(),
+ guard: getSwitchGuardSnapshot(),
+ hardening: getDeviceHardeningSnapshot(),
+ };
+ }),
+
+ getIdentityProfiles: os
+ .input(z.object({ accountId: z.string() }))
+ .output(DeviceProfilesSnapshotSchema)
+ .handler(async ({ input }) => {
+ return getCloudIdentityProfiles(input.accountId);
+ }),
+
+ previewIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
+ return previewGenerateCloudIdentityProfile();
+ }),
+
+ bindIdentityProfile: os
+ .input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return bindCloudIdentityProfile(input.accountId, input.mode);
+ }),
+
+ bindIdentityProfileWithPayload: os
+ .input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return bindCloudIdentityProfileWithPayload(input.accountId, input.profile);
+ }),
+
+ restoreIdentityProfileRevision: os
+ .input(z.object({ accountId: z.string(), versionId: z.string() }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return restoreCloudIdentityProfileRevision(input.accountId, input.versionId);
+ }),
+
+ restoreBaselineProfile: os
+ .input(z.object({ accountId: z.string() }))
+ .output(DeviceProfileSchema)
+ .handler(async ({ input }) => {
+ return restoreCloudBaselineProfile(input.accountId);
+ }),
+
+ deleteIdentityProfileRevision: os
+ .input(z.object({ accountId: z.string(), versionId: z.string() }))
+ .output(z.void())
+ .handler(async ({ input }) => {
+ await deleteCloudIdentityProfileRevision(input.accountId, input.versionId);
+ }),
+
+ openIdentityStorageFolder: os.output(z.void()).handler(async () => {
+ await openCloudIdentityStorageFolder();
+ }),
});
diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts
index f7f0299..451a2a3 100644
--- a/src/ipc/database/cloudHandler.ts
+++ b/src/ipc/database/cloudHandler.ts
@@ -7,6 +7,7 @@ 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 { 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';
@@ -21,6 +22,7 @@ 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,
@@ -73,6 +75,8 @@ function ensureDatabaseInitialized(dbPath: string): void {
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',
@@ -84,9 +88,17 @@ function ensureDatabaseInitialized(dbPath: string): void {
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');
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');
+ }
// Create index on email for faster lookups
// Create index on email for faster lookups
@@ -142,6 +154,196 @@ interface MigrationStats {
failedFields: number;
}
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function readStringCandidate(
+ source: Record,
+ ...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,
@@ -260,6 +462,8 @@ export class CloudAccountRepo {
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',
@@ -371,6 +575,8 @@ export class CloudAccountRepo {
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,
@@ -437,6 +643,8 @@ export class CloudAccountRepo {
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,
@@ -492,6 +700,203 @@ export class CloudAccountRepo {
}
}
+ 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();
diff --git a/src/ipc/database/schema.ts b/src/ipc/database/schema.ts
index 4b24135..8c8ddbd 100644
--- a/src/ipc/database/schema.ts
+++ b/src/ipc/database/schema.ts
@@ -8,6 +8,8 @@ export const accounts = sqliteTable('accounts', {
avatarUrl: text('avatar_url'),
tokenJson: text('token_json').notNull(),
quotaJson: text('quota_json'),
+ deviceProfileJson: text('device_profile_json'),
+ deviceHistoryJson: text('device_history_json'),
createdAt: integer('created_at').notNull(),
lastUsed: integer('last_used').notNull(),
status: text('status'),
diff --git a/src/ipc/device/handler.ts b/src/ipc/device/handler.ts
new file mode 100644
index 0000000..4e636bc
--- /dev/null
+++ b/src/ipc/device/handler.ts
@@ -0,0 +1,715 @@
+import Database from 'better-sqlite3';
+import fs from 'fs';
+import path from 'path';
+import { createHash, randomBytes } from 'crypto';
+import { type DeviceProfile } from '../../types/account';
+import { logger } from '../../utils/logger';
+import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths';
+
+const GLOBAL_BASELINE_FILE = 'device_original.json';
+const SQLITE_RETRY_COUNT = 3;
+const LAST_KNOWN_GOOD_DIR = 'device_last_known_good';
+const LAST_KNOWN_GOOD_STORAGE_FILE = 'storage.json';
+const LAST_KNOWN_GOOD_STATE_DB_FILE = 'state.vscdb';
+const LAST_KNOWN_GOOD_MARKER_FILE = 'marker.json';
+const DEVICE_HARDENING_SAFE_MODE_THRESHOLD = 3;
+const DEVICE_HARDENING_SAFE_MODE_DURATION_MS = 5 * 60 * 1000;
+
+export type DeviceApplyFailureReason =
+ | 'backup_failed'
+ | 'storage_write_failed'
+ | 'state_sync_failed'
+ | 'verify_storage_failed'
+ | 'verify_state_failed'
+ | 'snapshot_update_failed'
+ | 'rollback_failed'
+ | 'unknown';
+
+export interface DeviceHardeningSnapshot {
+ consecutiveApplyFailures: number;
+ safeModeActive: boolean;
+ safeModeUntil: number | null;
+ lastFailureReason: DeviceApplyFailureReason | null;
+ lastFailureStage: string | null;
+ lastFailureAt: number | null;
+}
+
+const UUID_V4_REGEX =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+const SQM_ID_REGEX =
+ /^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/;
+
+interface DeviceProfileTemplate {
+ machineIdPrefix: string;
+ machineIdHexLength: number;
+}
+
+const DEFAULT_PROFILE_TEMPLATE: DeviceProfileTemplate = {
+ machineIdPrefix: 'auth0|user_',
+ machineIdHexLength: 32,
+};
+
+const DEVICE_PROFILE_TEMPLATES: Record = {
+ win32: [DEFAULT_PROFILE_TEMPLATE],
+ linux: [DEFAULT_PROFILE_TEMPLATE],
+ darwin: [DEFAULT_PROFILE_TEMPLATE],
+};
+
+interface LastKnownGoodMarker {
+ version: 1;
+ savedAt: number;
+ hasStateDb: boolean;
+}
+
+interface DeviceHardeningState {
+ consecutiveApplyFailures: number;
+ safeModeUntil: number | null;
+ lastFailureReason: DeviceApplyFailureReason | null;
+ lastFailureStage: string | null;
+ lastFailureAt: number | null;
+}
+
+const deviceHardeningState: DeviceHardeningState = {
+ consecutiveApplyFailures: 0,
+ safeModeUntil: null,
+ lastFailureReason: null,
+ lastFailureStage: null,
+ lastFailureAt: null,
+};
+
+function isSqliteBusyError(error: unknown): boolean {
+ if (!error || typeof error !== 'object') {
+ return false;
+ }
+ const candidate = error as { code?: string; message?: string };
+ if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') {
+ return true;
+ }
+ if (typeof candidate.message === 'string') {
+ return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED');
+ }
+ return false;
+}
+
+function resolveExistingPath(paths: string[]): string | null {
+ for (const targetPath of paths) {
+ if (fs.existsSync(targetPath)) {
+ return targetPath;
+ }
+ }
+ return null;
+}
+
+function randomHex(length: number): string {
+ return randomBytes(Math.ceil(length / 2))
+ .toString('hex')
+ .slice(0, length)
+ .toLowerCase();
+}
+
+function getLastKnownGoodSnapshotDir(): string {
+ return path.join(getAgentDir(), LAST_KNOWN_GOOD_DIR);
+}
+
+function getLastKnownGoodStoragePath(): string {
+ return path.join(getLastKnownGoodSnapshotDir(), LAST_KNOWN_GOOD_STORAGE_FILE);
+}
+
+function getLastKnownGoodStateDbPath(): string {
+ return path.join(getLastKnownGoodSnapshotDir(), LAST_KNOWN_GOOD_STATE_DB_FILE);
+}
+
+function getLastKnownGoodMarkerPath(): string {
+ return path.join(getLastKnownGoodSnapshotDir(), LAST_KNOWN_GOOD_MARKER_FILE);
+}
+
+function ensureDirectoryExists(dirPath: string): void {
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
+ }
+}
+
+function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
+ const markerPath = getLastKnownGoodMarkerPath();
+ if (!fs.existsSync(markerPath)) {
+ return null;
+ }
+
+ try {
+ const content = fs.readFileSync(markerPath, 'utf-8');
+ const parsed = JSON.parse(content) as Partial;
+ if (
+ parsed &&
+ parsed.version === 1 &&
+ typeof parsed.savedAt === 'number' &&
+ typeof parsed.hasStateDb === 'boolean'
+ ) {
+ return {
+ version: 1,
+ savedAt: parsed.savedAt,
+ hasStateDb: parsed.hasStateDb,
+ };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+function parseStorageJson(storagePath: string): Record {
+ const content = fs.readFileSync(storagePath, 'utf-8');
+ const parsed = JSON.parse(content);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('storage.json top-level value must be an object');
+ }
+ return parsed as Record;
+}
+
+function getTelemetryField(storage: Record, key: string): string | null {
+ const telemetry = storage.telemetry;
+ if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
+ const nestedValue = (telemetry as Record)[key];
+ if (typeof nestedValue === 'string' && nestedValue.length > 0) {
+ return nestedValue;
+ }
+ }
+
+ const flatValue = storage[`telemetry.${key}`];
+ if (typeof flatValue === 'string' && flatValue.length > 0) {
+ return flatValue;
+ }
+
+ return null;
+}
+
+function ensureTelemetryObject(storage: Record): Record {
+ if (!storage.telemetry || typeof storage.telemetry !== 'object' || Array.isArray(storage.telemetry)) {
+ storage.telemetry = {};
+ }
+ return storage.telemetry as Record;
+}
+
+function getStateDbPathFromStorage(storagePath: string): string {
+ return path.join(path.dirname(storagePath), 'state.vscdb');
+}
+
+function getGlobalBaselinePath(): string {
+ return path.join(getAgentDir(), GLOBAL_BASELINE_FILE);
+}
+
+function writeJsonAtomically(filePath: string, payload: unknown): void {
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
+ fs.writeFileSync(tempPath, content, 'utf-8');
+ try {
+ fs.renameSync(tempPath, filePath);
+ } catch {
+ fs.copyFileSync(tempPath, filePath);
+ fs.unlinkSync(tempPath);
+ }
+}
+
+function readStateServiceMachineIdValue(dbPath: string): string | null {
+ if (!fs.existsSync(dbPath)) {
+ return null;
+ }
+
+ let db: Database.Database | null = null;
+ try {
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
+ db.pragma('busy_timeout = 3000');
+ const row = db
+ .prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1")
+ .get() as { value?: unknown } | undefined;
+ if (!row || typeof row.value !== 'string' || row.value.length === 0) {
+ return null;
+ }
+ return row.value;
+ } catch (error) {
+ logger.warn('Failed to read state.serviceMachineId from state.vscdb', error);
+ return null;
+ } finally {
+ if (db) {
+ db.close();
+ }
+ }
+}
+
+function ensureStorageProfileApplied(profile: DeviceProfile, storagePath: string): void {
+ const applied = readCurrentDeviceProfile(storagePath);
+ const isMatched =
+ applied.machineId === profile.machineId &&
+ applied.macMachineId === profile.macMachineId &&
+ applied.devDeviceId === profile.devDeviceId &&
+ applied.sqmId === profile.sqmId;
+ if (!isMatched) {
+ throw new Error('device_profile_integrity_check_failed_for_storage');
+ }
+}
+
+function ensureStateServiceMachineIdApplied(serviceMachineId: string, dbPath: string): void {
+ const value = readStateServiceMachineIdValue(dbPath);
+ if (value !== serviceMachineId) {
+ throw new Error('device_profile_integrity_check_failed_for_state_db');
+ }
+}
+
+function cleanupPathIfExists(targetPath: string): void {
+ if (fs.existsSync(targetPath)) {
+ fs.rmSync(targetPath, { force: true });
+ }
+}
+
+function cleanupStateDbJournalFiles(dbPath: string): void {
+ cleanupPathIfExists(`${dbPath}-wal`);
+ cleanupPathIfExists(`${dbPath}-shm`);
+}
+
+function pickDeviceProfileTemplate(): DeviceProfileTemplate {
+ const templates = DEVICE_PROFILE_TEMPLATES[process.platform] || [DEFAULT_PROFILE_TEMPLATE];
+ const randomIndex = Number.parseInt(randomHex(2), 16) % templates.length;
+ return templates[randomIndex];
+}
+
+function deterministicUuidFromSeed(seed: string, namespace: string): string {
+ const hash = createHash('sha256')
+ .update(`${namespace}:${seed}`)
+ .digest();
+ const bytes = Buffer.from(hash.subarray(0, 16));
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = bytes.toString('hex');
+ return [
+ hex.slice(0, 8),
+ hex.slice(8, 12),
+ hex.slice(12, 16),
+ hex.slice(16, 20),
+ hex.slice(20, 32),
+ ].join('-');
+}
+
+function ensureGeneratedProfile(profile: DeviceProfile, template: DeviceProfileTemplate): void {
+ const machineIdPrefix = template.machineIdPrefix;
+ const machineIdSuffix = profile.machineId.slice(machineIdPrefix.length);
+
+ if (!profile.machineId.startsWith(machineIdPrefix)) {
+ throw new Error('generated_profile_machine_id_prefix_invalid');
+ }
+ if (machineIdSuffix.length !== template.machineIdHexLength) {
+ throw new Error('generated_profile_machine_id_length_invalid');
+ }
+ if (!/^[0-9a-f]+$/.test(machineIdSuffix)) {
+ throw new Error('generated_profile_machine_id_suffix_invalid');
+ }
+ if (!UUID_V4_REGEX.test(profile.macMachineId)) {
+ throw new Error('generated_profile_mac_machine_id_invalid');
+ }
+ if (!UUID_V4_REGEX.test(profile.devDeviceId)) {
+ throw new Error('generated_profile_dev_device_id_invalid');
+ }
+ if (!SQM_ID_REGEX.test(profile.sqmId)) {
+ throw new Error('generated_profile_sqm_id_invalid');
+ }
+ if (profile.macMachineId === profile.devDeviceId) {
+ throw new Error('generated_profile_uuid_collision');
+ }
+}
+
+function ensureStorageStateConsistency(
+ storagePath: string,
+ stateDbPath: string,
+ expectedStateDb: boolean,
+ requireStateMachineIdMatch: boolean = true,
+): void {
+ const profile = readCurrentDeviceProfile(storagePath);
+ const hasStateDb = fs.existsSync(stateDbPath);
+
+ if (expectedStateDb && !hasStateDb) {
+ throw new Error('state_vscdb_missing_after_restore');
+ }
+ if (!hasStateDb) {
+ return;
+ }
+
+ const stateValue = readStateServiceMachineIdValue(stateDbPath);
+ if (!requireStateMachineIdMatch && !stateValue) {
+ return;
+ }
+ if (stateValue !== profile.devDeviceId) {
+ throw new Error('storage_state_service_machine_id_mismatch');
+ }
+}
+
+function saveLastKnownGoodSnapshot(storagePath: string, stateDbPath: string): void {
+ const snapshotDir = getLastKnownGoodSnapshotDir();
+ ensureDirectoryExists(snapshotDir);
+
+ const storageSnapshotPath = getLastKnownGoodStoragePath();
+ const stateDbSnapshotPath = getLastKnownGoodStateDbPath();
+ const markerPath = getLastKnownGoodMarkerPath();
+ const hasStateDb = fs.existsSync(stateDbPath);
+
+ fs.copyFileSync(storagePath, storageSnapshotPath);
+ if (hasStateDb) {
+ fs.copyFileSync(stateDbPath, stateDbSnapshotPath);
+ } else {
+ cleanupPathIfExists(stateDbSnapshotPath);
+ }
+
+ const marker: LastKnownGoodMarker = {
+ version: 1,
+ savedAt: Date.now(),
+ hasStateDb,
+ };
+ writeJsonAtomically(markerPath, marker);
+}
+
+function restoreLastKnownGoodSnapshot(storagePath: string, stateDbPath: string): boolean {
+ const marker = readLastKnownGoodMarker();
+ if (!marker) {
+ return false;
+ }
+
+ const storageSnapshotPath = getLastKnownGoodStoragePath();
+ const stateDbSnapshotPath = getLastKnownGoodStateDbPath();
+
+ if (!fs.existsSync(storageSnapshotPath)) {
+ return false;
+ }
+
+ try {
+ fs.copyFileSync(storageSnapshotPath, storagePath);
+ if (marker.hasStateDb && fs.existsSync(stateDbSnapshotPath)) {
+ fs.copyFileSync(stateDbSnapshotPath, stateDbPath);
+ } else {
+ cleanupPathIfExists(stateDbPath);
+ cleanupStateDbJournalFiles(stateDbPath);
+ }
+ ensureStorageStateConsistency(storagePath, stateDbPath, marker.hasStateDb);
+ return true;
+ } catch (error) {
+ logger.error('Failed to restore last known good device snapshot', error);
+ return false;
+ }
+}
+
+function classifyApplyFailure(stage: string, error: unknown): DeviceApplyFailureReason {
+ if (stage === 'prepare_backup') {
+ return 'backup_failed';
+ }
+ if (stage === 'prepare_write_storage') {
+ return 'storage_write_failed';
+ }
+ if (stage === 'prepare_sync_state') {
+ return 'state_sync_failed';
+ }
+ if (stage === 'verify_storage') {
+ return 'verify_storage_failed';
+ }
+ if (stage === 'verify_state') {
+ return 'verify_state_failed';
+ }
+ if (stage === 'commit_snapshot') {
+ return 'snapshot_update_failed';
+ }
+ if (stage === 'rollback') {
+ return 'rollback_failed';
+ }
+
+ if (error instanceof Error) {
+ if (error.message.includes('device_profile_integrity_check_failed_for_storage')) {
+ return 'verify_storage_failed';
+ }
+ if (error.message.includes('device_profile_integrity_check_failed_for_state_db')) {
+ return 'verify_state_failed';
+ }
+ }
+ return 'unknown';
+}
+
+function markApplySuccess(): void {
+ deviceHardeningState.consecutiveApplyFailures = 0;
+ deviceHardeningState.safeModeUntil = null;
+}
+
+function markApplyFailure(reason: DeviceApplyFailureReason, stage: string): void {
+ const now = Date.now();
+ deviceHardeningState.consecutiveApplyFailures += 1;
+ deviceHardeningState.lastFailureReason = reason;
+ deviceHardeningState.lastFailureStage = stage;
+ deviceHardeningState.lastFailureAt = now;
+ if (deviceHardeningState.consecutiveApplyFailures >= DEVICE_HARDENING_SAFE_MODE_THRESHOLD) {
+ deviceHardeningState.safeModeUntil = now + DEVICE_HARDENING_SAFE_MODE_DURATION_MS;
+ }
+}
+
+function isSafeModeActiveNow(): boolean {
+ if (!deviceHardeningState.safeModeUntil) {
+ return false;
+ }
+ const active = Date.now() < deviceHardeningState.safeModeUntil;
+ if (!active) {
+ deviceHardeningState.safeModeUntil = null;
+ }
+ return active;
+}
+
+function buildApplyError(
+ reason: DeviceApplyFailureReason,
+ stage: string,
+ original: unknown,
+): Error {
+ const message = original instanceof Error ? original.message : String(original);
+ const wrapped = new Error(`device_apply_failed:${reason}:${stage}:${message}`);
+ (wrapped as Error & { deviceFailureReason?: DeviceApplyFailureReason; deviceFailureStage?: string })
+ .deviceFailureReason = reason;
+ (wrapped as Error & { deviceFailureReason?: DeviceApplyFailureReason; deviceFailureStage?: string })
+ .deviceFailureStage = stage;
+ return wrapped;
+}
+
+export function getDeviceHardeningSnapshot(): DeviceHardeningSnapshot {
+ const safeModeActive = isSafeModeActiveNow();
+ return {
+ consecutiveApplyFailures: deviceHardeningState.consecutiveApplyFailures,
+ safeModeActive,
+ safeModeUntil: safeModeActive ? deviceHardeningState.safeModeUntil : null,
+ lastFailureReason: deviceHardeningState.lastFailureReason,
+ lastFailureStage: deviceHardeningState.lastFailureStage,
+ lastFailureAt: deviceHardeningState.lastFailureAt,
+ };
+}
+
+export function isIdentityProfileApplyEnabled(): boolean {
+ const raw =
+ process.env.CRACK_IDENTITY_PROFILE_APPLY_ENABLED ??
+ process.env.CRACK_DEVICE_FINGERPRINT_ENABLED;
+ if (!raw) {
+ return !isSafeModeActiveNow();
+ }
+ const normalized = raw.trim().toLowerCase();
+ const envEnabled = (
+ normalized !== '0' &&
+ normalized !== 'false' &&
+ normalized !== 'off' &&
+ normalized !== 'no'
+ );
+ if (!envEnabled) {
+ return false;
+ }
+ return !isSafeModeActiveNow();
+}
+
+export function getStoragePath(): string {
+ const storagePath = resolveExistingPath(getAntigravityStoragePaths());
+ if (!storagePath) {
+ throw new Error('storage_json_not_found');
+ }
+ return storagePath;
+}
+
+export function getStorageDirectoryPath(): string {
+ return path.dirname(getStoragePath());
+}
+
+export function loadGlobalOriginalProfile(): DeviceProfile | null {
+ const baselinePath = getGlobalBaselinePath();
+ if (!fs.existsSync(baselinePath)) {
+ return null;
+ }
+
+ try {
+ const raw = fs.readFileSync(baselinePath, 'utf-8');
+ return JSON.parse(raw) as DeviceProfile;
+ } catch (error) {
+ logger.warn('Failed to load global original device profile', error);
+ return null;
+ }
+}
+
+export function saveGlobalOriginalProfile(profile: DeviceProfile): void {
+ const baselinePath = getGlobalBaselinePath();
+ const baselineDir = path.dirname(baselinePath);
+ if (!fs.existsSync(baselineDir)) {
+ fs.mkdirSync(baselineDir, { recursive: true });
+ }
+ if (fs.existsSync(baselinePath)) {
+ return;
+ }
+ fs.writeFileSync(baselinePath, JSON.stringify(profile, null, 2), 'utf-8');
+}
+
+export function readCurrentDeviceProfile(storagePath?: string): DeviceProfile {
+ const targetPath = storagePath || getStoragePath();
+ const storage = parseStorageJson(targetPath);
+
+ const machineId = getTelemetryField(storage, 'machineId');
+ const macMachineId = getTelemetryField(storage, 'macMachineId');
+ const devDeviceId = getTelemetryField(storage, 'devDeviceId');
+ const sqmId = getTelemetryField(storage, 'sqmId');
+
+ if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
+ throw new Error('missing_device_fingerprint_fields');
+ }
+
+ return {
+ machineId,
+ macMachineId,
+ devDeviceId,
+ sqmId,
+ };
+}
+
+export function ensureGlobalOriginalFromCurrentStorage(): void {
+ if (loadGlobalOriginalProfile()) {
+ return;
+ }
+
+ try {
+ const profile = readCurrentDeviceProfile();
+ saveGlobalOriginalProfile(profile);
+ } catch (error) {
+ logger.warn('Failed to capture baseline device profile from storage.json', error);
+ }
+}
+
+export function generateDeviceProfile(): DeviceProfile {
+ const template = pickDeviceProfileTemplate();
+ const seed = randomHex(32);
+ const profile: DeviceProfile = {
+ machineId: `${template.machineIdPrefix}${seed.slice(0, template.machineIdHexLength)}`,
+ macMachineId: deterministicUuidFromSeed(seed, 'mac'),
+ devDeviceId: deterministicUuidFromSeed(seed, 'dev'),
+ sqmId: `{${deterministicUuidFromSeed(seed, 'sqm').toUpperCase()}}`,
+ };
+ ensureGeneratedProfile(profile, template);
+ return profile;
+}
+
+export function syncStateServiceMachineIdValue(serviceMachineId: string, dbPath?: string): void {
+ const targetDbPath = dbPath || resolveExistingPath(getAntigravityDbPaths());
+ if (!targetDbPath) {
+ throw new Error('state_vscdb_not_found');
+ }
+
+ const targetDbDir = path.dirname(targetDbPath);
+ if (!fs.existsSync(targetDbDir)) {
+ fs.mkdirSync(targetDbDir, { recursive: true });
+ }
+
+ for (let attempt = 1; attempt <= SQLITE_RETRY_COUNT; attempt += 1) {
+ let db: Database.Database | null = null;
+ try {
+ db = new Database(targetDbPath);
+ db.pragma('busy_timeout = 3000');
+ db.exec('CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT);');
+ db.prepare("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('storage.serviceMachineId', ?)")
+ .run(serviceMachineId);
+ return;
+ } catch (error) {
+ if (isSqliteBusyError(error) && attempt < SQLITE_RETRY_COUNT) {
+ logger.warn(`state.vscdb busy, retrying (${attempt}/${SQLITE_RETRY_COUNT})`, error);
+ continue;
+ }
+ throw error;
+ } finally {
+ if (db) {
+ db.close();
+ }
+ }
+ }
+
+ throw new Error('sync_state_service_machine_id_failed');
+}
+
+export function applyDeviceProfile(profile: DeviceProfile): string {
+ const storagePath = getStoragePath();
+ const backupPath = `${storagePath}.backup`;
+ const stateDbPath = getStateDbPathFromStorage(storagePath);
+ const stateBackupPath = `${stateDbPath}.backup`;
+ const hadStateDb = fs.existsSync(stateDbPath);
+ let stage = 'prepare_backup';
+
+ try {
+ fs.copyFileSync(storagePath, backupPath);
+ if (hadStateDb) {
+ fs.copyFileSync(stateDbPath, stateBackupPath);
+ }
+
+ stage = 'prepare_write_storage';
+ const storage = parseStorageJson(storagePath);
+ const telemetry = ensureTelemetryObject(storage);
+
+ telemetry.machineId = profile.machineId;
+ telemetry.macMachineId = profile.macMachineId;
+ telemetry.devDeviceId = profile.devDeviceId;
+ telemetry.sqmId = profile.sqmId;
+
+ storage['telemetry.machineId'] = profile.machineId;
+ storage['telemetry.macMachineId'] = profile.macMachineId;
+ storage['telemetry.devDeviceId'] = profile.devDeviceId;
+ storage['telemetry.sqmId'] = profile.sqmId;
+ storage['storage.serviceMachineId'] = profile.devDeviceId;
+
+ writeJsonAtomically(storagePath, storage);
+
+ stage = 'prepare_sync_state';
+ syncStateServiceMachineIdValue(profile.devDeviceId, stateDbPath);
+
+ stage = 'verify_storage';
+ ensureStorageProfileApplied(profile, storagePath);
+
+ stage = 'verify_state';
+ ensureStateServiceMachineIdApplied(profile.devDeviceId, stateDbPath);
+
+ stage = 'commit_snapshot';
+ try {
+ saveLastKnownGoodSnapshot(storagePath, stateDbPath);
+ } catch (snapshotError) {
+ logger.warn('Failed to update last known good device snapshot', snapshotError);
+ }
+
+ cleanupPathIfExists(backupPath);
+ cleanupPathIfExists(stateBackupPath);
+ markApplySuccess();
+ return storagePath;
+ } catch (error) {
+ const classifiedReason = classifyApplyFailure(stage, error);
+ markApplyFailure(classifiedReason, stage);
+
+ let rollbackStage = 'rollback_immediate_backup';
+ let restoredFromImmediateBackup = false;
+ try {
+ if (fs.existsSync(backupPath)) {
+ fs.copyFileSync(backupPath, storagePath);
+ }
+ if (hadStateDb && fs.existsSync(stateBackupPath)) {
+ fs.copyFileSync(stateBackupPath, stateDbPath);
+ }
+ if (!hadStateDb) {
+ cleanupPathIfExists(stateDbPath);
+ cleanupStateDbJournalFiles(stateDbPath);
+ }
+ ensureStorageStateConsistency(storagePath, stateDbPath, hadStateDb, false);
+ restoredFromImmediateBackup = true;
+ } catch (restoreError) {
+ logger.error('Failed to restore device profile from immediate backup', restoreError);
+ rollbackStage = 'rollback_last_known_good';
+ }
+
+ if (!restoredFromImmediateBackup) {
+ const restoredFromLastKnownGood = restoreLastKnownGoodSnapshot(storagePath, stateDbPath);
+ if (!restoredFromLastKnownGood) {
+ logger.error('Failed to restore device profile from last known good snapshot');
+ markApplyFailure('rollback_failed', rollbackStage);
+ }
+ }
+
+ throw buildApplyError(classifiedReason, stage, error);
+ } finally {
+ cleanupPathIfExists(backupPath);
+ cleanupPathIfExists(stateBackupPath);
+ }
+}
diff --git a/src/ipc/switchFlow.ts b/src/ipc/switchFlow.ts
new file mode 100644
index 0000000..285a738
--- /dev/null
+++ b/src/ipc/switchFlow.ts
@@ -0,0 +1,91 @@
+import { type DeviceProfile } from '../types/account';
+import { logger } from '../utils/logger';
+import { closeAntigravity, startAntigravity, _waitForProcessExit } from './process/handler';
+import { applyDeviceProfile } from './device/handler';
+import { type SwitchFailureReason, recordSwitchFailure, recordSwitchSuccess } from './switchMetrics';
+
+export interface SwitchFlowOptions {
+ scope: 'local' | 'cloud';
+ targetProfile: DeviceProfile | null;
+ applyFingerprint: boolean;
+ processExitTimeoutMs: number;
+ performSwitch: () => Promise;
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+}
+
+function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReason {
+ if (stage === 'close') {
+ return 'process_close_failed';
+ }
+ if (stage === 'missing_profile') {
+ return 'missing_bound_profile';
+ }
+ if (stage === 'apply') {
+ return 'apply_device_profile_failed';
+ }
+ if (stage === 'switch') {
+ return 'perform_switch_failed';
+ }
+ if (stage === 'start') {
+ return 'start_process_failed';
+ }
+
+ // Keep legacy compatibility with reason encoded in thrown errors.
+ if (error instanceof Error && error.message.includes('missing bound device profile')) {
+ return 'missing_bound_profile';
+ }
+ if (error instanceof Error && error.message.includes('device_apply_failed')) {
+ return 'apply_device_profile_failed';
+ }
+ return 'unknown';
+}
+
+export async function executeSwitchFlow(options: SwitchFlowOptions): Promise {
+ const {
+ scope,
+ targetProfile,
+ applyFingerprint,
+ processExitTimeoutMs,
+ performSwitch,
+ } = options;
+
+ let stage = 'close';
+ try {
+ await closeAntigravity();
+ try {
+ await _waitForProcessExit(processExitTimeoutMs);
+ } catch (error) {
+ logger.warn('Process did not exit cleanly within timeout, but proceeding...', error);
+ }
+
+ stage = 'apply';
+ if (applyFingerprint) {
+ if (!targetProfile) {
+ stage = 'missing_profile';
+ throw new Error('Account has no bound identity profile');
+ }
+ applyDeviceProfile(targetProfile);
+ } else {
+ logger.warn(
+ 'Identity profile apply is disabled by CRACK_IDENTITY_PROFILE_APPLY_ENABLED / CRACK_DEVICE_FINGERPRINT_ENABLED',
+ );
+ }
+
+ stage = 'switch';
+ await performSwitch();
+ stage = 'start';
+ await startAntigravity();
+ recordSwitchSuccess(scope);
+ } catch (error) {
+ const reason = toSwitchFailureReason(stage, error);
+ const message = getErrorMessage(error);
+ recordSwitchFailure(scope, reason, message);
+ throw error;
+ }
+}
diff --git a/src/ipc/switchGuard.ts b/src/ipc/switchGuard.ts
new file mode 100644
index 0000000..fc22090
--- /dev/null
+++ b/src/ipc/switchGuard.ts
@@ -0,0 +1,80 @@
+import { logger } from '../utils/logger';
+
+type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
+
+interface SwitchTask {
+ owner: SwitchOwner;
+ action: () => Promise;
+ resolve: (value: unknown) => void;
+ reject: (reason?: unknown) => void;
+}
+
+let activeSwitchOwner: SwitchOwner | null = null;
+const pendingQueue: SwitchTask[] = [];
+let processing = false;
+
+function processQueue(): void {
+ if (processing) {
+ return;
+ }
+
+ const nextTask = pendingQueue.shift();
+ if (!nextTask) {
+ return;
+ }
+
+ processing = true;
+ activeSwitchOwner = nextTask.owner;
+ logger.info(`Acquired switch guard: ${nextTask.owner}`);
+
+ Promise.resolve()
+ .then(nextTask.action)
+ .then((result) => {
+ nextTask.resolve(result);
+ })
+ .catch((error) => {
+ nextTask.reject(error);
+ })
+ .finally(() => {
+ logger.info(`Released switch guard: ${nextTask.owner}`);
+ activeSwitchOwner = null;
+ processing = false;
+ processQueue();
+ });
+}
+
+export async function runWithSwitchGuard(
+ owner: SwitchOwner,
+ action: () => Promise,
+): Promise {
+ return await new Promise((resolve, reject) => {
+ pendingQueue.push({
+ owner,
+ action: async () => await action(),
+ resolve: (value) => {
+ resolve(value as T);
+ },
+ reject,
+ });
+ logger.info(
+ `Queued switch request: ${owner} (active=${activeSwitchOwner || 'none'}, pending=${pendingQueue.length})`,
+ );
+ processQueue();
+ });
+}
+
+export function getActiveSwitchOwner(): SwitchOwner | null {
+ return activeSwitchOwner;
+}
+
+export function getSwitchGuardSnapshot(): {
+ activeOwner: SwitchOwner | null;
+ pendingOwners: SwitchOwner[];
+ pendingCount: number;
+} {
+ return {
+ activeOwner: activeSwitchOwner,
+ pendingOwners: pendingQueue.map((item) => item.owner),
+ pendingCount: pendingQueue.length,
+ };
+}
diff --git a/src/ipc/switchMetrics.ts b/src/ipc/switchMetrics.ts
new file mode 100644
index 0000000..421c391
--- /dev/null
+++ b/src/ipc/switchMetrics.ts
@@ -0,0 +1,113 @@
+import { logger } from '../utils/logger';
+
+type SwitchScope = 'local' | 'cloud';
+
+export type SwitchFailureReason =
+ | 'unknown'
+ | 'process_close_failed'
+ | 'missing_bound_profile'
+ | 'apply_device_profile_failed'
+ | 'perform_switch_failed'
+ | 'start_process_failed';
+
+interface SwitchFailureMetadata {
+ reason: SwitchFailureReason;
+ message: string;
+ occurredAt: number;
+}
+
+interface SwitchMetricBucket {
+ switchSuccess: number;
+ switchFailure: number;
+ rollbackAttempt: number;
+ rollbackSuccess: number;
+ rollbackFailure: number;
+ failureReasons: Record;
+ lastFailure: SwitchFailureMetadata | null;
+}
+
+type SwitchMetricMap = Record;
+
+const metrics: SwitchMetricMap = {
+ local: {
+ switchSuccess: 0,
+ switchFailure: 0,
+ rollbackAttempt: 0,
+ rollbackSuccess: 0,
+ rollbackFailure: 0,
+ failureReasons: {
+ unknown: 0,
+ process_close_failed: 0,
+ missing_bound_profile: 0,
+ apply_device_profile_failed: 0,
+ perform_switch_failed: 0,
+ start_process_failed: 0,
+ },
+ lastFailure: null,
+ },
+ cloud: {
+ switchSuccess: 0,
+ switchFailure: 0,
+ rollbackAttempt: 0,
+ rollbackSuccess: 0,
+ rollbackFailure: 0,
+ failureReasons: {
+ unknown: 0,
+ process_close_failed: 0,
+ missing_bound_profile: 0,
+ apply_device_profile_failed: 0,
+ perform_switch_failed: 0,
+ start_process_failed: 0,
+ },
+ lastFailure: null,
+ },
+};
+
+function logSnapshot(scope: SwitchScope, reason: string): void {
+ logger.info(`[switch-metrics] ${scope} ${reason}`, metrics[scope]);
+}
+
+export function recordSwitchSuccess(scope: SwitchScope): void {
+ metrics[scope].switchSuccess += 1;
+ logSnapshot(scope, 'switch_success');
+}
+
+export function recordSwitchFailure(
+ scope: SwitchScope,
+ reason: SwitchFailureReason = 'unknown',
+ message: string = '',
+): void {
+ metrics[scope].switchFailure += 1;
+ metrics[scope].failureReasons[reason] += 1;
+ metrics[scope].lastFailure = {
+ reason,
+ message,
+ occurredAt: Date.now(),
+ };
+ logSnapshot(scope, 'switch_failure');
+}
+
+export function recordSwitchRollback(scope: SwitchScope, success: boolean): void {
+ metrics[scope].rollbackAttempt += 1;
+ if (success) {
+ metrics[scope].rollbackSuccess += 1;
+ } else {
+ metrics[scope].rollbackFailure += 1;
+ }
+ logSnapshot(scope, success ? 'rollback_success' : 'rollback_failure');
+}
+
+export function getSwitchMetricsSnapshot(): SwitchMetricMap {
+ return {
+ local: {
+ ...metrics.local,
+ failureReasons: { ...metrics.local.failureReasons },
+ lastFailure: metrics.local.lastFailure ? { ...metrics.local.lastFailure } : null,
+ },
+ cloud: {
+ ...metrics.cloud,
+ failureReasons: { ...metrics.cloud.failureReasons },
+ lastFailure: metrics.cloud.lastFailure ? { ...metrics.cloud.lastFailure } : null,
+ },
+ };
+}
diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts
index 0c2dccb..b95076c 100644
--- a/src/localization/i18n.ts
+++ b/src/localization/i18n.ts
@@ -238,6 +238,7 @@ i18n
unknown: 'Unknown User',
actions: 'Actions',
useAccount: 'Use Account',
+ identityProfile: 'Identity Profile',
refresh: 'Refresh Quota',
delete: 'Delete Account',
noQuota: 'No quota data',
@@ -245,6 +246,31 @@ i18n
resetTime: 'Reset time',
resetUnknown: 'Unknown',
},
+ identity: {
+ title: 'Identity Profile',
+ loading: 'Loading...',
+ generateAndBind: 'Create and Bind',
+ captureAndBind: 'Capture and Bind Current',
+ restoreOriginal: 'Restore Baseline',
+ openFolder: 'Open Identity Storage',
+ previewTitle: 'Generated Identity Preview',
+ confirm: 'Confirm',
+ cancel: 'Cancel',
+ close: 'Close',
+ currentStorage: 'Current Runtime Identity',
+ accountBinding: 'Bound Account Identity',
+ history: 'Identity History',
+ noHistory: 'No identity history',
+ current: 'Active',
+ restore: 'Restore',
+ generateSuccess: 'Identity created and bound',
+ captureSuccess: 'Current identity captured and bound',
+ restoreOriginalSuccess: 'Baseline identity restored',
+ restoreVersionSuccess: 'Historical identity restored',
+ deleteVersionSuccess: 'Historical identity deleted',
+ openFolderSuccess: 'Identity storage opened',
+ baseline: 'Baseline Identity',
+ },
list: {
noAccounts: 'No cloud accounts added yet.',
},
@@ -552,6 +578,7 @@ i18n
unknown: '未知用户',
actions: '操作',
useAccount: '使用账号',
+ identityProfile: '身份配置',
refresh: '刷新配额',
delete: '删除账号',
noQuota: '无配额数据',
@@ -559,6 +586,31 @@ i18n
resetTime: '重置时间',
resetUnknown: '未知',
},
+ identity: {
+ title: '身份配置',
+ loading: '加载中...',
+ generateAndBind: '生成并绑定',
+ captureAndBind: '采集当前并绑定',
+ restoreOriginal: '恢复基线配置',
+ openFolder: '打开身份存储目录',
+ previewTitle: '生成配置预览',
+ confirm: '确认',
+ cancel: '取消',
+ close: '关闭',
+ currentStorage: '当前运行配置',
+ accountBinding: '账号绑定配置',
+ history: '配置历史',
+ noHistory: '暂无配置历史',
+ current: '当前生效',
+ restore: '恢复',
+ generateSuccess: '生成并绑定成功',
+ captureSuccess: '采集当前并绑定成功',
+ restoreOriginalSuccess: '基线配置已恢复',
+ restoreVersionSuccess: '历史配置已恢复',
+ deleteVersionSuccess: '历史配置已删除',
+ openFolderSuccess: '身份存储目录已打开',
+ baseline: '基线配置',
+ },
list: {
noAccounts: '暂无云账号。',
},
@@ -869,6 +921,7 @@ i18n
unknown: 'Неизвестный',
actions: 'Действия',
useAccount: 'Использовать этот аккаунт',
+ identityProfile: 'Профиль идентичности',
refresh: 'Обновить квоту',
delete: 'Удалить аккаунт',
noQuota: 'Нет данных',
@@ -876,6 +929,31 @@ i18n
resetTime: 'Время сброса',
resetUnknown: 'Неизвестно',
},
+ identity: {
+ title: 'Профиль идентичности',
+ loading: 'Загрузка...',
+ generateAndBind: 'Создать и привязать',
+ captureAndBind: 'Считать текущий и привязать',
+ restoreOriginal: 'Восстановить базовый профиль',
+ openFolder: 'Открыть хранилище профилей',
+ previewTitle: 'Предпросмотр сгенерированного профиля',
+ confirm: 'Подтвердить',
+ cancel: 'Отмена',
+ close: 'Закрыть',
+ currentStorage: 'Текущий профиль окружения',
+ accountBinding: 'Профиль, привязанный к аккаунту',
+ history: 'История профилей',
+ noHistory: 'История профилей пуста',
+ current: 'Активный',
+ restore: 'Восстановить',
+ generateSuccess: 'Профиль создан и привязан',
+ captureSuccess: 'Текущий профиль считан и привязан',
+ restoreOriginalSuccess: 'Базовый профиль восстановлен',
+ restoreVersionSuccess: 'Исторический профиль восстановлен',
+ deleteVersionSuccess: 'Исторический профиль удален',
+ openFolderSuccess: 'Хранилище профилей открыто',
+ baseline: 'Базовый профиль',
+ },
list: {
noAccounts: 'Нет добавленных облачных аккаунтов.',
},
diff --git a/src/styles/global.css b/src/styles/global.css
index 8645b76..af3b393 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -113,6 +113,9 @@
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
}
+ body {
+ letter-spacing: var(--tracking-normal);
+ }
}
@layer base {
@@ -200,3 +203,182 @@
@apply bg-background text-foreground;
}
}
+
+@theme inline {
+ --font-sans: Geist, sans-serif;
+ --font-mono: Geist Mono, monospace;
+ --font-serif: Georgia, serif;
+ --radius: 0.5rem;
+ --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
+ --tracking-tight: calc(var(--tracking-normal) - 0.025em);
+ --tracking-wide: calc(var(--tracking-normal) + 0.025em);
+ --tracking-wider: calc(var(--tracking-normal) + 0.05em);
+ --tracking-widest: calc(var(--tracking-normal) + 0.1em);
+ --tracking-normal: var(--tracking-normal);
+ --shadow-2xl: var(--shadow-2xl);
+ --shadow-xl: var(--shadow-xl);
+ --shadow-lg: var(--shadow-lg);
+ --shadow-md: var(--shadow-md);
+ --shadow: var(--shadow);
+ --shadow-sm: var(--shadow-sm);
+ --shadow-xs: var(--shadow-xs);
+ --shadow-2xs: var(--shadow-2xs);
+ --spacing: var(--spacing);
+ --letter-spacing: var(--letter-spacing);
+ --shadow-offset-y: var(--shadow-offset-y);
+ --shadow-offset-x: var(--shadow-offset-x);
+ --shadow-spread: var(--shadow-spread);
+ --shadow-blur: var(--shadow-blur);
+ --shadow-opacity: var(--shadow-opacity);
+ --color-shadow-color: var(--shadow-color);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+}
+
+:root {
+ --background: oklch(0.9900 0 0);
+ --foreground: oklch(0 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0 0 0);
+ --popover: oklch(0.9900 0 0);
+ --popover-foreground: oklch(0 0 0);
+ --primary: oklch(0 0 0);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.9400 0 0);
+ --secondary-foreground: oklch(0 0 0);
+ --muted: oklch(0.9700 0 0);
+ --muted-foreground: oklch(0.4400 0 0);
+ --accent: oklch(0.9400 0 0);
+ --accent-foreground: oklch(0 0 0);
+ --destructive: oklch(0.6300 0.1900 23.0300);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.9200 0 0);
+ --input: oklch(0.9400 0 0);
+ --ring: oklch(0 0 0);
+ --chart-1: oklch(0.8100 0.1700 75.3500);
+ --chart-2: oklch(0.5500 0.2200 264.5300);
+ --chart-3: oklch(0.7200 0 0);
+ --chart-4: oklch(0.9200 0 0);
+ --chart-5: oklch(0.5600 0 0);
+ --radius: 0.5rem;
+ --sidebar: oklch(0.9900 0 0);
+ --sidebar-foreground: oklch(0 0 0);
+ --sidebar-primary: oklch(0 0 0);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.9400 0 0);
+ --sidebar-accent-foreground: oklch(0 0 0);
+ --sidebar-border: oklch(0.9400 0 0);
+ --sidebar-ring: oklch(0 0 0);
+ --font-sans: Geist, sans-serif;
+ --font-serif: Georgia, serif;
+ --font-mono: Geist Mono, monospace;
+ --shadow-color: hsl(0 0% 0%);
+ --shadow-opacity: 0.18;
+ --shadow-blur: 2px;
+ --shadow-spread: 0px;
+ --shadow-offset-x: 0px;
+ --shadow-offset-y: 1px;
+ --letter-spacing: 0em;
+ --spacing: 0.25rem;
+ --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
+ --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
+ --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
+ --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
+ --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
+ --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
+ --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
+ --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
+ --tracking-normal: 0em;
+}
+
+.dark {
+ --background: oklch(0 0 0);
+ --foreground: oklch(1 0 0);
+ --card: oklch(0.1400 0 0);
+ --card-foreground: oklch(1 0 0);
+ --popover: oklch(0.1800 0 0);
+ --popover-foreground: oklch(1 0 0);
+ --primary: oklch(1 0 0);
+ --primary-foreground: oklch(0 0 0);
+ --secondary: oklch(0.2500 0 0);
+ --secondary-foreground: oklch(1 0 0);
+ --muted: oklch(0.2300 0 0);
+ --muted-foreground: oklch(0.7200 0 0);
+ --accent: oklch(0.3200 0 0);
+ --accent-foreground: oklch(1 0 0);
+ --destructive: oklch(0.6900 0.2000 23.9100);
+ --destructive-foreground: oklch(0 0 0);
+ --border: oklch(0.2600 0 0);
+ --input: oklch(0.3200 0 0);
+ --ring: oklch(0.7200 0 0);
+ --chart-1: oklch(0.8100 0.1700 75.3500);
+ --chart-2: oklch(0.5800 0.2100 260.8400);
+ --chart-3: oklch(0.5600 0 0);
+ --chart-4: oklch(0.4400 0 0);
+ --chart-5: oklch(0.9200 0 0);
+ --radius: 0.5rem;
+ --sidebar: oklch(0.1800 0 0);
+ --sidebar-foreground: oklch(1 0 0);
+ --sidebar-primary: oklch(1 0 0);
+ --sidebar-primary-foreground: oklch(0 0 0);
+ --sidebar-accent: oklch(0.3200 0 0);
+ --sidebar-accent-foreground: oklch(1 0 0);
+ --sidebar-border: oklch(0.3200 0 0);
+ --sidebar-ring: oklch(0.7200 0 0);
+ --font-sans: Geist, sans-serif;
+ --font-serif: Georgia, serif;
+ --font-mono: Geist Mono, monospace;
+ --shadow-color: hsl(0 0% 0%);
+ --shadow-opacity: 0.18;
+ --shadow-blur: 2px;
+ --shadow-spread: 0px;
+ --shadow-offset-x: 0px;
+ --shadow-offset-y: 1px;
+ --letter-spacing: 0em;
+ --spacing: 0.25rem;
+ --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
+ --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
+ --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
+ --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
+ --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
+ --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
+ --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
+ --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
+}
\ No newline at end of file
diff --git a/src/tests/unit/account.test.ts b/src/tests/unit/account.test.ts
index 1a72df1..f7589e4 100644
--- a/src/tests/unit/account.test.ts
+++ b/src/tests/unit/account.test.ts
@@ -5,8 +5,12 @@ import {
switchAccount,
deleteAccount,
} from '../../ipc/account/handler';
+import { restoreAccount } from '../../ipc/database/handler';
+import { startAntigravity } from '../../ipc/process/handler';
import fs from 'fs';
import path from 'path';
+import { applyDeviceProfile, generateDeviceProfile } from '../../ipc/device/handler';
+import { getSwitchGuardSnapshot } from '../../ipc/switchGuard';
// Mock dependencies
vi.mock('../../utils/paths', async () => {
@@ -35,13 +39,35 @@ vi.mock('../../ipc/database/handler', () => ({
vi.mock('../../ipc/process/handler', () => ({
closeAntigravity: vi.fn(),
startAntigravity: vi.fn(),
+ _waitForProcessExit: vi.fn(),
isProcessRunning: vi.fn(() => Promise.resolve(false)),
}));
+vi.mock('../../ipc/device/handler', () => ({
+ applyDeviceProfile: vi.fn(),
+ ensureGlobalOriginalFromCurrentStorage: vi.fn(),
+ generateDeviceProfile: vi.fn(() => ({
+ machineId: 'auth0|user_test',
+ macMachineId: 'mac-machine-id',
+ devDeviceId: 'dev-device-id',
+ sqmId: '{SQM-ID}',
+ })),
+ loadGlobalOriginalProfile: vi.fn(() => null),
+ isIdentityProfileApplyEnabled: vi.fn(() => true),
+ readCurrentDeviceProfile: vi.fn(() => ({
+ machineId: 'current-machine-id',
+ macMachineId: 'current-mac-machine-id',
+ devDeviceId: 'current-dev-device-id',
+ sqmId: '{CURRENT-SQM-ID}',
+ })),
+ saveGlobalOriginalProfile: vi.fn(),
+}));
+
describe('Account Handler', () => {
const testAgentDir = path.join(process.cwd(), 'temp_test_agent');
beforeEach(() => {
+ vi.clearAllMocks();
if (fs.existsSync(testAgentDir)) {
fs.rmSync(testAgentDir, { recursive: true, force: true });
}
@@ -66,7 +92,29 @@ describe('Account Handler', () => {
it('should switch account', async () => {
const account = await addAccountSnapshot();
await switchAccount(account.id);
- // Mocks should be called, verified by implementation logic not throwing
+ expect(generateDeviceProfile).toHaveBeenCalled();
+ expect(applyDeviceProfile).toHaveBeenCalled();
+ });
+
+ it('should reuse existing device profile on switch', async () => {
+ const account = await addAccountSnapshot();
+ const accountFilePath = path.join(testAgentDir, 'accounts.json');
+ const allAccounts = JSON.parse(fs.readFileSync(accountFilePath, 'utf-8')) as Record;
+ allAccounts[account.id].deviceProfile = {
+ machineId: 'existing-machine',
+ macMachineId: 'existing-mac',
+ devDeviceId: 'existing-dev',
+ sqmId: '{EXISTING-SQM}',
+ };
+ fs.writeFileSync(accountFilePath, JSON.stringify(allAccounts, null, 2), 'utf-8');
+
+ await switchAccount(account.id);
+ expect(applyDeviceProfile).toHaveBeenCalledWith({
+ machineId: 'existing-machine',
+ macMachineId: 'existing-mac',
+ devDeviceId: 'existing-dev',
+ sqmId: '{EXISTING-SQM}',
+ });
});
it('should delete account', async () => {
@@ -76,4 +124,52 @@ describe('Account Handler', () => {
const accounts = await listAccountsData();
expect(accounts).toHaveLength(0);
});
+
+ it('should fail fast without rollback or forced restart when restore fails', async () => {
+ const restoreMock = vi.mocked(restoreAccount);
+ restoreMock.mockImplementationOnce(() => {
+ throw new Error('restore_failed');
+ });
+
+ const account = await addAccountSnapshot();
+ await expect(switchAccount(account.id)).rejects.toThrow('restore_failed');
+
+ expect(applyDeviceProfile).toHaveBeenCalledTimes(1);
+ expect(applyDeviceProfile).toHaveBeenCalledWith({
+ machineId: 'auth0|user_test',
+ macMachineId: 'mac-machine-id',
+ devDeviceId: 'dev-device-id',
+ sqmId: '{SQM-ID}',
+ });
+ expect(startAntigravity).not.toHaveBeenCalled();
+ });
+
+ it('should queue switch requests instead of rejecting concurrent calls', async () => {
+ const account = await addAccountSnapshot();
+
+ const startMock = vi.mocked(startAntigravity);
+ let releaseFirstStart!: () => void;
+ const firstStartBlocker = new Promise((resolve) => {
+ releaseFirstStart = resolve;
+ });
+ startMock.mockImplementationOnce(async () => {
+ await firstStartBlocker;
+ });
+
+ const firstSwitch = switchAccount(account.id);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const secondSwitch = switchAccount(account.id);
+
+ const runningSnapshot = getSwitchGuardSnapshot();
+ expect(runningSnapshot.activeOwner).toBe('local-account-switch');
+ expect(runningSnapshot.pendingCount).toBeGreaterThanOrEqual(1);
+
+ releaseFirstStart();
+
+ await Promise.all([firstSwitch, secondSwitch]);
+
+ const finalSnapshot = getSwitchGuardSnapshot();
+ expect(finalSnapshot.activeOwner).toBeNull();
+ expect(finalSnapshot.pendingCount).toBe(0);
+ });
});
diff --git a/src/tests/unit/cloudHandler-sync.test.ts b/src/tests/unit/cloudHandler-sync.test.ts
index c5f5b02..14ac1ea 100644
--- a/src/tests/unit/cloudHandler-sync.test.ts
+++ b/src/tests/unit/cloudHandler-sync.test.ts
@@ -266,4 +266,124 @@ describe('CloudAccountRepo.syncFromIDE', () => {
expect(wroteUnifiedKey).toBe(true);
expect(updatedOldKey).toBe(false);
});
+
+});
+
+describe('cloud switch fail-fast path', () => {
+ afterEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ });
+
+ it('should fail fast without rollback or forced restart when inject fails', async () => {
+ vi.resetModules();
+
+ const applyDeviceProfileMock = vi.fn();
+ const startAntigravityMock = vi.fn(async () => undefined);
+ const recordSwitchFailureMock = vi.fn();
+ const recordSwitchSuccessMock = vi.fn();
+
+ const account = {
+ id: 'acc-1',
+ email: 'cloud@test.dev',
+ name: 'Cloud User',
+ provider: 'google' as const,
+ token: {
+ access_token: 'access',
+ refresh_token: 'refresh',
+ expires_in: 3600,
+ expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
+ token_type: 'Bearer',
+ email: 'cloud@test.dev',
+ },
+ created_at: Math.floor(Date.now() / 1000),
+ last_used: Math.floor(Date.now() / 1000),
+ device_profile: {
+ machineId: 'target-machine',
+ macMachineId: 'target-mac',
+ devDeviceId: 'target-dev',
+ sqmId: '{TARGET-SQM}',
+ },
+ };
+
+ vi.doMock('../../ipc/database/cloudHandler', () => ({
+ CloudAccountRepo: {
+ getAccount: vi.fn(async () => account),
+ setDeviceBinding: vi.fn(),
+ updateToken: vi.fn(async () => undefined),
+ injectCloudToken: vi.fn(() => {
+ throw new Error('inject_failed');
+ }),
+ updateLastUsed: vi.fn(),
+ setActive: vi.fn(),
+ getSetting: vi.fn(() => 'en'),
+ },
+ }));
+
+ vi.doMock('../../ipc/device/handler', () => ({
+ applyDeviceProfile: applyDeviceProfileMock,
+ ensureGlobalOriginalFromCurrentStorage: vi.fn(),
+ generateDeviceProfile: vi.fn(() => account.device_profile),
+ isIdentityProfileApplyEnabled: vi.fn(() => true),
+ readCurrentDeviceProfile: vi.fn(() => ({
+ machineId: 'prev-machine',
+ macMachineId: 'prev-mac',
+ devDeviceId: 'prev-dev',
+ sqmId: '{PREV-SQM}',
+ })),
+ }));
+
+ vi.doMock('../../ipc/process/handler', () => ({
+ closeAntigravity: vi.fn(async () => undefined),
+ startAntigravity: startAntigravityMock,
+ _waitForProcessExit: vi.fn(async () => undefined),
+ }));
+
+ vi.doMock('../../ipc/switchMetrics', () => ({
+ recordSwitchFailure: recordSwitchFailureMock,
+ recordSwitchSuccess: recordSwitchSuccessMock,
+ }));
+
+ vi.doMock('../../ipc/tray/handler', () => ({
+ updateTrayMenu: vi.fn(),
+ }));
+
+ vi.doMock('../../utils/paths', () => ({
+ getAntigravityDbPaths: () => [],
+ }));
+
+ vi.doMock('../../utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+ }));
+
+ vi.doMock('../../services/GoogleAPIService', () => ({
+ GoogleAPIService: {
+ refreshAccessToken: vi.fn(async () => undefined),
+ },
+ }));
+
+ vi.doMock('electron', () => ({
+ shell: {
+ openExternal: vi.fn(),
+ },
+ }));
+
+ const { switchCloudAccount } = await import('../../ipc/cloud/handler');
+ await expect(switchCloudAccount('acc-1')).rejects.toThrow('Switch failed: inject_failed');
+
+ expect(applyDeviceProfileMock).toHaveBeenCalledTimes(1);
+ expect(applyDeviceProfileMock).toHaveBeenCalledWith(account.device_profile);
+ expect(startAntigravityMock).not.toHaveBeenCalled();
+ expect(recordSwitchFailureMock).toHaveBeenCalledWith(
+ 'cloud',
+ 'perform_switch_failed',
+ expect.stringContaining('inject_failed'),
+ );
+ expect(recordSwitchSuccessMock).not.toHaveBeenCalled();
+ });
});
diff --git a/src/tests/unit/paths.test.ts b/src/tests/unit/paths.test.ts
index 61eb90c..b3061f6 100644
--- a/src/tests/unit/paths.test.ts
+++ b/src/tests/unit/paths.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
getAppDataDir,
getAntigravityDbPath,
+ getAntigravityStoragePath,
getAntigravityExecutablePath,
} from '../../utils/paths';
@@ -17,6 +18,11 @@ describe('Path Utilities', () => {
expect(dbPath).toContain('state.vscdb');
});
+ it('should get correct storage path', () => {
+ const storagePath = getAntigravityStoragePath();
+ expect(storagePath).toContain('storage.json');
+ });
+
it('should get correct executable path', () => {
const execPath = getAntigravityExecutablePath();
if (process.platform === 'linux') {
diff --git a/src/types/account.ts b/src/types/account.ts
index 9ac400e..8b8b128 100644
--- a/src/types/account.ts
+++ b/src/types/account.ts
@@ -1,11 +1,35 @@
import { z } from 'zod';
+export interface DeviceProfile {
+ machineId: string;
+ macMachineId: string;
+ devDeviceId: string;
+ sqmId: string;
+}
+
+export interface DeviceProfileVersion {
+ id: string;
+ createdAt: number;
+ label: string;
+ profile: DeviceProfile;
+ isCurrent: boolean;
+}
+
+export interface DeviceProfilesSnapshot {
+ currentStorage?: DeviceProfile;
+ boundProfile?: DeviceProfile;
+ history: DeviceProfileVersion[];
+ baseline?: DeviceProfile;
+}
+
export interface Account {
id: string; // UUID
name: string;
email: string;
backup_file?: string;
avatar_url?: string;
+ deviceProfile?: DeviceProfile;
+ deviceHistory?: DeviceProfileVersion[];
created_at: string;
last_used: string;
}
@@ -30,12 +54,36 @@ export interface AccountInfo {
// Zod Schemas for validation
+export const DeviceProfileSchema = z.object({
+ machineId: z.string(),
+ macMachineId: z.string(),
+ devDeviceId: z.string(),
+ sqmId: z.string(),
+});
+
+export const DeviceProfileVersionSchema = z.object({
+ id: z.string(),
+ createdAt: z.number(),
+ label: z.string(),
+ profile: DeviceProfileSchema,
+ isCurrent: z.boolean(),
+});
+
+export const DeviceProfilesSnapshotSchema = z.object({
+ currentStorage: DeviceProfileSchema.optional(),
+ boundProfile: DeviceProfileSchema.optional(),
+ history: z.array(DeviceProfileVersionSchema),
+ baseline: DeviceProfileSchema.optional(),
+});
+
export const AccountSchema = z.object({
id: z.string(), // Relaxed from .uuid()
name: z.string(), // Relaxed from .min(1)
email: z.string(), // Relaxed from .email()
backup_file: z.string().optional(),
avatar_url: z.string().optional(),
+ deviceProfile: DeviceProfileSchema.optional(),
+ deviceHistory: z.array(DeviceProfileVersionSchema).optional(),
created_at: z.string(),
last_used: z.string(),
});
diff --git a/src/types/cloudAccount.ts b/src/types/cloudAccount.ts
index a640862..1af5ddc 100644
--- a/src/types/cloudAccount.ts
+++ b/src/types/cloudAccount.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { DeviceProfileSchema, DeviceProfileVersionSchema, type DeviceProfile, type DeviceProfileVersion } from './account';
export interface CloudTokenData {
access_token: string;
@@ -29,6 +30,8 @@ export interface CloudAccount {
avatar_url?: string | null;
token: CloudTokenData;
quota?: CloudQuotaData;
+ device_profile?: DeviceProfile;
+ device_history?: DeviceProfileVersion[];
created_at: number;
last_used: number; // Unix timestamp
status?: 'active' | 'rate_limited' | 'expired';
@@ -65,6 +68,8 @@ export const CloudAccountSchema = z.object({
avatar_url: z.string().optional().nullable(),
token: CloudTokenDataSchema,
quota: CloudQuotaDataSchema.optional(),
+ device_profile: DeviceProfileSchema.optional(),
+ device_history: z.array(DeviceProfileVersionSchema).optional(),
created_at: z.number(),
last_used: z.number(),
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
diff --git a/src/types/db.ts b/src/types/db.ts
index d3f77bd..eac7b7d 100644
--- a/src/types/db.ts
+++ b/src/types/db.ts
@@ -8,6 +8,8 @@ export const CloudAccountRowSchema = z.object({
avatar_url: z.string().nullable(),
token_json: z.string(),
quota_json: z.string().nullable(),
+ device_profile_json: z.string().nullable().optional(),
+ device_history_json: z.string().nullable().optional(),
created_at: z.number(),
last_used: z.number(),
status: z.string().nullable(),
diff --git a/src/utils/paths.ts b/src/utils/paths.ts
index ca4ac25..f52f76f 100644
--- a/src/utils/paths.ts
+++ b/src/utils/paths.ts
@@ -158,6 +158,52 @@ export function getAntigravityDbPaths(): string[] {
return paths;
}
+export function getAntigravityStoragePaths(): string[] {
+ const appData = getAppDataDir();
+ const paths: string[] = [];
+ const home = os.homedir();
+
+ if (isWsl()) {
+ paths.push(path.join(appData, 'User', 'globalStorage', 'storage.json'));
+ paths.push(path.join(appData, 'User', 'storage.json'));
+ paths.push(path.join(appData, 'storage.json'));
+ return paths;
+ }
+
+ if (process.platform === 'linux') {
+ paths.push(path.join(appData, 'User', 'globalStorage', 'storage.json'));
+ paths.push(path.join(appData, 'User', 'storage.json'));
+ paths.push(path.join(appData, 'storage.json'));
+ return paths;
+ }
+
+ if (process.platform === 'darwin') {
+ paths.push(
+ path.join(
+ home,
+ 'Library',
+ 'Application Support',
+ 'Antigravity',
+ 'User',
+ 'globalStorage',
+ 'storage.json',
+ ),
+ );
+ paths.push(path.join(home, 'Library', 'Application Support', 'Antigravity', 'storage.json'));
+ return paths;
+ }
+
+ paths.push(path.join(appData, 'User', 'globalStorage', 'storage.json'));
+ paths.push(path.join(appData, 'User', 'storage.json'));
+ paths.push(path.join(appData, 'storage.json'));
+ return paths;
+}
+
+export function getAntigravityStoragePath(): string {
+ const paths = getAntigravityStoragePaths();
+ return paths.length > 0 ? paths[0] : '';
+}
+
// Keep for backward compatibility if needed, but prefer getAntigravityDbPaths
export function getAntigravityDbPath(): string {
const paths = getAntigravityDbPaths();