feat: complete account-bound profile switching and hardening (#78)
This commit is contained in:
31 files changed
+3612
-197
No files matched your search
+265
-23
@@ -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<string, Account>;
|
||||
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<Account> {
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
deviceHistory: [],
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
@@ -171,7 +223,184 @@ export async function addAccountSnapshot(): Promise<Account> {
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
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<DeviceProfile> {
|
||||
return generateDeviceProfile();
|
||||
}
|
||||
|
||||
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
|
||||
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<DeviceProfile> {
|
||||
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<DeviceProfile> {
|
||||
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<DeviceProfile> {
|
||||
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<DeviceProfile> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DeviceProfile> {
|
||||
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<void> {
|
||||
const directory = getStorageDirectoryPath();
|
||||
const result = await shell.openPath(directory);
|
||||
if (result) {
|
||||
throw new Error(`Failed to open device folder: ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user