Compare commits

...
5 Commits
Author SHA1 Message Date
semantic-release-bot 51a93c5c88 chore(release): 0.8.0 [skip ci]
## [0.8.0](https://github.com/Draculabo/AntigravityManager/compare/v0.7.0...v0.8.0) (2026-02-07)

### ✨ Features

* complete account-bound profile switching and hardening ([#78](https://github.com/Draculabo/AntigravityManager/issues/78)) ([a93c6d0](https://github.com/Draculabo/AntigravityManager/commit/a93c6d0cea5b9904a30234faffe767505f753373))

### 🐛 Bug Fixes

* **ci:** increase Node heap for publish step to prevent macOS OOM ([#76](https://github.com/Draculabo/AntigravityManager/issues/76)) ([ee64179](https://github.com/Draculabo/AntigravityManager/commit/ee641799c27425ceb6d2d6d00a132881bedb1f04))
* **ci:** make WiX Toolset setup resilient on Windows runners ([#73](https://github.com/Draculabo/AntigravityManager/issues/73)) ([5fe434f](https://github.com/Draculabo/AntigravityManager/commit/5fe434f7050cb8975d65502baeb03b1747e00611))

### 📝 Documentation

* **openspec:** backfill missing proposals ([287e848](https://github.com/Draculabo/AntigravityManager/commit/287e848b3291d2ee43b173802b87491c2a90930d))
2026-02-07 17:13:38 +00:00
Draculabo 287e848b32 docs(openspec): backfill missing proposals 2026-02-08 01:12:17 +08:00
vin a93c6d0cea feat: complete account-bound profile switching and hardening (#78) 2026-02-08 01:10:07 +08:00
vin ee641799c2 fix(ci): increase Node heap for publish step to prevent macOS OOM (#76) 2026-02-06 23:57:16 +08:00
vin 5fe434f705 fix(ci): make WiX Toolset setup resilient on Windows runners (#73) 2026-02-06 23:13:48 +08:00
51 changed files with 4094 additions and 202 deletions

No files matched your search

+13 -2
View File
@@ -54,15 +54,26 @@ jobs:
sudo apt-get update
sudo apt-get install -y fakeroot rpm libsecret-1-dev pkg-config
- name: Install WiX Toolset
- name: Ensure WiX Toolset
if: matrix.platform == 'win32' && matrix.arch == 'x64'
run: choco install wixtoolset --version=3.14.0 -y
shell: powershell
run: |
$installed = choco list --local-only wixtoolset --exact --limit-output
if (-not $installed) {
choco install wixtoolset --version=3.14.0 -y
} elseif ($installed -match '^wixtoolset\|3\.14(\.|$)') {
Write-Host "WiX Toolset already compatible: $installed"
} else {
Write-Host "Incompatible WiX Toolset detected: $installed"
choco upgrade wixtoolset --version=3.14.0 --allow-downgrade -y
}
- name: Install dependencies
run: npm ci
- name: Publish Release
env:
NODE_OPTIONS: --max-old-space-size=8192
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+15
View File
@@ -2,6 +2,21 @@
# Changelog
## [0.8.0](https://github.com/Draculabo/AntigravityManager/compare/v0.7.0...v0.8.0) (2026-02-07)
### ✨ Features
* complete account-bound profile switching and hardening ([#78](https://github.com/Draculabo/AntigravityManager/issues/78)) ([a93c6d0](https://github.com/Draculabo/AntigravityManager/commit/a93c6d0cea5b9904a30234faffe767505f753373))
### 🐛 Bug Fixes
* **ci:** increase Node heap for publish step to prevent macOS OOM ([#76](https://github.com/Draculabo/AntigravityManager/issues/76)) ([ee64179](https://github.com/Draculabo/AntigravityManager/commit/ee641799c27425ceb6d2d6d00a132881bedb1f04))
* **ci:** make WiX Toolset setup resilient on Windows runners ([#73](https://github.com/Draculabo/AntigravityManager/issues/73)) ([5fe434f](https://github.com/Draculabo/AntigravityManager/commit/5fe434f7050cb8975d65502baeb03b1747e00611))
### 📝 Documentation
* **openspec:** backfill missing proposals ([287e848](https://github.com/Draculabo/AntigravityManager/commit/287e848b3291d2ee43b173802b87491c2a90930d))
## [0.7.0](https://github.com/Draculabo/AntigravityManager/compare/v0.6.0...v0.7.0) (2026-02-06)
### ✨ Features
@@ -0,0 +1,36 @@
## Context
The existing Electron Forge packaging pipeline needs Linux arm64, macOS universal, and Windows MSI outputs, plus consistent artifact naming and SHA-256 checksum outputs for cross-platform release parity.
## Goals / Non-Goals
- Goals:
- Cover Linux arm64 artifacts (`deb`/`rpm`/`AppImage`)
- Provide macOS `x64`/`arm64`/`universal` dmg artifacts
- Add MSI artifacts for Windows
- Include architecture markers in artifact names and output SHA-256 checksums
- Non-Goals:
- Change existing update logic (`latest*.yml` remains unchanged)
- Modify runtime application behavior
## Decisions
- Decision: Build Linux arm64 on a dedicated arm64 runner, without cross-compilation.
- Decision: Build macOS universal via `--arch=universal` or by merging app bundles before dmg packaging.
- Decision: Generate Windows MSI with the official Forge maker (Wix/MSI) in parallel with Squirrel.
- Decision: Use `productName + version + arch` naming; Linux uses `amd64/aarch64`, macOS/Windows use `x64/arm64/universal`.
## Alternatives Considered
- x64-only artifacts: rejected because it does not meet user requirements.
- universal-only dmg: rejected due to weaker debugging/rollback capability and lack of architecture distinction.
## Risks / Trade-offs
- Linux arm64 builds may be affected by native dependency compilation (for example, `better-sqlite3`, `keytar`).
- Windows MSI requires extra tooling (Wix), which may increase CI duration and failure rate.
- The universal dmg pipeline requires validation for signing and update-flow consistency.
## Migration Plan
1) Introduce CI multi-arch jobs with naming and checksum outputs.
2) Add MSI maker and universal dmg packaging.
3) Stabilize the workflow after release validation.
## Open Questions
- Should artifact naming strictly match sample format (underscore vs hyphen)?
- Should checksum output be separate `.sha256` files or a single consolidated manifest?
@@ -0,0 +1,20 @@
# Change: Add Multi-Architecture and Multi-Format Release Artifacts
## Why
Current release artifacts only cover part of the required architectures and formats. This does not meet demand for Linux arm64, macOS universal, and Windows MSI, and it lacks consistent naming and SHA-256 checksum outputs aligned with distribution requirements.
## What Changes
- Add Linux arm64 (`aarch64`) builds producing `.deb`, `.rpm`, and `.AppImage`.
- Add `macOS universal.dmg` (while keeping x64/arm64 dmg outputs).
- Add Windows MSI artifacts in parallel with existing Squirrel `.exe`.
- Standardize artifact naming and architecture identifiers, and generate SHA-256 checksum files.
## Impact
- Affected specs: `release-packaging`
- Affected code:
- `E:\project\Crack\.github\workflows\publish.yaml`
- `E:\project\Crack\forge.config.ts`
- `E:\project\Crack\package.json`
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Linux Multi-Architecture Release Artifacts
The system SHALL produce Linux x64 and arm64 release artifacts, including `.deb`, `.rpm`, and `.AppImage`.
#### Scenario: Linux x64 artifact generation
- **WHEN** building a release on Linux x64
- **THEN** release assets include x64 `.deb`, `.rpm`, and `.AppImage` files
#### Scenario: Linux arm64 artifact generation
- **WHEN** building a release on Linux arm64
- **THEN** release assets include arm64 `.deb`, `.rpm`, and `.AppImage` files
### Requirement: macOS Multi-Architecture and Universal dmg
The system SHALL produce macOS x64, arm64, and universal dmg artifacts in release builds.
#### Scenario: macOS x64/arm64 dmg generation
- **WHEN** building releases for macOS x64 and arm64
- **THEN** release assets include dmg files for each corresponding architecture
#### Scenario: macOS universal dmg generation
- **WHEN** running a universal build
- **THEN** release assets include a universal dmg
### Requirement: Parallel Windows MSI and Squirrel Artifacts
The system SHALL generate both Squirrel installer artifacts and MSI artifacts in Windows release builds.
#### Scenario: Windows MSI generation
- **WHEN** building a release on Windows x64
- **THEN** release assets include both `.exe` installer and `.msi` artifacts
### Requirement: Artifact Naming and SHA-256 Output
The system SHALL output SHA-256 checksums for all release artifacts and use a unified naming convention that includes architecture markers.
#### Scenario: Artifact naming includes version and architecture
- **WHEN** generating release artifacts
- **THEN** artifact names include `productName`, `version`, and `arch` identifiers
#### Scenario: SHA-256 checksum output
- **WHEN** release artifact generation finishes
- **THEN** release assets include SHA-256 checksums for corresponding artifacts
@@ -0,0 +1,7 @@
## 1. Implementation
- [x] 1.1 Update the release workflow matrix to add Linux arm64 builds and split build steps by architecture.
- [x] 1.2 Add macOS universal build flow (merge app bundles before generating universal dmg).
- [x] 1.3 Introduce Windows MSI maker and required dependencies, and generate MSI in the Windows job.
- [x] 1.4 Extend `postMake` artifact renaming and architecture mapping logic to generate SHA-256 checksum files.
- [ ] 1.5 Update release attachments and artifact inventory documentation (if needed).
- [ ] 1.6 Verify CI outputs for Linux arm64, macOS universal, and Windows MSI.
@@ -0,0 +1,35 @@
## Context
The stability of account injection and detection differs across platforms, with risks concentrated in:
- DB lock conflicts causing unstable writes.
- Version-detection failures causing incorrect injection-path routing.
- Compatibility boundaries introduced by OS behavior differences.
## Goals / Non-Goals
- Goals:
- Improve cross-platform write and switching stability.
- Define clear priority between version detection and capability probing.
- Improve observability and troubleshooting efficiency.
- Non-Goals:
- Extend support to non-default runtime configuration path parsing.
- Introduce new business strategies.
## Decisions
- Decision: Transactionalize the write flow to reduce partial-success risk.
- Decision: Add DB busy retry and `busy_timeout`.
- Decision: On version-detection failure, use `package.json` as Windows fallback source.
- Decision: Give capability probing higher priority than version detection; prefer new format when a new-format key is detected.
## Risks / Trade-offs
- A stricter write flow may add minor latency.
- Running capability probing and version detection together increases implementation complexity.
## Migration Plan
1. Complete transactional writes and retry mechanisms.
2. Add version-detection cache and fallback.
3. Introduce strategy-based injection and capability probing.
4. Add corresponding tests and regression verification.
@@ -0,0 +1,35 @@
# Change: Cross-Platform Stability and Robustness Improvements (P0+P1+P2)
## Why
- Account injection and detection across platforms are vulnerable to DB locks, version-detection failures, and OS-level differences.
- Stability, diagnosability, and maintainability must improve without changing business semantics.
## What Changes
- Make the write flow atomic (transaction-wrapped).
- Add DB lock retry and `busy_timeout`.
- Add cache and lower-noise logging for version detection.
- Add a more robust Windows fallback for version detection (read from `package.json`).
- Introduce strategy-based injection routing (new/legacy/dual-write).
- Add capability probing: prefer new format when new-format keys are detected.
## Impact
- Affected specs: `ide-auth-compat`
- Affected code:
- `src/ipc/database/cloudHandler.ts`
- `src/ipc/database/handler.ts`
- `src/utils/antigravityVersion.ts`
- `src/utils/paths.ts` (this stage covers default runtime configuration path only)
- `src/types/db.ts`
## Constraints
- Do not change existing business semantics; only improve stability and structure.
- This stage only covers the default runtime configuration path.
## Risks
- Transaction and retry logic may change when errors surface.
- Capability probing may conflict with version detection unless priority is clearly defined.
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Atomic Injection Writes
The system SHALL use transactions when writing IDE state to ensure atomic updates.
#### Scenario: Injection write consistency
- **WHEN** injecting new/legacy token formats
- **THEN** related keys are committed in a single transaction to avoid partial writes
### Requirement: DB Lock Retry
The system SHALL perform short backoff retries on SQLITE_BUSY or SQLITE_LOCKED.
#### Scenario: Successful DB lock retry
- **WHEN** a write encounters a lock
- **THEN** the system retries for a limited number of attempts and records logs
### Requirement: Version Detection Cache
The system SHALL cache IDE version-detection results in-process to reduce repeated system calls.
#### Scenario: Version cache hit
- **WHEN** IDE version is requested multiple times
- **THEN** the system call is executed only once
### Requirement: Windows Version Detection Fallback
The system SHALL read `package.json` as a fallback source when Windows version detection fails.
#### Scenario: Windows version detection failure
- **WHEN** PowerShell fails to read version
- **THEN** version is obtained by falling back to `package.json`
### Requirement: Strategy-Based Injection
The system SHALL use a strategy pattern to split new/legacy/dual-write injection paths for clearer structure.
#### Scenario: Strategy selection
- **WHEN** version-based and capability-based routing differ
- **THEN** the system selects the corresponding injection implementation via strategy objects
### Requirement: Prefer New Format via Capability Probing
The system SHALL prefer new-format injection when new-format keys are detected.
#### Scenario: New-format key exists
- **WHEN** `antigravityUnifiedStateSync.oauthToken` exists
- **THEN** injection uses new format with higher priority
@@ -0,0 +1,17 @@
## 1. Scope and Planning Confirmation
- [ ] 1.1 Review `proposal.md`.
- [ ] 1.2 Finalize priority: capability probing vs version detection.
- [ ] 1.3 Confirm this stage covers default runtime configuration path only.
## 2. Implementation
- [x] 2.1 Make write flow transactional (atomic).
- [x] 2.2 Add DB lock retry and `busy_timeout`.
- [x] 2.3 Add version-detection cache and lower-noise logs.
- [x] 2.4 Add Windows fallback for version detection (`package.json`).
- [x] 2.5 Split injection path by strategy pattern.
- [x] 2.6 Add capability probing: prefer new format when new key is detected.
## 3. Verification
- [x] 3.1 Simulate SQLITE_BUSY/LOCKED retry behavior.
- [x] 3.2 Verify fallback path for version-detection failure.
- [x] 3.3 Verify capability-probing priority behavior.
@@ -0,0 +1,35 @@
## Context
Current SQLite access mainly relies on native `better-sqlite3` APIs, without unified typing and runtime validation strategy. This raises maintenance cost and regression risk. Introducing Drizzle ORM aims to improve type safety and maintainability while preserving existing behavior and compatibility.
## Goals / Non-Goals
- Goals:
- Provide structured schema and type inference
- Unify DB access and validation patterns, reducing `any/as`
- Preserve WAL, `busy_timeout`, transactions, and compatibility logic
- Non-Goals:
- Do not change existing database schema
- Do not introduce a new database engine or remote storage
## Decisions
- Decision: Adopt Drizzle ORM while keeping `better-sqlite3` as the driver.
- Decision: Keep runtime validation and compatibility logic for external IDE DB (`ItemTable`).
- Decision: Migrate in order: internal DB first, external DB second.
## Alternatives Considered
- Continue with `better-sqlite3 + Zod`: limited type-safety gains and higher long-term maintenance cost.
- Prisma: heavyweight footprint and higher Electron integration cost.
## Risks / Trade-offs
- Packaging compatibility risk (Electron + native module ABI): requires cross-platform validation.
- Temporary complexity increase while dual stacks coexist during migration.
- Drizzle provides static typing only; runtime validation is still required to avoid dirty data.
## Migration Plan
1. Define Drizzle schema (`accounts`/`settings`/`ItemTable`).
2. Migrate `cloudHandler.ts` (internal DB).
3. Migrate `database/handler.ts` (external IDE DB).
4. Update unit tests and mocks.
5. Run cross-platform smoke verification.
## Open Questions
- Loose compatibility mode is confirmed; stronger runtime schema is not introduced for now.
@@ -0,0 +1,17 @@
# Change: Refactor SQLite Access with Drizzle ORM
## Why
Current `better-sqlite3` usage relies heavily on `any/as`, lacks unified typing and runtime validation, and increases long-term maintenance cost. Introducing Drizzle ORM provides structured schema definitions and more consistent type constraints.
## What Changes
- Introduce Drizzle ORM and define schema for the internal database.
- Incrementally migrate read/write paths in `cloudHandler` and `database/handler`.
- Preserve existing WAL, `busy_timeout`, and transaction behavior to avoid regressions.
- Keep runtime validation and compatibility logic for the external IDE DB (`ItemTable`).
## Impact
- Affected code: `src/ipc/database/cloudHandler.ts`, `src/ipc/database/handler.ts`, tests, and DB utility layer
- Affected behavior: data-access typing and runtime validation approach changes (regression validation required)
@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Use Drizzle ORM for Internal Database
The system SHALL use Drizzle ORM to access the internal database (`cloud_accounts.db`) and provide type constraints through schema.
#### Scenario: Read cloud account list
- **WHEN** the system reads the `accounts` table
- **THEN** returned data SHALL match Drizzle schema types
### Requirement: Keep Compatibility and Validation for External IDE Database
The system SHALL preserve compatibility logic and runtime validation when accessing the external IDE database (`ItemTable`).
#### Scenario: Read ItemTable key
- **WHEN** reading a specific key from `ItemTable`
- **THEN** data that fails validation SHALL be logged as warning and skipped
### Requirement: Maintain Database Connection Stability
The system SHALL preserve existing WAL, `busy_timeout`, and transaction strategies to avoid migration regressions.
#### Scenario: Transactional write consistency
- **WHEN** executing write operations
- **THEN** related updates SHALL be committed within the same transaction
@@ -0,0 +1,19 @@
## 1. Scope and Migration Confirmation
- [x] 1.1 Confirm migration scope (internal DB vs external IDE DB).
- [x] 1.2 Confirm Drizzle driver choice (keep `better-sqlite3`).
- [x] 1.3 Confirm no changes to existing database schema.
## 2. Design and Preparation
- [x] 2.1 Build Drizzle schema (`accounts`/`settings`/`ItemTable`).
- [x] 2.2 Define connection initialization and pragma strategy (WAL, `busy_timeout`).
- [x] 2.3 Define runtime validation strategy (external IDE DB, loose compatibility).
## 3. Implementation
- [x] 3.1 Migrate read/write paths in `cloudHandler.ts`.
- [x] 3.2 Migrate read/write paths in `database/handler.ts`.
- [x] 3.3 Preserve existing transaction and retry semantics.
## 4. Testing and Verification
- [x] 4.1 Update unit tests and mocks.
- [ ] 4.2 Run regression checks for account backup/restore and injection flow.
- [ ] 4.3 Complete cross-platform smoke verification (Win/macOS/Linux).
@@ -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`
@@ -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.
@@ -0,0 +1,38 @@
## Context
- The target IDE changed account OAuth token persistence format starting from version 1.16.5.
- Existing JS logic only covers the legacy format, causing account detection and switching failures.
- Version detection, new/legacy routing, and dual-write fallback need to be completed.
## Goals / Non-Goals
- Goals:
- Ensure compatibility for versions 1.16.5+.
- Apply dual-format injection fallback when version detection fails.
- Extend backup/restore coverage to include new-format keys.
## Decisions
- Decision: Write a single format when version detection succeeds; dual-write when detection fails.
- Decision: Prefer reading the new format for account detection, with legacy fallback.
- Decision: Keep protobuf encode/decode behavior and field ordering consistent.
## Risks / Trade-offs
- Risk: Protobuf encode/decode mismatch may cause injection failure.
- Mitigation: Per-field validation and regression test coverage.
- Risk: Version detection failure.
- Mitigation: Dual-write fallback.
- Risk: DB lock conflicts.
- Mitigation: Keep existing flow: close IDE -> backup -> inject -> restart.
## Migration Plan
1. Implement version-detection module (JS).
2. Implement protobuf encode/decode for new format.
3. Implement routed injection/detection logic with dual-write fallback.
4. Extend backup/restore key set.
## Open Questions
- None.
@@ -0,0 +1,34 @@
# Change: IDE Auth Compatibility Fix for 1.16.5+
## Why
- The target IDE changed OAuth token persistence format starting from 1.16.5.
- The current JS implementation only supports the legacy format, causing account detection and switching failures.
- The verified behavior needs to be fully migrated into the current code path to reduce regression risk.
## What Changes
- Add IDE version detection and route injection by legacy/new format.
- Support encoding/decoding and account detection for the new OAuth token format.
- Apply dual-write fallback (legacy + new) when version detection fails.
- Extend backup/restore logic to include new-format keys.
## Constraints
- Keep existing behavior semantics; do not introduce new strategy layers or business fields.
- This stage only covers the default runtime configuration path.
## Impact
- Affected specs: `ide-auth-compat`
- Affected code:
- `src/ipc/database/cloudHandler.ts`
- `src/utils/protobuf.ts`
- `src/ipc/database/handler.ts`
- `src/utils/paths.ts`
## Risks
- Protobuf encoding/decoding mismatch may break injection or parsing.
- Version detection failure may route to the wrong injection path.
- DB lock conflicts during injection may cause write failures.
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: New-Format Injection for 1.16.5+
The system SHALL write only the new format `antigravityUnifiedStateSync.oauthToken` when IDE version is >= 1.16.5.
#### Scenario: New-version injection
- **WHEN** IDE version is 1.16.5 or above
- **THEN** the system writes the new format and completes account switching
### Requirement: Legacy Injection for Older Versions
The system SHALL write only the legacy format `jetskiStateSync.agentManagerInitState` when IDE version is < 1.16.5.
#### Scenario: Legacy-version injection
- **WHEN** IDE version is below 1.16.5
- **THEN** the system writes the legacy format and completes account switching
### Requirement: Dual-Write Fallback on Version Detection Failure
The system SHALL attempt both new and legacy format injection when IDE version cannot be detected.
#### Scenario: Version detection fails
- **WHEN** the system cannot read IDE version
- **THEN** the system writes both new and legacy formats and must not abort due to one path failing
### Requirement: Account Detection via New Format
The system SHALL read OAuth token from the new format and complete account detection.
#### Scenario: Only new format exists
- **WHEN** IDE database contains only the new-format key
- **THEN** the system still recognizes accounts and completes synchronization
### Requirement: Backup/Restore Includes New-Format Keys
The system SHALL include new-format keys in account backup and restore.
#### Scenario: Backup and restore for new format
- **WHEN** users perform backup and restore
- **THEN** new-format keys are fully saved and restored
@@ -0,0 +1,19 @@
## 1. Scope and Planning Confirmation
- [x] 1.1 Review `proposal.md`.
- [x] 1.2 Confirm this stage covers default runtime configuration path only.
- [x] 1.3 Define behavior-consistency constraints.
## 2. Implementation
- [x] 2.1 Add IDE version-detection module.
- [x] 2.2 Add encode/decode support for new-format OAuth token.
- [x] 2.3 Add routed injection logic and dual-write fallback on detection failure.
- [x] 2.4 Prefer new format for account detection, with legacy fallback.
- [x] 2.5 Extend backup/restore to cover new-format keys.
## 3. Verification
- [ ] 3.1 Self-test injection paths for both new and legacy formats.
- [ ] 3.2 Verify dual-write behavior when version detection fails.
- [ ] 3.3 Verify account availability after backup/restore.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "antigravity-manager",
"version": "0.7.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antigravity-manager",
"version": "0.7.0",
"version": "0.8.0",
"license": "CC-BY-NC-SA-4.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.7.0",
"version": "0.8.0",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
+2
View File
@@ -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>,
+37
View File
@@ -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();
}
+40
View File
@@ -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();
}
+33 -9
View File
@@ -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
View File
@@ -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>
);
}
+428
View File
@@ -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>
);
}
+31
View File
@@ -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
View File
@@ -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}`);
}
}
/**
+77 -2
View File
@@ -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
View File
@@ -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}`);
}
}
+111
View File
@@ -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();
}),
});
+405
View File
@@ -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();
+2
View File
@@ -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'),
+715
View File
@@ -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);
}
}
+91
View File
@@ -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;
}
}
+80
View File
@@ -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,
};
}
+113
View File
@@ -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,
},
};
}
+78
View File
@@ -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: 'Нет добавленных облачных аккаунтов.',
},
+182
View File
@@ -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);
}
+97 -1
View File
@@ -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);
});
});
+120
View File
@@ -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();
});
});
+6
View File
@@ -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') {
+48
View File
@@ -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(),
});
+5
View File
@@ -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(),
+2
View File
@@ -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(),
+46
View File
@@ -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();