feat: complete account-bound profile switching and hardening (#78)
This commit is contained in:
31 files changed
+3612
-197
No files matched your search
@@ -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)`.
|
||||
@@ -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?
|
||||
@@ -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`
|
||||
+109
@@ -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
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all ${isSelected ? 'ring-primary border-primary/50 ring-2' : 'hover:border-primary/50'}`}
|
||||
className={`group flex h-full flex-col overflow-hidden border bg-card transition-all duration-200 hover:border-primary/40 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
>
|
||||
<CardHeader className="group relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
{/* Selection Checkbox - Visible on hover or selected */}
|
||||
{onToggleSelection && (
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} from-background rounded-full bg-gradient-to-br to-transparent p-2 transition-opacity`}
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} rounded-full bg-background/90 p-2 transition-opacity`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -135,7 +147,7 @@ export function CloudAccountCard({
|
||||
className="bg-muted h-10 w-10 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full border">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
@@ -147,7 +159,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
@@ -164,6 +176,11 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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 ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
@@ -224,7 +242,7 @@ export function CloudAccountCard({
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
|
||||
className="hover:bg-muted/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-2 text-sm transition-colors hover:border-border/60"
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
@@ -236,7 +254,7 @@ export function CloudAccountCard({
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
>
|
||||
@@ -246,6 +264,12 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.left')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -258,7 +282,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/50 text-muted-foreground justify-between p-2 px-4 text-xs">
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-between border-t p-2 px-4 text-xs">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
|
||||
+143
-102
@@ -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<CloudAccount | null>(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 (
|
||||
<div className="relative space-y-6 pb-20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground">{t('cloud.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-muted/20 flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
<div className="space-y-5 pb-20">
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground max-w-2xl">{t('cloud.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.actions')}</div>
|
||||
<div className="text-base font-semibold">{totalAccounts}</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-switch"
|
||||
checked={!!autoSwitchEnabled}
|
||||
onCheckedChange={handleToggleAutoSwitch}
|
||||
disabled={isSettingsLoading || setAutoSwitchMutation.isPending}
|
||||
/>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.active')}</div>
|
||||
<div className="text-base font-semibold text-emerald-600">{activeAccounts}</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.rateLimited')}</div>
|
||||
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={toggleSelectAll} title={t('cloud.batch.selectAll')}>
|
||||
<CheckSquare
|
||||
className={`mr-2 h-4 w-4 ${selectedIds.size > 0 && selectedIds.size === accounts?.length ? 'text-primary fill-primary/20' : ''}`}
|
||||
/>
|
||||
{t('cloud.batch.selectAll')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleForcePoll}
|
||||
title={t('cloud.checkQuota')}
|
||||
disabled={forcePollMutation.isPending}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${forcePollMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSyncLocal}
|
||||
disabled={syncMutation.isPending}
|
||||
title={t('cloud.syncFromIDE')}
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('cloud.authDialog.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.authDialog.verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-card p-3">
|
||||
<div className="bg-muted/50 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-switch"
|
||||
checked={!!autoSwitchEnabled}
|
||||
onCheckedChange={handleToggleAutoSwitch}
|
||||
disabled={isSettingsLoading || setAutoSwitchMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={toggleSelectAll} title={t('cloud.batch.selectAll')} className="cursor-pointer">
|
||||
<CheckSquare
|
||||
className={`mr-2 h-4 w-4 ${selectedIds.size > 0 && selectedIds.size === accounts?.length ? 'text-primary fill-primary/20' : ''}`}
|
||||
/>
|
||||
{t('cloud.batch.selectAll')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleForcePoll}
|
||||
title={t('cloud.checkQuota')}
|
||||
disabled={forcePollMutation.isPending}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${forcePollMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSyncLocal}
|
||||
disabled={syncMutation.isPending}
|
||||
title={t('cloud.syncFromIDE')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('cloud.authDialog.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.authDialog.verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
@@ -402,6 +431,7 @@ export function CloudAccountList() {
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={toggleSelection}
|
||||
isRefreshing={
|
||||
@@ -417,15 +447,16 @@ export function CloudAccountList() {
|
||||
))}
|
||||
|
||||
{accounts?.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/10 col-span-full rounded-lg border-2 border-dashed py-12 text-center">
|
||||
{t('cloud.list.noAccounts')}
|
||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bg-background animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-xl">
|
||||
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-lg">
|
||||
<div className="flex items-center gap-2 border-r pr-4">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('cloud.batch.selected', { count: selectedIds.size })}
|
||||
@@ -452,6 +483,16 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IdentityProfileDialog
|
||||
account={identityAccount}
|
||||
open={Boolean(identityAccount)}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setIdentityAccount(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
||||
N/A
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-start justify-between gap-3">
|
||||
<span className="text-muted-foreground text-[11px] tracking-wide uppercase">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="max-w-[70%] text-right font-mono text-xs break-all">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<DeviceProfilesSnapshot | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [actionKey, setActionKey] = useState<string | null>(null);
|
||||
const [previewProfile, setPreviewProfile] = useState<DeviceProfile | null>(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<void>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="from-primary/10 via-background to-background border-b bg-gradient-to-r p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="bg-primary/15 text-primary rounded-xl border p-2.5">
|
||||
<Fingerprint className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{t('cloud.identity.title')}</DialogTitle>
|
||||
<DialogDescription className="truncate">{account.email}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<History className="mr-1.5 h-3.5 w-3.5" />
|
||||
{history.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6" aria-busy={refreshing}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'preview-generate'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('preview-generate', async () => {
|
||||
const profile = await previewGenerateCloudIdentityProfile();
|
||||
setPreviewProfile(profile);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.generateAndBind')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'capture-bind'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('capture-bind', async () => {
|
||||
await bindCloudIdentityProfile({ accountId: account.id, mode: 'capture' });
|
||||
setPreviewProfile(null);
|
||||
toast({ title: t('cloud.identity.captureSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.captureAndBind')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'restore-original'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('restore-original', async () => {
|
||||
await restoreCloudBaselineProfile({ accountId: account.id });
|
||||
toast({ title: t('cloud.identity.restoreOriginalSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.restoreOriginal')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'open-folder'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('open-folder', async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
toast({ title: t('cloud.identity.openFolderSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{previewProfile ? (
|
||||
<Card className="border-primary/20 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{renderProfile(previewProfile)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={actionKey === 'confirm-generate'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction('confirm-generate', async () => {
|
||||
await bindCloudIdentityProfileWithPayload({
|
||||
accountId: account.id,
|
||||
profile: previewProfile,
|
||||
});
|
||||
setPreviewProfile(null);
|
||||
toast({ title: t('cloud.identity.generateSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('cloud.identity.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setPreviewProfile(null)}
|
||||
>
|
||||
{t('cloud.identity.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Cpu className="text-muted-foreground h-4 w-4" />
|
||||
{t('cloud.identity.currentStorage')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.currentStorage)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="text-muted-foreground h-4 w-4" />
|
||||
{t('cloud.identity.accountBinding')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.boundProfile)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t('cloud.identity.history')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[38vh] space-y-3 overflow-y-auto pr-1">
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showLoadingPlaceholder && history.length === 0 ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.noHistory')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showLoadingPlaceholder
|
||||
? history.map((version) => (
|
||||
<div key={version.id} className="bg-muted/20 rounded-xl border p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{version.label}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(version.createdAt * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{version.isCurrent ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t('cloud.identity.current')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{renderProfile(version.profile)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={version.isCurrent || actionKey === `restore-${version.id}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction(`restore-${version.id}`, async () => {
|
||||
await restoreCloudIdentityProfileRevision({
|
||||
accountId: account.id,
|
||||
versionId: version.id,
|
||||
});
|
||||
toast({ title: t('cloud.identity.restoreVersionSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('cloud.identity.restore')}
|
||||
</Button>
|
||||
{!version.isCurrent ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionKey === `delete-${version.id}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction(`delete-${version.id}`, async () => {
|
||||
await deleteCloudIdentityProfileRevision({
|
||||
accountId: account.id,
|
||||
versionId: version.id,
|
||||
});
|
||||
toast({ title: t('cloud.identity.deleteVersionSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">
|
||||
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.baseline)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
|
||||
{t('cloud.identity.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title ? <ToastTitle>{title}</ToastTitle> : null}
|
||||
{description ? <ToastDescription>{description}</ToastDescription> : null}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
+265
-23
@@ -3,15 +3,66 @@ import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Account, AccountBackupData } from '../../types/account';
|
||||
import {
|
||||
Account,
|
||||
AccountBackupData,
|
||||
DeviceProfile,
|
||||
DeviceProfilesSnapshot,
|
||||
DeviceProfileVersion,
|
||||
} from '../../types/account';
|
||||
import {
|
||||
backupAccount as dbBackup,
|
||||
restoreAccount as dbRestore,
|
||||
getCurrentAccountInfo,
|
||||
} from '../database/handler';
|
||||
import { closeAntigravity, startAntigravity } from '../process/handler';
|
||||
import {
|
||||
applyDeviceProfile,
|
||||
ensureGlobalOriginalFromCurrentStorage,
|
||||
generateDeviceProfile,
|
||||
isIdentityProfileApplyEnabled,
|
||||
loadGlobalOriginalProfile,
|
||||
readCurrentDeviceProfile,
|
||||
saveGlobalOriginalProfile,
|
||||
getStorageDirectoryPath,
|
||||
} from '../device/handler';
|
||||
import { runWithSwitchGuard } from '../switchGuard';
|
||||
import { executeSwitchFlow } from '../switchFlow';
|
||||
import { shell } from 'electron';
|
||||
|
||||
type AccountIndex = Record<string, Account>;
|
||||
const SWITCH_EXIT_TIMEOUT_MS = 10000;
|
||||
|
||||
function getDeviceHistory(account: Account): DeviceProfileVersion[] {
|
||||
if (!account.deviceHistory) {
|
||||
account.deviceHistory = [];
|
||||
}
|
||||
return account.deviceHistory;
|
||||
}
|
||||
|
||||
function bindDeviceProfileToAccount(
|
||||
account: Account,
|
||||
profile: DeviceProfile,
|
||||
label: string,
|
||||
addHistory: boolean,
|
||||
): void {
|
||||
account.deviceProfile = profile;
|
||||
if (!addHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = getDeviceHistory(account);
|
||||
for (const version of history) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
|
||||
history.push({
|
||||
id: uuidv4(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
label,
|
||||
profile,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the accounts index from the file system.
|
||||
@@ -141,6 +192,7 @@ export async function addAccountSnapshot(): Promise<Account> {
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
deviceHistory: [],
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
@@ -171,7 +223,184 @@ export async function addAccountSnapshot(): Promise<Account> {
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
await runWithSwitchGuard('local-account-switch', async () => {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
if (!account.deviceProfile) {
|
||||
const generated = generateDeviceProfile();
|
||||
saveGlobalOriginalProfile(generated);
|
||||
bindDeviceProfileToAccount(account, generated, 'auto_generated', true);
|
||||
}
|
||||
|
||||
await executeSwitchFlow({
|
||||
scope: 'local',
|
||||
targetProfile: account.deviceProfile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS,
|
||||
performSwitch: async () => {
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewGenerateIdentityProfile(): Promise<DeviceProfile> {
|
||||
return generateDeviceProfile();
|
||||
}
|
||||
|
||||
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let currentStorage: DeviceProfile | undefined;
|
||||
try {
|
||||
currentStorage = readCurrentDeviceProfile();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read current storage device profile', error);
|
||||
}
|
||||
|
||||
return {
|
||||
currentStorage,
|
||||
boundProfile: account.deviceProfile,
|
||||
history: account.deviceHistory || [],
|
||||
baseline: loadGlobalOriginalProfile() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bindIdentityProfile(
|
||||
accountId: string,
|
||||
mode: 'capture' | 'generate',
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let profile: DeviceProfile;
|
||||
if (mode === 'capture') {
|
||||
profile = readCurrentDeviceProfile();
|
||||
} else {
|
||||
profile = generateDeviceProfile();
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, mode, true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function bindIdentityProfileWithPayload(
|
||||
accountId: string,
|
||||
profile: DeviceProfile,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, 'generated', true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function applyBoundIdentityProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
if (!account.deviceProfile) {
|
||||
throw new Error('Account has no bound device profile');
|
||||
}
|
||||
|
||||
applyDeviceProfile(account.deviceProfile);
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
return account.deviceProfile;
|
||||
}
|
||||
|
||||
export async function restoreIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let targetProfile: DeviceProfile | null = null;
|
||||
if (versionId === 'baseline') {
|
||||
targetProfile = loadGlobalOriginalProfile();
|
||||
if (!targetProfile) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
} else if (versionId === 'current') {
|
||||
targetProfile = account.deviceProfile || null;
|
||||
if (!targetProfile) {
|
||||
throw new Error('No currently bound profile');
|
||||
}
|
||||
} else {
|
||||
const history = getDeviceHistory(account);
|
||||
const targetVersion = history.find((version) => version.id === versionId);
|
||||
if (!targetVersion) {
|
||||
throw new Error('Device profile version not found');
|
||||
}
|
||||
targetProfile = targetVersion.profile;
|
||||
for (const version of history) {
|
||||
version.isCurrent = version.id === versionId;
|
||||
}
|
||||
}
|
||||
|
||||
applyDeviceProfile(targetProfile);
|
||||
account.deviceProfile = targetProfile;
|
||||
saveAccountsIndex(accounts);
|
||||
return targetProfile;
|
||||
}
|
||||
|
||||
export async function deleteIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<void> {
|
||||
if (versionId === 'baseline') {
|
||||
throw new Error('Original profile cannot be deleted');
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
@@ -179,34 +408,47 @@ export async function switchAccount(accountId: string): Promise<void> {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
const history = getDeviceHistory(account);
|
||||
if (history.some((version) => version.id === versionId && version.isCurrent)) {
|
||||
throw new Error('Currently bound profile cannot be deleted');
|
||||
}
|
||||
|
||||
// NOTE Close Antigravity (with error handling)
|
||||
try {
|
||||
await closeAntigravity();
|
||||
} catch (error) {
|
||||
// NOTE Continue even if close fails
|
||||
logger.warn('Unable to close Antigravity, attempting forced restore...', error);
|
||||
const before = history.length;
|
||||
account.deviceHistory = history.filter((version) => version.id !== versionId);
|
||||
if (account.deviceHistory.length === before) {
|
||||
throw new Error('Historical device profile not found');
|
||||
}
|
||||
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
export async function restoreBaselineProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
const baseline = loadGlobalOriginalProfile();
|
||||
if (!baseline) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
|
||||
account.deviceProfile = baseline;
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
// NOTE Start Antigravity
|
||||
await startAntigravity();
|
||||
return baseline;
|
||||
}
|
||||
|
||||
export async function openIdentityStorageFolder(): Promise<void> {
|
||||
const directory = getStorageDirectoryPath();
|
||||
const result = await shell.openPath(directory);
|
||||
if (result) {
|
||||
throw new Error(`Failed to open device folder: ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
|
||||
import { AccountSchema } from '../../types/account';
|
||||
import {
|
||||
applyBoundIdentityProfile,
|
||||
addAccountSnapshot,
|
||||
bindIdentityProfile,
|
||||
bindIdentityProfileWithPayload,
|
||||
deleteAccount,
|
||||
deleteIdentityProfileRevision,
|
||||
getIdentityProfiles,
|
||||
listAccountsData,
|
||||
previewGenerateIdentityProfile,
|
||||
restoreBaselineProfile,
|
||||
restoreIdentityProfileRevision,
|
||||
switchAccount,
|
||||
openIdentityStorageFolder,
|
||||
} from './handler';
|
||||
import {
|
||||
AccountSchema,
|
||||
DeviceProfileSchema,
|
||||
DeviceProfilesSnapshotSchema,
|
||||
} from '../../types/account';
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
@@ -25,4 +43,61 @@ export const accountRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
await deleteAccount(input.accountId);
|
||||
}),
|
||||
|
||||
previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
|
||||
return previewGenerateIdentityProfile();
|
||||
}),
|
||||
|
||||
getIdentityProfiles: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfilesSnapshotSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getIdentityProfiles(input.accountId);
|
||||
}),
|
||||
|
||||
bindIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfile(input.accountId, input.mode);
|
||||
}),
|
||||
|
||||
bindIdentityProfileWithPayload: os
|
||||
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfileWithPayload(input.accountId, input.profile);
|
||||
}),
|
||||
|
||||
applyBoundIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return applyBoundIdentityProfile(input.accountId);
|
||||
}),
|
||||
|
||||
restoreIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
deleteIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
restoreBaselineProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreBaselineProfile(input.accountId);
|
||||
}),
|
||||
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openIdentityStorageFolder();
|
||||
}),
|
||||
});
|
||||
+173
-60
@@ -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<CloudAccou
|
||||
}
|
||||
|
||||
export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
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<DeviceProfilesSnapshot> {
|
||||
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<DeviceProfile> {
|
||||
return generateDeviceProfile();
|
||||
}
|
||||
|
||||
export async function bindCloudIdentityProfile(
|
||||
accountId: string,
|
||||
mode: 'capture' | 'generate',
|
||||
): Promise<DeviceProfile> {
|
||||
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<DeviceProfile> {
|
||||
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<DeviceProfile> {
|
||||
const baseline = loadGlobalOriginalProfile();
|
||||
return CloudAccountRepo.restoreDeviceVersion(accountId, versionId, baseline);
|
||||
}
|
||||
|
||||
export async function restoreCloudBaselineProfile(accountId: string): Promise<DeviceProfile> {
|
||||
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<void> {
|
||||
CloudAccountRepo.deleteDeviceVersion(accountId, versionId);
|
||||
}
|
||||
|
||||
export async function openCloudIdentityStorageFolder(): Promise<void> {
|
||||
const directory = getStorageDirectoryPath();
|
||||
const result = await shell.openPath(directory);
|
||||
if (result) {
|
||||
throw new Error(`Failed to open identity storage: ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
@@ -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<typeof drizzleSchema>,
|
||||
@@ -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<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStringCandidate(
|
||||
source: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const candidate = source[key];
|
||||
if (typeof candidate === 'string' && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
|
||||
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
|
||||
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
|
||||
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
|
||||
|
||||
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
machineId,
|
||||
macMachineId,
|
||||
devDeviceId,
|
||||
sqmId,
|
||||
};
|
||||
}
|
||||
|
||||
function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): boolean {
|
||||
return (
|
||||
left.machineId === right.machineId &&
|
||||
left.macMachineId === right.macMachineId &&
|
||||
left.devDeviceId === right.devDeviceId &&
|
||||
left.sqmId === right.sqmId
|
||||
);
|
||||
}
|
||||
|
||||
function readVersionedProfilePayload(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!('schemaVersion' in value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const schemaVersion = value.schemaVersion;
|
||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
||||
throw new Error('invalid_device_profile_schema_version');
|
||||
}
|
||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
|
||||
}
|
||||
if (!('profile' in value)) {
|
||||
throw new Error('invalid_device_profile_payload');
|
||||
}
|
||||
return value.profile;
|
||||
}
|
||||
|
||||
function readVersionedHistoryPayload(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!('schemaVersion' in value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const schemaVersion = value.schemaVersion;
|
||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
||||
throw new Error('invalid_device_history_schema_version');
|
||||
}
|
||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
|
||||
}
|
||||
if (!('history' in value)) {
|
||||
throw new Error('invalid_device_history_payload');
|
||||
}
|
||||
return value.history;
|
||||
}
|
||||
|
||||
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
schemaVersion: DEVICE_PAYLOAD_SCHEMA_VERSION,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
function serializeDeviceHistory(history: DeviceProfileVersion[] | undefined): string | null {
|
||||
if (!history || history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
schemaVersion: DEVICE_PAYLOAD_SCHEMA_VERSION,
|
||||
history,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: DeviceProfileVersion[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = normalizeDeviceProfile(item.profile);
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
|
||||
const createdAtCandidate = item.createdAt;
|
||||
const createdAt =
|
||||
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
|
||||
? Math.floor(createdAtCandidate)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
|
||||
const isCurrent = item.isCurrent === true;
|
||||
|
||||
normalized.push({
|
||||
id,
|
||||
createdAt,
|
||||
label,
|
||||
profile,
|
||||
isCurrent,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseDeviceProfileColumn(value: string | null | undefined): DeviceProfile | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error('invalid_device_profile_json');
|
||||
}
|
||||
const normalized = normalizeDeviceProfile(readVersionedProfilePayload(parsed));
|
||||
if (!normalized) {
|
||||
throw new Error('invalid_device_profile_json');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseDeviceHistoryColumn(
|
||||
value: string | null | undefined,
|
||||
): DeviceProfileVersion[] | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error('invalid_device_history_json');
|
||||
}
|
||||
const payload = readVersionedHistoryPayload(parsed);
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('invalid_device_history_json');
|
||||
}
|
||||
const normalized = normalizeDeviceHistory(payload);
|
||||
if (!normalized) {
|
||||
throw new Error('invalid_device_history_json');
|
||||
}
|
||||
if (normalized.length !== payload.length) {
|
||||
throw new Error('invalid_device_history_entry');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createMigrationStats(): MigrationStats {
|
||||
return {
|
||||
totalFields: 0,
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string, DeviceProfileTemplate[]> = {
|
||||
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<LastKnownGoodMarker>;
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function getTelemetryField(storage: Record<string, unknown>, key: string): string | null {
|
||||
const telemetry = storage.telemetry;
|
||||
if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
|
||||
const nestedValue = (telemetry as Record<string, unknown>)[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<string, unknown>): Record<string, unknown> {
|
||||
if (!storage.telemetry || typeof storage.telemetry !== 'object' || Array.isArray(storage.telemetry)) {
|
||||
storage.telemetry = {};
|
||||
}
|
||||
return storage.telemetry as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
|
||||
|
||||
interface SwitchTask {
|
||||
owner: SwitchOwner;
|
||||
action: () => Promise<unknown>;
|
||||
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<T>(
|
||||
owner: SwitchOwner,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await new Promise<T>((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,
|
||||
};
|
||||
}
|
||||
@@ -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<SwitchFailureReason, number>;
|
||||
lastFailure: SwitchFailureMetadata | null;
|
||||
}
|
||||
|
||||
type SwitchMetricMap = Record<SwitchScope, SwitchMetricBucket>;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: 'Нет добавленных облачных аккаунтов.',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
},
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user