diff --git a/openspec/changes/update-device-fingerprint-hardening/acceptance-checklist.md b/openspec/changes/update-device-fingerprint-hardening/acceptance-checklist.md new file mode 100644 index 0000000..4a2e502 --- /dev/null +++ b/openspec/changes/update-device-fingerprint-hardening/acceptance-checklist.md @@ -0,0 +1,40 @@ +# Device Fingerprint Capability Acceptance Checklist + +## Backend Capabilities + +- `get_storage_path / get_storage_dir / get_state_db_path` +- `read_profile / write_profile` +- `generate_profile` +- `sync_service_machine_id / sync_service_machine_id_from_storage` +- `load_global_original / save_global_original` +- `get_device_profiles` +- `bind_device_profile / bind_device_profile_with_profile` +- `restore_device_version / delete_device_version` +- `restore_original_device` +- `open_device_folder` + +## UI Capabilities + +- Show current storage / account binding / history / baseline +- Generate and bind +- Capture and bind +- Restore original fingerprint +- Restore history revision +- Delete history revision +- Open storage folder + +## Switching Behavior + +- Local account switching applies bound fingerprint +- Cloud account switching applies bound fingerprint +- Concurrent switch requests use queued serial semantics +- Failure semantics are fail-fast (no extra transaction orchestration) + +## Compatibility Scope + +- Legacy data missing optional fields: fill defaults +- Corrupted structure/type mismatch: explicit failure + +## Status Marking + +Please mark each item as: `Passed / Failed / Diverged (with reason)`. diff --git a/openspec/changes/update-device-fingerprint-hardening/design.md b/openspec/changes/update-device-fingerprint-hardening/design.md new file mode 100644 index 0000000..9aff47d --- /dev/null +++ b/openspec/changes/update-device-fingerprint-hardening/design.md @@ -0,0 +1,50 @@ +## Context + +Device-fingerprint capability spans the data layer, switch-orchestration layer, and UI management layer. +Historical proposals split the same capability across multiple change IDs, which fragmented implementation and acceptance criteria. +This design combines "feature completeness + robustness" into a single constraint set to keep implementation, verification, and release aligned. + +## Goals / Non-Goals + +- Goals: + - Unify scope and acceptance criteria for device-fingerprint capability. + - Ensure behavioral consistency and diagnosability for local/cloud switching. + - Keep consistent behavior on default paths across Windows/macOS/Linux. + - Keep legacy-data compatibility predictable and verifiable. +- Non-Goals: + - Support for non-default runtime configuration paths. + - Business strategy expansion unrelated to this capability. + +## Decisions + +- Decision: Use a single change ID to manage the complete capability. + - Rationale: Reduce governance overhead caused by duplicate proposals. +- Decision: Apply fingerprint using a two-phase flow with strict pre-commit verification. + - Rationale: Reduce mixed-state risk caused by partial success. +- Decision: Use versioned payload + boundary validation for fingerprint/history data. + - Rationale: Avoid silent data contamination and define clear compatibility/failure boundaries. +- Decision: Use fail-fast semantics for switch failures and return structured failure reasons. + - Rationale: Make failure behavior predictable and observable. +- Decision: Trigger safe mode for temporary degradation after consecutive failures. + - Rationale: Keep switching flow available during fault windows. + +## Risks / Trade-offs + +- Risk: Stricter validation may reject legacy dirty data. + - Mitigation: Normalize missing optional fields; fail-fast only for critical structure errors. +- Risk: Validation and diagnostic extensions add minor performance cost. + - Mitigation: Keep validation lightweight and limited to critical boundaries. +- Risk: More UI management actions may increase user misoperations. + - Mitigation: Add confirmation interactions and unit-test coverage for failure branches. + +## Migration Plan + +1. Use `update-device-fingerprint-hardening` as the single active proposal. +2. Merge historical duplicate proposal content into this proposal (`proposal`/`design`/`tasks`/`spec`). +3. Close and remove duplicate proposal directories. +4. Deliver with unified task checklist and acceptance checklist. + +## Open Questions + +- Should safe mode exit only by time window, or also allow manual recovery? +- Should diagnostic snapshots be exposed in the renderer as a visual panel? diff --git a/openspec/changes/update-device-fingerprint-hardening/proposal.md b/openspec/changes/update-device-fingerprint-hardening/proposal.md new file mode 100644 index 0000000..c30a3a3 --- /dev/null +++ b/openspec/changes/update-device-fingerprint-hardening/proposal.md @@ -0,0 +1,39 @@ +# Change: Device Fingerprint Consolidation and Hardening + +## What Changes + +- Unify backend scope: + - Fingerprint read/write with synchronized updates to `storage.json` and `state.vscdb` + - Account-level binding, revision history, and baseline restore + - Fingerprint apply orchestration during switch flows (local/cloud) +- Unify management scope: + - Device fingerprint management dialog + - Generate-and-bind, capture-and-bind, restore revision, delete revision, restore baseline, and open storage folder +- Unify robustness scope: + - Two-phase apply (`prepare -> verify -> commit`) + - Deterministic rollback before commit on failure + - Failure reason classification, safe mode, degradation strategy, and diagnostics snapshots + - Versioned payload with boundary validation (lenient compatibility + strict failure for critical structure) +- Path scope: this stage focuses on the default runtime configuration path. + +## Non-Goals + +- Do not promise any anti-detection, anti-ban, or risk-control bypass outcome. +- Do not introduce token/protobuf semantic changes unrelated to device fingerprinting. +- Do not perform ORM or large database refactors in this change. + +## Impact + +- Affected specs: `device-fingerprint-hardening` +- Affected code: + - `src/ipc/device/handler.ts` + - `src/ipc/account/handler.ts` + - `src/ipc/cloud/handler.ts` + - `src/ipc/switchFlow.ts` + - `src/ipc/switchMetrics.ts` + - `src/ipc/database/cloudHandler.ts` + - `src/components/DeviceFingerprintDialog.tsx` + - `src/actions/account.ts` + - `src/actions/cloud.ts` + - `src/tests/unit/account.test.ts` + - `src/tests/unit/cloudHandler-sync.test.ts` diff --git a/openspec/changes/update-device-fingerprint-hardening/specs/device-fingerprint-hardening/spec.md b/openspec/changes/update-device-fingerprint-hardening/specs/device-fingerprint-hardening/spec.md new file mode 100644 index 0000000..da9538c --- /dev/null +++ b/openspec/changes/update-device-fingerprint-hardening/specs/device-fingerprint-hardening/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Complete Device Fingerprint Management Capability + +The system SHALL provide complete device-fingerprint management capability: view current storage fingerprint, account-bound fingerprint, history revisions, and baseline fingerprint, and execute generate/capture/bind/restore/delete operations. + +#### Scenario: Open device fingerprint management + +- **WHEN** an operator opens device fingerprint management for a specific account +- **THEN** the system returns current storage fingerprint, account-bound fingerprint, history revisions, and baseline fingerprint (if present) + +#### Scenario: Generate and bind a new fingerprint + +- **WHEN** the operator confirms "Generate and Bind" +- **THEN** the system generates a valid new fingerprint and binds it to the target account +- **AND** the new revision is appended to history and marked as current + +#### Scenario: Capture current and bind + +- **WHEN** the operator confirms "Capture Current and Bind" +- **THEN** the system reads the current storage fingerprint and binds it to the target account +- **AND** a new revision is appended to history + +### Requirement: Switch Flow Must Apply Account-Bound Fingerprint + +The system SHALL ensure the target account has a valid bound fingerprint before continuing switch flow for both local-account and cloud-account switching. + +#### Scenario: Target account has no bound fingerprint during switching + +- **GIVEN** the target account exists and has no bound fingerprint +- **WHEN** account switching is initiated +- **THEN** the system automatically generates and binds a usable fingerprint +- **AND** then continues with the switch flow + +#### Scenario: Concurrent switch requests + +- **WHEN** multiple switch requests are triggered while a switch is in progress +- **THEN** requests are queued and processed serially +- **AND** no concurrent write conflicts occur + +### Requirement: Fingerprint Apply Must Preserve Storage Consistency + +The system SHALL update `storage.json` and `state.vscdb` in sync when applying device fingerprint, and SHALL ensure `storage.serviceMachineId` equals `devDeviceId`. + +#### Scenario: Normal fingerprint apply + +- **WHEN** the system applies a device fingerprint +- **THEN** telemetry fields in `storage.json` are updated +- **AND** `storage.serviceMachineId` is updated to `devDeviceId` +- **AND** `ItemTable['storage.serviceMachineId']` in `state.vscdb` is updated accordingly + +### Requirement: Device Fingerprint Apply Uses Two-Phase Protocol + +The system SHALL execute device-fingerprint apply with a two-phase protocol (`prepare -> verify -> commit`), and SHALL perform deterministic rollback when failure occurs before commit. + +#### Scenario: Failure during verify phase + +- **WHEN** prepare writes succeed but verify detects inconsistent state +- **THEN** the system rolls back to pre-apply state +- **AND** returns a classifiable failure reason + +#### Scenario: Write failure before commit + +- **WHEN** any critical write fails before commit completes +- **THEN** the system performs rollback +- **AND** post-operation state equals pre-operation state + +### Requirement: Fingerprint Data Uses Versioned Structure and Boundary Validation + +The system SHALL use explicit schema versions for fingerprint and history data, and SHALL validate at read/write boundaries. + +#### Scenario: Missing optional fields + +- **WHEN** history payload lacks optional fields +- **THEN** the system fills default values and continues + +#### Scenario: Critical structure corruption or type mismatch + +- **WHEN** payload has critical structure corruption or critical field type mismatch +- **THEN** the system fails fast and returns a readable error + +### Requirement: Consecutive Failures Trigger Safe Degradation + +The system SHALL enter safe mode when consecutive device-fingerprint apply failures reach a threshold, temporarily disable fingerprint apply, keep switching available, and emit warnings. + +#### Scenario: Consecutive-failure threshold reached + +- **WHEN** consecutive failures reach the threshold +- **THEN** safe mode is activated and fingerprint apply is temporarily disabled +- **AND** switch flow remains available under warning conditions + +### Requirement: Provide Observable Diagnostic Snapshot + +The system SHALL expose a read-only diagnostics interface returning switch-guard state, switch counters, failure-reason counters, latest failure metadata, and safe-mode state. + +#### Scenario: Query diagnostics after failures + +- **WHEN** diagnostics are queried after one or more failures +- **THEN** reason-classified failure counters are returned +- **AND** latest failure details and safe-mode state are returned + +### Requirement: Path Resolution Scope Constraint + +The system SHALL use default runtime configuration paths for path resolution in this capability. + +#### Scenario: Use default runtime configuration paths + +- **WHEN** the system performs device-fingerprint-related path resolution +- **THEN** resolution is completed based on default runtime configuration paths diff --git a/openspec/changes/update-device-fingerprint-hardening/tasks.md b/openspec/changes/update-device-fingerprint-hardening/tasks.md new file mode 100644 index 0000000..ce43559 --- /dev/null +++ b/openspec/changes/update-device-fingerprint-hardening/tasks.md @@ -0,0 +1,44 @@ +## 1. Proposal Consolidation + +- [x] 1.1 Merge duplicate device-fingerprint proposals into `update-device-fingerprint-hardening`. +- [x] 1.2 Unify scope and terminology across proposal/design/spec. +- [ ] 1.3 Complete proposal archival strategy (archive after release). + +## 2. Feature Completeness (Backend) + +- [x] 2.1 Provide APIs for querying/binding/restoring/deleting fingerprint history. +- [x] 2.2 Provide API to restore original device fingerprint. +- [x] 2.3 Provide API to open fingerprint storage folder. +- [x] 2.4 Reuse unified fingerprint-apply chain for local and cloud switching. + +## 3. Feature Completeness (UI) + +- [x] 3.1 Provide management entry via `DeviceFingerprintDialog`. +- [x] 3.2 Support generate-and-bind, capture-and-bind, restore history, and delete history. +- [x] 3.3 Support restore original fingerprint and open storage folder. +- [x] 3.4 Complete i18n copy and error messaging. + +## 4. Robustness Hardening + +- [x] 4.1 Implement two-phase apply in `src/ipc/device/handler.ts` (`prepare -> verify -> commit`). +- [x] 4.2 Perform deterministic rollback on pre-commit failure. +- [x] 4.3 Introduce failure-reason classification and integrate with `src/ipc/switchMetrics.ts`. +- [x] 4.4 Introduce versioned payload and strict boundary validation. +- [x] 4.5 Introduce safe mode and recovery strategy. +- [x] 4.6 Extend diagnostics snapshot (failure-reason counts, latest failure metadata, safe-mode status). + +## 5. Verification + +- [x] 5.1 Run `npm run type-check`. +- [x] 5.2 Run targeted unit tests: + - `npm run test:unit src/tests/unit/account.test.ts` + - `npm run test:unit src/tests/unit/cloudHandler-sync.test.ts` +- [ ] 5.3 Add/extend rollback invariant unit tests. +- [ ] 5.4 Add/extend abnormal history-data behavior unit tests. +- [ ] 5.5 Add/extend unit tests for platform-constrained generation rules. +- [ ] 5.6 Complete manual acceptance matrix across Windows/macOS/Linux. + +## 6. Delivery Documentation + +- [ ] 6.1 Produce a unified acceptance checklist and mark status item by item. +- [ ] 6.2 Archive merged proposals after release. diff --git a/src/App.tsx b/src/App.tsx index 9eff178..a1478f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { router } from './utils/routes'; import './localization/i18n'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/toaster'; import { LOCAL_STORAGE_KEYS } from '@/constants'; function App() { @@ -32,6 +33,7 @@ root.render( + , diff --git a/src/actions/account.ts b/src/actions/account.ts index d72a63a..1e1e204 100644 --- a/src/actions/account.ts +++ b/src/actions/account.ts @@ -1,4 +1,5 @@ import { ipc } from '@/ipc/manager'; +import type { DeviceProfile } from '@/types/account'; export function listAccounts() { return ipc.client.account.listAccounts(); @@ -15,3 +16,39 @@ export function switchAccount(accountId: string) { export function deleteAccount(accountId: string) { return ipc.client.account.deleteAccount({ accountId }); } + +export function previewGenerateIdentityProfile() { + return ipc.client.account.previewGenerateIdentityProfile(); +} + +export function getIdentityProfiles(accountId: string) { + return ipc.client.account.getIdentityProfiles({ accountId }); +} + +export function bindIdentityProfile(accountId: string, mode: 'capture' | 'generate') { + return ipc.client.account.bindIdentityProfile({ accountId, mode }); +} + +export function bindIdentityProfileWithPayload(accountId: string, profile: DeviceProfile) { + return ipc.client.account.bindIdentityProfileWithPayload({ accountId, profile }); +} + +export function applyBoundIdentityProfile(accountId: string) { + return ipc.client.account.applyBoundIdentityProfile({ accountId }); +} + +export function restoreIdentityProfileRevision(accountId: string, versionId: string) { + return ipc.client.account.restoreIdentityProfileRevision({ accountId, versionId }); +} + +export function deleteIdentityProfileRevision(accountId: string, versionId: string) { + return ipc.client.account.deleteIdentityProfileRevision({ accountId, versionId }); +} + +export function restoreBaselineProfile(accountId: string) { + return ipc.client.account.restoreBaselineProfile({ accountId }); +} + +export function openIdentityStorageFolder() { + return ipc.client.account.openIdentityStorageFolder(); +} diff --git a/src/actions/cloud.ts b/src/actions/cloud.ts index 24d8429..762a815 100644 --- a/src/actions/cloud.ts +++ b/src/actions/cloud.ts @@ -1,4 +1,5 @@ import { ipc } from '@/ipc/manager'; +import type { DeviceProfile } from '@/types/account'; export function addGoogleAccount(input: { authCode: string }) { return ipc.client.cloud.addGoogleAccount(input); @@ -39,3 +40,42 @@ export function syncLocalAccount() { export function startAuthFlow() { return ipc.client.cloud.startAuthFlow(); } + +export function getSwitchStatus() { + return ipc.client.cloud.getSwitchStatus(); +} + +export function getCloudIdentityProfiles(input: { accountId: string }) { + return ipc.client.cloud.getIdentityProfiles(input); +} + +export function previewGenerateCloudIdentityProfile() { + return ipc.client.cloud.previewIdentityProfile(); +} + +export function bindCloudIdentityProfile(input: { accountId: string; mode: 'capture' | 'generate' }) { + return ipc.client.cloud.bindIdentityProfile(input); +} + +export function bindCloudIdentityProfileWithPayload(input: { + accountId: string; + profile: DeviceProfile; +}) { + return ipc.client.cloud.bindIdentityProfileWithPayload(input); +} + +export function restoreCloudIdentityProfileRevision(input: { accountId: string; versionId: string }) { + return ipc.client.cloud.restoreIdentityProfileRevision(input); +} + +export function restoreCloudBaselineProfile(input: { accountId: string }) { + return ipc.client.cloud.restoreBaselineProfile(input); +} + +export function deleteCloudIdentityProfileRevision(input: { accountId: string; versionId: string }) { + return ipc.client.cloud.deleteIdentityProfileRevision(input); +} + +export function openCloudIdentityStorageFolder() { + return ipc.client.cloud.openIdentityStorageFolder(); +} diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index 4473e22..edbf7d4 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Checkbox } from '@/components/ui/checkbox'; -import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react'; +import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react'; import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; @@ -28,6 +28,7 @@ interface CloudAccountCardProps { onRefresh: (id: string) => void; onDelete: (id: string) => void; onSwitch: (id: string) => void; + onManageIdentity: (id: string) => void; isSelected?: boolean; onToggleSelection?: (id: string, selected: boolean) => void; isRefreshing?: boolean; @@ -40,6 +41,7 @@ export function CloudAccountCard({ onRefresh, onDelete, onSwitch, + onManageIdentity, isSelected = false, onToggleSelection, isRefreshing, @@ -59,6 +61,16 @@ export function CloudAccountCard({ return 'text-red-500'; }; + const getQuotaBarColor = (percentage: number) => { + if (percentage > 80) { + return 'bg-emerald-500'; + } + if (percentage > 20) { + return 'bg-amber-500'; + } + return 'bg-rose-500'; + }; + const formatTimeRemaining = (dateStr: string) => { const targetDate = new Date(dateStr); if (Number.isNaN(targetDate.getTime())) { @@ -112,13 +124,13 @@ export function CloudAccountCard({ return ( - + {/* Selection Checkbox - Visible on hover or selected */} {onToggleSelection && (
) : ( -
+
{account.name?.[0]?.toUpperCase() || 'A'}
)} @@ -147,7 +159,7 @@ export function CloudAccountCard({
- @@ -164,6 +176,11 @@ export function CloudAccountCard({ {t('cloud.card.refresh')} + onManageIdentity(account.id)}> + + {t('cloud.card.identityProfile')} + + onDelete(account.id)} className="text-destructive focus:text-destructive" @@ -208,6 +225,7 @@ export function CloudAccountCard({ size="sm" onClick={() => onSwitch(account.id)} disabled={isSwitching} + className="cursor-pointer" > {isSwitching ? ( @@ -224,7 +242,7 @@ export function CloudAccountCard({ modelQuotas.map(([modelName, info]) => (
{modelName.replace('models/', '')} @@ -236,7 +254,7 @@ export function CloudAccountCard({ > {getResetTimeLabel(info.resetTime)} -
+
@@ -246,6 +264,12 @@ export function CloudAccountCard({ {t('cloud.card.left')}
+
+
+
)) @@ -258,7 +282,7 @@ export function CloudAccountCard({
- + {t('cloud.card.used')}{' '} {formatDistanceToNow(account.last_used * 1000, { addSuffix: true })} diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index c9c26c6..46e1389 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -11,6 +11,7 @@ import { startAuthFlow, } from '@/hooks/useCloudAccounts'; import { CloudAccountCard } from '@/components/CloudAccountCard'; +import { IdentityProfileDialog } from '@/components/IdentityProfileDialog'; import { CloudAccount } from '@/types/cloudAccount'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; @@ -62,6 +63,11 @@ export function CloudAccountList() { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [authCode, setAuthCode] = useState(''); + const [identityAccount, setIdentityAccount] = useState(null); + const totalAccounts = accounts?.length || 0; + const activeAccounts = accounts?.filter((account) => account.is_active).length || 0; + const rateLimitedAccounts = + accounts?.filter((account) => account.status === 'rate_limited').length || 0; const handleAddAccount = (codeVal?: string) => { const codeToUse = codeVal || authCode; @@ -161,6 +167,11 @@ export function CloudAccountList() { } }; + const handleManageIdentity = (id: string) => { + const target = (accounts || []).find((item) => item.id === id) || null; + setIdentityAccount(target); + }; + const handleToggleAutoSwitch = (checked: boolean) => { setAutoSwitchMutation.mutate( { enabled: checked }, @@ -290,110 +301,128 @@ export function CloudAccountList() { } return ( -
-
-
-

{t('cloud.title')}

-

{t('cloud.description')}

-
- -
-
-
- - +
+
+
+
+

{t('cloud.title')}

+

{t('cloud.description')}

+
+
+
+
{t('cloud.card.actions')}
+
{totalAccounts}
- +
+
{t('cloud.card.active')}
+
{activeAccounts}
+
+
+
{t('cloud.card.rateLimited')}
+
{rateLimitedAccounts}
+
- - - - - - - - - - - - - - {t('cloud.authDialog.title')} - {t('cloud.authDialog.description')} - -
-
- -
-
- - ) => - setAuthCode(e.target.value) - } - /> -

- {t('cloud.authDialog.instruction')} -

-
-
- - - -
-
+
+
+
+ + +
+ +
+ + + + + + + + + + + + + + {t('cloud.authDialog.title')} + {t('cloud.authDialog.description')} + +
+
+ +
+
+ + ) => + setAuthCode(e.target.value) + } + /> +

+ {t('cloud.authDialog.instruction')} +

+
+
+ + + +
+
+
+
{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 ( + + + +
+
+
+ +
+
+ {t('cloud.identity.title')} + {account.email} +
+
+ + + {history.length} + +
+
+ +
+
+ + + + + + + +
+ + {previewProfile ? ( + + + {t('cloud.identity.previewTitle')} + + + {renderProfile(previewProfile)} +
+ + +
+
+
+ ) : null} + +
+
+
+ + + + + {t('cloud.identity.currentStorage')} + + + + {showLoadingPlaceholder ? ( +
+ {t('cloud.identity.loading')} +
+ ) : ( + renderProfile(snapshot?.currentStorage) + )} +
+
+ + + + + + {t('cloud.identity.accountBinding')} + + + + {showLoadingPlaceholder ? ( +
+ {t('cloud.identity.loading')} +
+ ) : ( + renderProfile(snapshot?.boundProfile) + )} +
+
+
+ + + + {t('cloud.identity.history')} + + + {showLoadingPlaceholder ? ( +
+ {t('cloud.identity.loading')} +
+ ) : null} + + {!showLoadingPlaceholder && history.length === 0 ? ( +
+ {t('cloud.identity.noHistory')} +
+ ) : null} + + {!showLoadingPlaceholder + ? history.map((version) => ( +
+
+
+
{version.label}
+
+ {new Date(version.createdAt * 1000).toLocaleString()} +
+
+ {version.isCurrent ? ( + + {t('cloud.identity.current')} + + ) : null} +
+ + {renderProfile(version.profile)} + +
+ + {!version.isCurrent ? ( + + ) : null} +
+
+ )) + : null} +
+
+
+ + + + + {t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })} + + + + {showLoadingPlaceholder ? ( +
{t('cloud.identity.loading')}
+ ) : ( + renderProfile(snapshot?.baseline) + )} +
+
+
+
+ + + + +
+
+ ); +} 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();