Compare commits

...
54 Commits
Author SHA1 Message Date
semantic-release-bot d25f2948c3 chore(release): 0.5.0 [skip ci]
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)

### ✨ Features

* **i18n:** add Russian localization ([#48](https://github.com/Draculabo/AntigravityManager/issues/48)) ([63956c9](https://github.com/Draculabo/AntigravityManager/commit/63956c9c2d60f829a998237abe6ade675fdb01ed))
* Implement collapsible sidebar and refined status bar UI ([#45](https://github.com/Draculabo/AntigravityManager/issues/45)) ([1265d04](https://github.com/Draculabo/AntigravityManager/commit/1265d044f69e52fba7da72fde6c47a0b85c58232))
* sentry integration ([#51](https://github.com/Draculabo/AntigravityManager/issues/51)) ([a785640](https://github.com/Draculabo/AntigravityManager/commit/a785640d7a51b65a6852383401bd7c284b716975))
2026-01-30 05:01:40 +00:00
63956c9c2d feat(i18n): add Russian localization (#48)
* feat(i18n): add Russian localization

* fix: missing i18n keys

---------

Co-authored-by: Violetta <[email protected]>
Co-authored-by: Draculabo <[email protected]>
2026-01-30 12:50:37 +08:00
vin a785640d7a feat: sentry integration (#51)
* feat: integrate sentry error reporting

* feat: report logger errors to sentry with 30s log context
2026-01-30 11:48:49 +08:00
Sujit BiswasandDraculabo 1265d044f6 feat: Implement collapsible sidebar and refined status bar UI (#45)
* chore: mark peer dependencies in package-lock.json

* docs: add comprehensive developer guide for antigravity-manager

* build: add @radix-ui/react-tooltip dependency

The tooltip component is needed for future UI enhancements to provide better user guidance and information display.

* feat(StatusBar): add collapsed view with toggle button

- Introduce `isCollapsed` prop to support both expanded and compact layouts
- Collapsed view shows a single icon button with status tooltip
- Enhanced visual styling with shadows, borders, and improved spacing
- Use Tooltip component to display status in collapsed mode

* feat(ui): add Radix UI tooltip component wrapper

Create a reusable tooltip component using Radix UI primitives.
The wrapper provides styled content with animations and integrates
with the project's class name utility for consistent theming.

* feat(layout): add collapsible sidebar with persistent state

- Add toggle button to collapse/expand sidebar with smooth animations
- Persist sidebar state in localStorage to maintain user preference
- Show tooltips for navigation items when sidebar is collapsed
- Adjust StatusBar component to support collapsed state

* chore: remove "peer": true flags from package-lock.json

* build: mark peer dependencies in package-lock.json

* build: add yaml v2.8.2 dependency and update package-lock

* docs(project_overview): update skill documentation formatting

* docs: remove extra blank line in AGENTS.md

* docs: update changelog formatting to use hyphens

* docs: improve readability of OpenSpec workflow files

* chore: standardize YAML string quotes in GitHub configs

* style(StatusBar): reformat JSX for consistency and readability

- Simplify className expres

* style(ui): standardize quotes and class order in tooltip component

- Use single quotes consistently across imports and strings
- Reorder Tailwind CSS classes to follow recommended conventions

* style(ipc): remove unnecessary parentheses in debug log statement

* style(MainLayout): improve code formatting and consistency

- Reformat multi-line import for better readability
- Normalize Tailwind CSS class ordering
- Remove unnecessary whitespace and ensure consistent quotes
- Maintain all functionality while improving code style

* style(i18n): format long translation strings for better readability

* style: remove unnecessary line break in logger.info call

The logger.info call had an unnecessary line break that made the code less readable. The line has been consolidated to improve code style consistency.

* chore: disable several ESLint rules for development flexibility

Disable TypeScript ESLint rules and other linting rules that are too restrictive during active development. This allows faster iteration without constant linting errors for common patterns like `any` types, unused variables, and inline comments.

* refactor: use const for immutable variables in request mapper

- Change `let` to `const` for variables that are not reassigned
- Improves code clarity and prevents accidental reassignment

* refactor: replace direct hasOwnProperty calls with safe variant

Use Object.prototype.hasOwnProperty.call to avoid issues when objects have overridden hasOwnProperty method. This ensures consistent behavior across all object types.

* fix: disable eslint for intentional assignment in useEffect

The eslint warning was triggered by directly using setProxyConfig with config.proxy inside useEffect without including it in the dependency array. This assignment is intentional and only needs to run when config changes, not when setProxyConfig changes. Adding the eslint-disable comment prevents the warning while maintaining correct behavior.

* chore: disable react-hooks/exhaustive-deps rule

This rule was already disabled; the change only adds a trailing comma to the ESLint configuration for consistency.

* fix(eslint): re-enable lint rules for hooks and case-declarations

---------

Co-authored-by: Draculabo <[email protected]>
2026-01-28 18:55:40 +08:00
semantic-release-bot 9ca0ad0396 chore(release): 0.4.0 [skip ci]
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)

### ✨ Features

* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))

### 🐛 Bug Fixes

* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
2026-01-28 08:17:50 +00:00
Draculabo f0eb7c6b61 fix: stub nestjs optional modules for packaging 2026-01-28 16:16:27 +08:00
Draculabo ea51253d58 feat: add system autostart and single-instance support 2026-01-28 13:57:18 +08:00
Draculabo dd9e84a0db feat: implement smart foreground quota refresh with debounce
- Trigger immediate quota poll when main window gains focus.
- Add 10s debounce to prevent API spam on frequent window switching.
- Add concurrency guard to prevent overlapping poll requests.
- Reset background polling interval after manual refresh.
- Add unit tests for polling, debounce, and concurrency logic.
2026-01-28 12:58:35 +08:00
semantic-release-bot a988a33d21 chore(release): 0.3.5 [skip ci]
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)

### 🐛 Bug Fixes

* "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))

### 📝 Documentation

* remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
2026-01-26 20:39:04 +00:00
Sujit BiswasandDraculabo e959ee346e fix: "Check Quota Now" button not refreshing UI after polling (#42)
* feat: add React Query hooks for managing cloud accounts and update the generated route tree.

* feat: implement React Query hooks for comprehensive cloud account management.

* fix(cloud-accounts): add error handling to force poll mutation

Add an onError handler to log failures when forcing a cloud monitor poll, improving debuggability.

* fix: add error handling for cloud account polling failure

Add error toast notification when force polling fails, providing localized error message to improve user feedback.

* refactor: remove error logging from force poll cloud monitor hook

The onError handler was removed as error handling is now managed at a higher level in the application. This simplifies the hook and prevents duplicate error logging.

* fix(localization): add missing pollFailed translation keys

Add missing English and Chinese translations for the 'pollFailed' error message used when quota polling fails for all accounts.

* fix(CloudAccountList): prevent duplicate force poll requests

Add guard clause to disable button while mutation is pending to avoid duplicate API calls.

* fix: ensure UI updates after cloud quota poll and refresh

* chore: remove "peer": true flags from package-lock.json

---------

Co-authored-by: Draculabo <[email protected]>
2026-01-27 04:38:24 +08:00
Draculabo 5a2168030e docs: remove beta download links from README 2026-01-27 03:22:52 +08:00
semantic-release-bot 00bf4dea0b chore(release): 0.3.4 [skip ci]
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)

### 🐛 Bug Fixes

* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))

### 📝 Documentation

* update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
2026-01-26 19:06:28 +00:00
vin 02080588b7 fix(security): add safeStorage fallback for production builds (#38) (#43)
Summary
  - Replace hard keytar dependency with a multi-strategy key storage:
    1) safeStorage (Electron built-in) — preferred
    2) keytar (system keychain) — fallback
    3) file-based encryption — last resort
  - Dynamically import keytar to avoid asar load failures
  - Refactor cloudHandler to use const (clean code)
  - Fix “Failed to load cloud accounts” in packaged app

  Rationale
  The keytar native module can fail to load from asar archives in production builds. This ensures the app works even when system keychain access is unavailable.

  Additional changes
  - Replace sync file ops with async (fs/promises)
  - Add atomic file writes to prevent data races
  - Implement concurrency lock for key generation
  - Add security error codes for better diagnostics
  - Add warnings for file-based fallback mode
  - Align error handling after merge
  - Migrate legacy encrypted account data

  References
  - Issue: #38
  - Commit: 92dc2f6
2026-01-27 03:05:41 +08:00
Draculabo d5ee08d5a0 docs: update beta release link 2026-01-26 02:10:10 +08:00
semantic-release-bot e844aa9545 chore(release): 0.3.3 [skip ci]
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)

### 🐛 Bug Fixes

* accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))

### 📝 Documentation

* add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
* clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
2026-01-25 17:39:31 +00:00
Draculabo 0d4e2ab21f fix: accept lowercase antigravity in process detection 2026-01-26 01:37:58 +08:00
Draculabo f15bb48fdd docs: add beta download link to readme 2026-01-26 01:17:59 +08:00
Draculabo 515648d64d chore(release): add changelog link, PR list, and contributor links to release notes 2026-01-25 22:10:14 +08:00
Draculabo 22265e153c docs: clean up changelog duplicate 2026-01-25 19:08:53 +08:00
semantic-release-bot ad39a735b5 chore(release): 0.3.2 [skip ci]
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)

### 🐛 Bug Fixes

* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))

### 📝 Documentation

* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))

### 🔧 Continuous Integration

* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
* trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
2026-01-25 10:36:15 +00:00
Draculabo 6a07bc0a10 ci: trigger publish on release published event 2026-01-25 18:35:20 +08:00
semantic-release-bot e91457eceb chore(release): 0.3.2 [skip ci]
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)

### 🐛 Bug Fixes

* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))

### 📝 Documentation

* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))

### 🔧 Continuous Integration

* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
2026-01-25 09:48:18 +00:00
Draculabo 01e3f8f8fd docs: add macOS self-signing workaround for Keychain issues 2026-01-25 17:47:13 +08:00
Draculabo bd3d41aed1 fix: handle keychain hint and suppress pgrep spam 2026-01-25 17:46:32 +08:00
Draculabo d2945a6e8a ci: setup semantic release configuration and github actions workflow 2026-01-25 05:07:39 +08:00
Draculabo 89f39710c7 chore(release): bump version to 0.3.1
Publish Release / publish (ubuntu-latest) (push) Failing after 5m31s
Publish Release / publish (macos-14) (push) Canceled after 0s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-25 04:00:32 +08:00
Draculabo 1bce5d331d fix: enable WAL mode and force initialization on startup 2026-01-25 03:53:13 +08:00
Draculabo 47be175e27 fix: enable WAL mode to resolve process resource contention 2026-01-25 03:31:59 +08:00
Draculabo f0718db374 fix: resolve startup race condition causing cloud accounts verify failure 2026-01-25 02:45:05 +08:00
Draculabo 4b12f1220d chore(release): bump version to 0.3.0
Publish Release / publish (ubuntu-latest) (push) Failing after 6m48s
Publish Release / publish (macos-14) (push) Canceled after 0s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-23 10:58:18 +08:00
Draculabo 47c14763de chore(release): bump version to 0.3.0-rc.3
Publish Release / publish (ubuntu-latest) (push) Failing after 5m51s
Publish Release / publish (macos-14) (push) Canceled after 0s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-21 20:48:27 +08:00
Draculabo 37a9750df2 feat: Improved process detection on macOS/Linux using find-process to reliably identify the main application 2026-01-21 20:48:03 +08:00
Draculabo f708de24c0 fix: correct Linux state.vscdb path to include User/globalStorage 2026-01-21 18:21:06 +08:00
Draculabo 6e7f2252ad chore(release): bump version to 0.3.0-rc.2
Publish Release / publish (ubuntu-latest) (push) Failing after 6m1s
Publish Release / publish (macos-14) (push) Canceled after 0s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-21 17:23:08 +08:00
Draculabo e96d3e0e8e fix: add macOS arm64 build for Apple Silicon support 2026-01-21 17:15:17 +08:00
Draculabo 12ab4e0ce7 chore(release): bump version to 0.3.0-rc.1
Publish Release / publish (ubuntu-latest) (push) Failing after 5m46s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-21 01:05:56 +08:00
Draculabo 013f3290f3 chore: add VS Code settings for auto-formatting and ESLint 2026-01-21 00:43:54 +08:00
Draculabo f224fea081 feat: add expiration warning for Google OAuth authentication 2026-01-21 00:43:00 +08:00
Draculabo a438070268 feat: add button to open logs folder 2026-01-21 00:42:49 +08:00
Draculabo e927ac9506 feat: verify Google OAuth code automatically after receipt 2026-01-21 00:37:42 +08:00
Draculabo 465eb5d0a4 fix: tray icon not appearing in production builds
Publish Release / publish (ubuntu-latest) (push) Failing after 7m3s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
- Use extraResource config to copy assets outside of ASAR package
- Add debug logging for tray icon path resolution
- Bump version to 0.2.2
2026-01-19 21:08:31 +08:00
Draculabo 37b0fdeeb0 chore(release): bump version to 0.2.1 2026-01-19 20:20:08 +08:00
Draculabo a343e0c1ae chore:: apply prettier formatting to entire codebase 2026-01-19 19:40:11 +08:00
Draculabo 695d787b01 chore: exclude .agents and openspec from formatting 2026-01-19 19:34:43 +08:00
Draculabo b4ca037518 fix: resolve CI failures (lint, format, tests) 2026-01-19 19:31:46 +08:00
c43507c716 Fix: Linux tray quit and zombie icons (#24)
* Fix: Linux tray quit and zombie icons (#23)

- Add before-quit handler to set isQuitting flag
- Change pgrep -x to pgrep -xi for case-insensitive matching on Linux
- Add antigravity-manager to self-kill exclusion filter
- Destroy existing tray before creating new one in initTray()
- Destroy tray in will-quit handler
- Export destroyTray function for cleanup

Fixes #23

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Fix: Address code review feedback

- Add icon validation before creating tray (isEmpty check)
- Add try/catch around tray.destroy() calls for error handling
- Add try/catch around destroyTray() in will-quit handler
- Add explicit !manager check in Linux process filter for defensive coding

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Jirka <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-19 16:11:48 +08:00
Draculabo f5c44c549f chore: release v0.2.0
Publish Release / publish (ubuntu-latest) (push) Failing after 6m12s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-16 22:26:55 +08:00
Draculabo f37127d385 build: conditionally include plugins based on start command in forge.config.ts 2026-01-16 22:00:33 +08:00
Draculabo f636dd61a5 feat: enhance cloudHandler to inject minimal auth state when database entry is missing 2026-01-16 20:30:49 +08:00
Draculabo 42973561fb chore: upgrade electron from 32.3.3 to 37.3.1 2026-01-16 02:58:26 +08:00
Draculabo 5772e2e90e feat: implement stability fixes and enhance error handling 2026-01-16 02:51:29 +08:00
Draculabo b0ec96d4a6 fix: Converting circular structure to JSON 2026-01-15 22:58:05 +08:00
Draculabo 5a004d9d16 docs: add curly brace constraints for conditional statements 2026-01-15 22:54:23 +08:00
Draculabo ccc5c91969 docs: fix incorrect reference documentation name 2026-01-15 22:37:35 +08:00
127 changed files with 13009 additions and 1361 deletions

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
---
name: antigravity-manager
description: Comprehensive guide to Antigravity Manager architecture, workflows, and development. Use this to understand how to work on the project.
---
# Antigravity Manager Developer Guide
## 🏗️ Architecture Overview
Antigravity Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
```mermaid
graph TD
User[User Interface] -->|React/Vite| Renderer[Renderer Process]
Renderer -->|ORPC Client| IPC[IPC Layer]
IPC -->|ORPC Router| Main[Main Process]
Main -->|Bootstraps| Server[NestJS Server]
Main -->|Calls| Services[Service Layer]
Services -->|Read/Write| DB[(SQLite Database)]
Services -->|HTTP| Cloud[Cloud APIs (Google/Anthropic)]
```
### Key Technologies
- **Frontend**: React 19, TailwindCSS v4, TanStack Router, TanStack Query.
- **Backend**: Electron (Main), NestJS (Core Logic), Better-SQLite3 (Data).
- **Communication**: ORPC (Type-safe IPC wrapper around Electron IPC).
- **Build**: Electron Forge + Vite.
## 📂 Directory Structure
- `src/main.ts`: Electron Main Process entry point.
- `src/preload.ts`: Bridge between Main and Renderer.
- `src/renderer.tsx`: React App entry point.
- `src/components/`: Reusable React UI components (Radix UI based).
- `src/ipc/`: IPC Routers and Handlers (Domain logic).
- `router.ts`: Main ORPC router definition.
- `account/`, `cloud/`, `database/`: Domain-specific handlers.
- `src/server/`: NestJS application modules (proxies/gateways).
- `src/services/`: Core business logic (framework agnostic).
- `GoogleAPIService.ts`: Gemini/Cloud interactions.
- `AutoSwitchService.ts`: Account rotation logic.
- `src/routes/`: Frontend routing definitions (File-based).
## 🚀 Development Workflow
### Prerequisites
- Node.js 18+
- npm (Project uses `package-lock.json`)
### Common Commands
- **Start Dev Server**: `npm start`
- **Lint Code**: `npm run lint`
- **Unit Test**: `npm run test:unit`
- **E2E Test**: `npm run test:e2e`
- **Build Production**: `npm run make`
## 🧠 Core Concepts
### IPC Communication (ORPC)
The project uses `orpc` for type-safe communication.
- **Define**: Create a router in `src/ipc/router.ts` with Zod schemas.
- **Implement**: Add logic in handlers (e.g., `src/ipc/account/handler.ts`).
- **Call**: Use the generated client in React components.
### Database Access
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
- Use `Better-SQLite3` for direct access.
- Logic should be encapsulated in `src/services` or `src/ipc`.
### Account Management
- Accounts are added via OAuth (Google/Claude).
- `GoogleAPIService` handles token exchange and refreshing.
- `AutoSwitchService` monitors usage and switches active accounts automatically.
## ⚠️ Critical Rules
1. **Type Safety**: strict TypeScript usage; Zod for runtime validation.
2. **Components**: Use `src/components/ui` (Radix primitives) for consistency.
3. **Async**: Handle all IPC/DB calls asynchronously with try/catch.
4. **Security**: Never commit secrets. API keys are user-provided or encrypted locally.
+5
View File
@@ -1,14 +1,18 @@
---
description: Implement an approved OpenSpec change and keep tasks in sync.
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
@@ -16,5 +20,6 @@ Track these steps as TODOs and complete them one by one.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->
+5
View File
@@ -1,13 +1,17 @@
---
description: Archive a deployed OpenSpec change and update specs.
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Determine the change ID to archive:
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
@@ -19,6 +23,7 @@ description: Archive a deployed OpenSpec change and update specs.
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Use `openspec list` to confirm change IDs before archiving.
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
<!-- OPENSPEC:END -->
+5
View File
@@ -1,8 +1,11 @@
---
description: Scaffold a new OpenSpec change and validate strictly.
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
@@ -10,6 +13,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
@@ -19,6 +23,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
+3 -3
View File
@@ -1,6 +1,6 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: "weekly"
interval: 'weekly'
+1 -1
View File
@@ -1,7 +1,7 @@
name-template: 'v$RESOLVED_VERSION'
tag-template: 'v$RESOLVED_VERSION'
categories:
- title: '🚀 What''s new'
- title: "🚀 What's new"
labels:
- 'feature'
- 'feat'
+7 -4
View File
@@ -1,9 +1,8 @@
name: Publish Release
on:
push:
tags:
- 'v*'
release:
types: [published]
workflow_dispatch:
jobs:
@@ -11,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }}
@@ -40,4 +39,8 @@ jobs:
- name: Publish Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: npm run publish
+37
View File
@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
- beta
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for semantic-release to analyze commits
persist-credentials: false # We use a token in the next steps
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
run: npx semantic-release
+3
View File
@@ -102,3 +102,6 @@ out/
# Tanstack
.tanstack/
# Sentry Config File
.env.sentry-build-plugin
+3 -1
View File
@@ -3,4 +3,6 @@
/coverage
/.vite
README.md
/src/routeTree.gen.ts
/src/routeTree.gen.ts
.agents/
openspec/
-1
View File
@@ -8,5 +8,4 @@
"tabWidth": 2,
"proseWrap": "preserve",
"tailwindFunctions": ["clsx", "tw"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}
+15 -7
View File
@@ -1,4 +1,3 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
@@ -107,9 +106,6 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- `npm run test:e2e` - 运行 Playwright 端到端测试
- `npm run test:all` - 运行所有测试
- **数据库 (DB)**:
- `npm run test-db` - 运行数据库测试脚本
### 运行单个测试 (Running Single Tests)
- 单元测试: `npm run test:unit path/to/test.test.ts`
@@ -117,6 +113,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- 类型检查: `npm run type-check`
## 🧪 开发注意事项(Development Notes)
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
- **React**: React Strict Mode 关闭。
@@ -141,6 +138,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
### 命名约定 (Naming Specifics)
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
- **Files**:
@@ -221,6 +219,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
- 标题后保留一行空行,便于渲染与阅读。
## 换行规范
return 等语句不要与其他语句在同一行,而是单独保持一行
## 💭 注释规范
@@ -263,6 +262,7 @@ export enum BudgetType {
- 信任既定前置条件,避免对承诺不变量的过度防御;若出现冲突,更新方案而非额外兜底。
- 老代码重构保守推进,新功能实现可采用更现代方式。
- 避免过早优化:先以简单直接的实现达成功能,确需时再优化(避免先行加缓存/抖动或拆分成过多文件)。
- 对于 if,while 等条件、循环语句必须加上花括号,不能与return、break、continue 等语句同级。
### 错误处理 (Error Handling)
@@ -277,7 +277,9 @@ try {
}
// 使用正确的错误类型
if (error instanceof Error) { /* 处理 Error 实例 */ }
if (error instanceof Error) {
/* 处理 Error 实例 */
}
```
### 新功能实现
@@ -300,6 +302,7 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
- \[ ] 形成 TODO 列表
实现/重构/修复:
- [ ] 阅读相关模板与周边代码,沿用既有模式
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
- [ ] 在约束内尽力提升前端交互与体验
@@ -334,9 +337,11 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
## 🔧 工具偏好与命令
包与脚本:
- `npm install` (或简写 `npm i`)
Shell:
- 在工作区根目录执行命令。
- 路径建议使用引号包裹。
@@ -349,6 +354,7 @@ Web 搜索:
- `context7` 获取依赖的最新用法
VSCode MCP (如可用):
- `mcp__vscode-mcp__get_references` 辅助重构范围判定
- `mcp__vscode-mcp__rename_symbol` 安全重命名
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
@@ -383,15 +389,17 @@ change description 示例:
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke: Bash("openskills read <skill-name>")
- The skill content will load with detailed instructions on how to complete the task
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
Usage notes:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already loaded in your context
- Each skill invocation is stateless
</usage>
</usage>
<available_skills>
@@ -492,7 +500,7 @@ Usage notes:
</skill>
</available_skills>
<!-- SKILLS_TABLE_END -->
</skills_system>
+142 -4
View File
@@ -1,29 +1,167 @@
<a name="readme-top"></a>
# Changelog
All notable changes to this project will be documented in this file.
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### ✨ Features
* **i18n:** add Russian localization ([#48](https://github.com/Draculabo/AntigravityManager/issues/48)) ([63956c9](https://github.com/Draculabo/AntigravityManager/commit/63956c9c2d60f829a998237abe6ade675fdb01ed))
* Implement collapsible sidebar and refined status bar UI ([#45](https://github.com/Draculabo/AntigravityManager/issues/45)) ([1265d04](https://github.com/Draculabo/AntigravityManager/commit/1265d044f69e52fba7da72fde6c47a0b85c58232))
* sentry integration ([#51](https://github.com/Draculabo/AntigravityManager/issues/51)) ([a785640](https://github.com/Draculabo/AntigravityManager/commit/a785640d7a51b65a6852383401bd7c284b716975))
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)
### ✨ Features
* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))
### 🐛 Bug Fixes
* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
### 🐛 Bug Fixes
- "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))
### 📝 Documentation
- remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
### 🐛 Bug Fixes
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
### 📝 Documentation
- update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
### 🐛 Bug Fixes
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
### 🐛 Bug Fixes
- accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
### 📝 Documentation
- add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
- clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
### 🐛 Bug Fixes
- handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
### 📝 Documentation
- add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
### 🔧 Continuous Integration
- setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
- trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
## [0.3.1] - 2026-01-25
### Bug Fixes
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
## [0.3.0] - 2026-01-23
### New Features
- Verify Google OAuth code automatically after receipt
- Add button to open logs folder
- Add expiration warning for Google OAuth authentication
### Bug Fixes
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
### Maintenance
- Add VS Code settings for auto-formatting and ESLint
## [0.2.2] - 2026-01-19
### Bug Fixes
- Fixed tray icon not appearing in production builds on Windows
- Used `extraResource` config to properly copy assets outside of ASAR package
- Added debug logging for tray icon path resolution
## [0.2.1] - 2026-01-19
### Bug Fixes
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
### Maintenance
- Applied Prettier formatting to entire codebase (68 files)
- Added node globals to ESLint configuration
## [0.2.0] - 2026-01-16
### New Features
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
- Implemented stability fixes and enhanced error handling across the application.
### Improvements
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
### Bug Fixes
- Fixed "Converting circular structure to JSON" error.
### Documentation
- Added curly brace constraints for conditional statements.
- Fixed incorrect reference documentation name.
## [0.1.1] - 2026-01-11
### Bug Fixes
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
## [0.1.0] - 2026-01-10
### New Features
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
### Bug Fixes
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
## [0.0.1] - 2025-12-22
### Added
- Initial release of Antigravity Manager
- Multi-account management for Google Gemini and Claude
- Real-time quota monitoring
+1 -1
View File
@@ -1 +1 @@
请始终参考 @AGENT.md 文件
请始终参考 @AGENTS.md 文件
+11 -10
View File
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
@@ -45,4 +45,5 @@ This Code of Conduct is adapted from the [Contributor Covenant][homepage], versi
[homepage]: https://www.contributor-covenant.org
---
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
+14 -10
View File
@@ -52,20 +52,21 @@ npm start
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
| Command | Description |
| ---------------------- | ----------------------------------- |
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
## ✏️ Making Changes
1. **Create a new branch** from `main`:
```bash
git checkout -b feature/your-feature-name
# or
@@ -73,6 +74,7 @@ npm start
```
2. **Make your changes** and commit them:
```bash
git add .
git commit -m "feat: add amazing new feature"
@@ -97,6 +99,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
@@ -107,6 +110,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
- `chore`: Changes to build process or auxiliary tools
**Examples:**
```
feat(auth): add Google OAuth support
fix(quota): resolve quota refresh timeout issue
+18
View File
@@ -280,6 +280,24 @@ Please check:
</details>
<details>
<summary><b>Q: macOS shows Keychain/Credential error and OAuth cannot be saved?</b></summary>
This is a common macOS security behavior, usually when the app is unsigned or run directly from Downloads.
This is a **temporary workaround** for personal use:
1. Move the app to `/Applications`
2. Run the following commands in Terminal (repeat after every update)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
Reopen the app and allow Keychain access if prompted.
</details>
<details>
<summary><b>Q: How to report issues or suggestions?</b></summary>
+40 -22
View File
@@ -174,7 +174,6 @@
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
</p>
---
## 🚀 快速开始
@@ -183,11 +182,11 @@
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
| 平台 | 下载链接 |
|------|----------|
| 平台 | 下载链接 |
| ------- | ------------------------------------------------------------------------------ |
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
### 从源码构建
@@ -217,14 +216,14 @@ npm run make
## 🛠️ 技术栈
| 类别 | 技术 |
|------|------|
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
| 类别 | 技术 |
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
---
@@ -232,16 +231,16 @@ npm run make
### 可用脚本
| 命令 | 描述 |
|------|------|
| `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 |
| 命令 | 描述 |
| ---------------------- | ------------------------ |
| `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 |
| `npm run format:write` | 使用 Prettier 格式化代码 |
| `npm run test:unit` | 运行单元测试 |
| `npm run test:e2e` | 运行 E2E 测试 |
| `npm run test:all` | 运行所有测试 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run make` | 构建生产包 |
| `npm run test:unit` | 运行单元测试 |
| `npm run test:e2e` | 运行 E2E 测试 |
| `npm run test:all` | 运行所有测试 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run make` | 构建生产包 |
### 项目结构
@@ -265,6 +264,7 @@ AntigravityManager/
<summary><b>Q: 程序无法启动怎么办?</b></summary>
请检查:
1. 确保已安装所有依赖:`npm install`
2. 检查 Node.js 版本是否 >= 18
3. 尝试删除 `node_modules` 后重新安装
@@ -280,6 +280,24 @@ AntigravityManager/
</details>
<details>
<summary><b>Q: macOS 上提示 Keychain/凭据管理不可用,OAuth 无法保存?</b></summary>
这是 macOS 安全机制导致的常见问题,通常出现在未签名的应用或从 Downloads 直接运行的场景。
这是**临时方案**,仅适合个人设备:
1. 将应用移动到 `/Applications`
2. 打开终端执行以下命令(每次更新后都需要重新执行)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
重新打开应用后,Keychain 应会提示授权(可选“始终允许”)。
</details>
<details>
<summary><b>Q: 如何反馈问题或建议?</b></summary>
+4 -4
View File
@@ -26,10 +26,10 @@ Master keys are stored in the OS native credential manager via `keytar`, never i
## 📋 Supported Versions
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < Latest | :x: |
| Version | Supported |
| -------- | ------------------ |
| Latest | :white_check_mark: |
| < Latest | :x: |
We only provide security updates for the latest version. Please ensure you're running the most recent release.
+10
View File
@@ -5,17 +5,20 @@
## 1. 核心功能
### 1.1 账号池管理
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
- **删除账号**:支持从本地数据库中移除账号。
### 1.2 配额实时监控
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
### 1.4 智能自动切换 (Intelligent Auto-Switching)
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
- **全局开关**:用户可在界面上一键开启或关闭此功能。
@@ -23,6 +26,7 @@
## 2. 技术实现
### 2.1 数据库设计 (`cloud_accounts.db`)
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
```sql
@@ -48,6 +52,7 @@ CREATE TABLE settings (
```
### 2.2 IPC 接口设计
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
@@ -59,15 +64,18 @@ CREATE TABLE settings (
- `forcePollCloudMonitor()`: 手动触发后台轮询。
### 2.3 Token 注入原理 (`ProtobufUtils`)
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
value 是一个 Base64 编码的 Protobuf 二进制数据。
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
1. **解码**:解析 Varint 和 Length-Delimited 字段。
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
### 2.4 切换流程
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
2. **停止进程**:优雅关闭 Antigravity 进程。
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
@@ -75,12 +83,14 @@ value 是一个 Base64 编码的 Protobuf 二进制数据。
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
### 2.5 自动切换逻辑 (`AutoSwitchService`)
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
### 2.6 安全加固 (Security Hardening)
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
+24 -14
View File
@@ -1,28 +1,38 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import eslintPluginPrettierRecommended from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import { fileURLToPath } from "node:url";
import { defineConfig } from "eslint/config";
import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import eslintPluginPrettierRecommended from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import path from 'node:path';
import { includeIgnoreFile } from '@eslint/compat';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'eslint/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
/** @type {import('eslint').Linter.Config[]} */
export default defineConfig([
includeIgnoreFile(prettierIgnorePath),
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
},
{ languageOptions: { globals: globals.browser } },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
pluginReact.configs.flat["jsx-runtime"],
pluginReact.configs.flat['jsx-runtime'],
reactHooks.configs.flat.recommended,
eslintPluginPrettierRecommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'no-empty-pattern': 'off',
'prefer-const': 'off',
},
},
]);
+17 -11
View File
@@ -12,6 +12,8 @@ import * as path from 'path';
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
const config: ForgeConfig = {
packagerConfig: {
asar: {
@@ -20,6 +22,7 @@ const config: ForgeConfig = {
name: 'Antigravity Manager',
executableName: 'antigravity-manager',
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
extraResource: ['src/assets'], // Copy assets folder to resources/assets
},
rebuildConfig: {},
hooks: {
@@ -80,7 +83,6 @@ const config: ForgeConfig = {
},
],
plugins: [
new AutoUnpackNativesPlugin({}),
new VitePlugin({
build: [
{
@@ -101,16 +103,20 @@ const config: ForgeConfig = {
},
],
}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
...(!isStartCommand
? [
new AutoUnpackNativesPlugin({}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
]
: []),
],
};
+1 -1
View File
@@ -93,7 +93,7 @@ src/
**数据库位置:**
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
* Linux: `~/.config/Antigravity/state.vscdb`
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
**关键数据库键:**
* `antigravityAuthStatus`: 认证状态和用户信息。
+7988 -317
View File
File diff suppressed because it is too large. Load diff
+16 -10
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.1.1",
"version": "0.5.0",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -18,7 +18,6 @@
"test:unit": "vitest",
"test:e2e": "playwright test",
"test:all": "vitest run && playwright test",
"test-db": "electron test-db.js",
"type-check": "tsc --noEmit"
},
"author": "Draculabo",
@@ -39,6 +38,10 @@
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0",
"@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.2",
"@sentry/vite-plugin": "^4.8.0",
"@swc/core": "^1.15.8",
"@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1",
@@ -47,13 +50,15 @@
"@types/better-sqlite3": "^7.6.12",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "^1.3.3",
"electron": "^32.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0",
"electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
"eslint": "^9.39.1",
@@ -67,6 +72,8 @@
"node-addon-api": "^8.3.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"semantic-release": "^25.0.2",
"semantic-release-config-gitmoji": "^1.5.3",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.16",
"ts-node": "^10.9.2",
@@ -85,7 +92,6 @@
"@nestjs/microservices": "^11.1.11",
"@nestjs/platform-express": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.10",
"@nestjs/websockets": "^11.1.11",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.11.3",
"@radix-ui/react-avatar": "^1.1.11",
@@ -102,6 +108,9 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.7.0",
"@sentry/integrations": "^7.114.0",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-router": "^1.139.3",
@@ -116,10 +125,12 @@
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2",
"find-process": "^2.0.0",
"happy-dom": "^20.0.10",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0",
"lodash-es": "^4.17.22",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -131,12 +142,7 @@
"undici": "^7.16.0",
"update-electron-app": "^3.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.1.12"
},
"ts-node": {
"transpileOnly": true,
"compilerOptions": {
"module": "commonjs"
}
}
}
+6 -6
View File
@@ -1,23 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./src/tests/e2e",
testDir: './src/tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
reporter: 'html',
use: {
trace: "on-first-retry",
trace: 'on-first-retry',
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+259
View File
@@ -0,0 +1,259 @@
/**
* Semantic Release Configuration
*
* This configuration uses the specific release rules requested by the user,
* but adapts the plugin configuration to work with standard Conventional Commits
* instead of Gitmoji (since the project history doesn't use emojis).
*/
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
const GITHUB_BASE_URL = 'https://github.com';
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
const releaseRules = [
{
release: 'minor',
type: 'feat',
},
{
release: 'patch',
type: 'fix',
},
{
release: 'patch',
type: 'perf',
},
{
release: 'patch',
type: 'style',
},
{
release: 'patch',
type: 'refactor',
},
{
release: 'patch',
type: 'build',
},
{ release: 'patch', scope: 'README', type: 'docs' },
{ release: 'patch', scope: 'README.md', type: 'docs' },
{ release: false, type: 'docs' },
{
release: false,
type: 'test',
},
{
release: false,
type: 'ci',
},
{
release: false,
type: 'chore',
},
{
release: false,
type: 'wip',
},
{
release: 'major',
type: 'BREAKING CHANGE',
},
{
release: 'major',
scope: 'BREAKING CHANGE',
},
{
release: 'major',
subject: '*BREAKING CHANGE*',
},
{ release: 'patch', subject: '*force release*' },
{ release: 'patch', subject: '*force patch*' },
{ release: 'minor', subject: '*force minor*' },
{ release: 'major', subject: '*force major*' },
{ release: false, subject: '*skip release*' },
];
const getGithubUsernameFromEmail = (email) => {
if (!email) {
return undefined;
}
const match = email.match(/^(?:\d+\+)?(?<username>[a-z0-9-]+)@users\.noreply\.github\.com$/i);
return match?.groups?.username;
};
const isBotIdentity = (value) => {
return typeof value === 'string' && /bot/i.test(value);
};
const buildReleaseFooter = (commits) => {
const mergedPullRequests = new Map();
const contributors = new Map();
(commits || []).forEach((commit) => {
const message = [commit?.message, commit?.subject, commit?.header].filter(Boolean).join('\n');
if (message) {
const mergeMatches = message.match(/pull request #(?<number>\d+)/gi);
if (mergeMatches) {
mergeMatches.forEach((match) => {
const number = match.replace(/\D/g, '');
if (number) {
mergedPullRequests.set(number, {
number,
url: `${REPO_URL}/pull/${number}`,
});
}
});
}
const squashMatches = message.match(/\(#(?<number>\d+)\)/g);
if (squashMatches) {
squashMatches.forEach((match) => {
const number = match.replace(/\D/g, '');
if (number) {
mergedPullRequests.set(number, {
number,
url: `${REPO_URL}/pull/${number}`,
});
}
});
}
}
const author = commit?.author || commit?.committer;
const username = getGithubUsernameFromEmail(author?.email);
const displayName = username ? `@${username}` : author?.name;
if (!displayName || isBotIdentity(displayName)) {
return;
}
if (username && isBotIdentity(username)) {
return;
}
contributors.set(username || displayName, {
name: displayName,
url: username ? `${GITHUB_BASE_URL}/${username}` : undefined,
});
});
const footerLines = [];
const sortedPullRequests = Array.from(mergedPullRequests.values()).sort(
(left, right) => Number(left.number) - Number(right.number),
);
if (sortedPullRequests.length > 0) {
footerLines.push('### 🔀 Merged Pull Requests');
sortedPullRequests.forEach((pullRequest) => {
footerLines.push(`- [#${pullRequest.number}](${pullRequest.url})`);
});
footerLines.push('');
}
const sortedContributors = Array.from(contributors.values()).sort((left, right) =>
left.name.localeCompare(right.name),
);
if (sortedContributors.length > 0) {
footerLines.push('### 🙌 Contributors');
sortedContributors.forEach((contributor) => {
if (contributor.url) {
footerLines.push(`- [${contributor.name}](${contributor.url})`);
return;
}
footerLines.push(`- ${contributor.name}`);
});
footerLines.push('');
}
footerLines.push(`Full Changelog: ${FULL_CHANGELOG_URL}`);
return footerLines.join('\n');
};
const appendReleaseNotes = (notes, commits) => {
const trimmedNotes = (notes || '').trimEnd();
const footer = buildReleaseFooter(commits);
if (!footer) {
return trimmedNotes;
}
return `${trimmedNotes}\n\n${footer}`;
};
const appendReleaseNotesPlugin = {
publish: async (pluginConfig, context) => {
if (!context?.nextRelease?.notes) {
return;
}
context.nextRelease.notes = appendReleaseNotes(context.nextRelease.notes, context.commits);
},
};
const releaseConfig = {
branches: ['main', { name: 'beta', prerelease: true }],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: releaseRules,
},
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: [
{ type: 'feat', section: '✨ Features' },
{ type: 'fix', section: '🐛 Bug Fixes' },
{ type: 'perf', section: '⚡ Performance Improvements' },
{ type: 'revert', section: '⏪ Reverts' },
{ type: 'docs', section: '📝 Documentation' },
{ type: 'style', section: '💄 Styles' },
{ type: 'refactor', section: '♻️ Code Refactoring' },
{ type: 'test', section: '✅ Tests' },
{ type: 'build', section: '👷 Build System' },
{ type: 'ci', section: '🔧 Continuous Integration' },
],
},
},
],
[
'@semantic-release/changelog',
{
changelogFile: 'CHANGELOG.md',
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
},
],
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
appendReleaseNotesPlugin,
[
'@semantic-release/github',
{
successComment: false,
failComment: false,
labels: false,
releaseName: 'v${nextRelease.version}',
},
],
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
],
};
releaseConfig.__internal = {
appendReleaseNotes,
buildReleaseFooter,
};
module.exports = releaseConfig;
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager";
import { ipc } from '@/ipc/manager';
export function listAccounts() {
return ipc.client.account.listAccounts();
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager";
import { ipc } from '@/ipc/manager';
export function getPlatform() {
return ipc.client.app.currentPlatfom();
+2 -2
View File
@@ -1,5 +1,5 @@
import { ipc } from "@/ipc/manager";
import { AccountBackupData, Account } from "@/types/account";
import { ipc } from '@/ipc/manager';
import { AccountBackupData, Account } from '@/types/account';
export function backupAccount(account: Account) {
return ipc.client.database.backupAccount(account);
+5
View File
@@ -0,0 +1,5 @@
import { ipc } from '@/ipc/manager';
export function openLogDirectory() {
return ipc.client.system.openLogDirectory();
}
+9 -11
View File
@@ -1,6 +1,6 @@
import { ThemeMode } from "@/types/theme-mode";
import { ipc } from "@/ipc/manager";
import { LOCAL_STORAGE_KEYS } from "@/constants";
import { ThemeMode } from '@/types/theme-mode';
import { ipc } from '@/ipc/manager';
import { LOCAL_STORAGE_KEYS } from '@/constants';
export interface ThemePreferences {
system: ThemeMode;
@@ -9,9 +9,7 @@ export interface ThemePreferences {
export async function getCurrentTheme(): Promise<ThemePreferences> {
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
const localTheme = localStorage.getItem(
LOCAL_STORAGE_KEYS.THEME,
) as ThemeMode | null;
const localTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as ThemeMode | null;
return {
system: currentTheme,
@@ -20,7 +18,7 @@ export async function getCurrentTheme(): Promise<ThemePreferences> {
}
export async function setTheme(newTheme: ThemeMode) {
const isDarkMode = newTheme === "dark";
const isDarkMode = newTheme === 'dark';
await ipc.client.theme.setThemeMode(newTheme);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
updateDocumentTheme(isDarkMode);
@@ -28,7 +26,7 @@ export async function setTheme(newTheme: ThemeMode) {
export async function toggleTheme() {
const isDarkMode = await ipc.client.theme.toggleThemeMode();
const newTheme = isDarkMode ? "dark" : "light";
const newTheme = isDarkMode ? 'dark' : 'light';
updateDocumentTheme(isDarkMode);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
@@ -37,7 +35,7 @@ export async function toggleTheme() {
export async function syncWithLocalTheme() {
const { local } = await getCurrentTheme();
if (!local) {
setTheme("system");
setTheme('system');
return;
}
@@ -46,8 +44,8 @@ export async function syncWithLocalTheme() {
function updateDocumentTheme(isDarkMode: boolean) {
if (isDarkMode) {
document.documentElement.classList.add("dark");
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove('dark');
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "../ipc/manager";
import { ipc } from '../ipc/manager';
export async function minimizeWindow() {
await ipc.client.window.minimizeWindow();
+8 -6
View File
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { enUS, zhCN, ru } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -32,18 +33,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
isSwitching,
isDeleting,
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
const [transform, setTransform] = useState('');
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
const initials = account.name
? account.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: account.email[0].toUpperCase();
// 处理鼠标移动,计算 3D 旋转角度
@@ -136,6 +137,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
{t('account.lastUsed', {
time: formatDistanceToNow(new Date(account.last_used), {
addSuffix: true,
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
}),
})}
</p>
+53 -31
View File
@@ -41,12 +41,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
// ... (existing code: imports and comments)
export function CloudAccountList() {
const { t } = useTranslation();
const { data: accounts, isLoading, isError } = useCloudAccounts();
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
const refreshMutation = useRefreshQuota();
const deleteMutation = useDeleteCloudAccount();
const addMutation = useAddGoogleAccount();
@@ -62,19 +63,49 @@ export function CloudAccountList() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [authCode, setAuthCode] = useState('');
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) {
return;
}
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
});
},
},
);
};
// Listen for Google Auth Code
useEffect(() => {
if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
setAuthCode(code);
// Auto submit if dialog is open
if (isAddDialogOpen) {
handleAddAccount(code);
}
// Note: Auto-submit will be triggered by the authCode change effect below
});
return cleanup;
}
}, [isAddDialogOpen]);
}, []);
// Auto-submit when authCode is set and dialog is open
useEffect(() => {
if (authCode && isAddDialogOpen && !addMutation.isPending) {
console.log('[OAuth] Auto-submitting auth code');
handleAddAccount(authCode);
}
}, [authCode, isAddDialogOpen]);
// Batch Operations State
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -103,7 +134,7 @@ export function CloudAccountList() {
onError: (err) =>
toast({
title: t('cloud.toast.switchFailed'),
description: err.message,
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
}),
},
@@ -145,8 +176,15 @@ export function CloudAccountList() {
};
const handleForcePoll = () => {
if (forcePollMutation.isPending) return;
forcePollMutation.mutate(undefined, {
onSuccess: () => toast({ title: t('cloud.polling') }),
onError: (err) =>
toast({
title: t('cloud.toast.pollFailed'),
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
}),
});
};
@@ -169,35 +207,13 @@ export function CloudAccountList() {
onError: (err) => {
toast({
title: t('cloud.toast.syncFailed.title'),
description: err.message,
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
});
},
});
};
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) return;
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: err.message,
variant: 'destructive',
});
},
},
);
};
const openAuthUrl = async () => {
try {
await startAuthFlow();
@@ -264,7 +280,13 @@ export function CloudAccountList() {
}
if (isError) {
return <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
const errorMessage = getLocalizedErrorMessage(error, t);
return (
<div className="p-4 text-red-500">
<div>{t('cloud.error.loadFailed')}</div>
<div className="mt-1 text-sm text-red-400">{errorMessage}</div>
</div>
);
}
return (
+88 -35
View File
@@ -1,21 +1,22 @@
import React from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
isProcessRunning,
startAntigravity,
closeAntigravity,
} from "@/actions/process";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Play, Square, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Play, Square, Loader2, Power } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export const StatusBar: React.FC = () => {
interface StatusBarProps {
isCollapsed?: boolean;
}
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: isRunning, isLoading } = useQuery({
queryKey: ["process", "status"],
queryKey: ['process', 'status'],
queryFn: isProcessRunning,
refetchInterval: 2000,
});
@@ -23,14 +24,14 @@ export const StatusBar: React.FC = () => {
const startMutation = useMutation({
mutationFn: startAntigravity,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
},
});
const stopMutation = useMutation({
mutationFn: closeAntigravity,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
},
});
@@ -44,30 +45,78 @@ export const StatusBar: React.FC = () => {
const isPending = startMutation.isPending || stopMutation.isPending;
// Collapsed View
if (isCollapsed) {
return (
<div className="flex justify-center py-2">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'h-10 w-10 rounded-lg transition-all duration-300',
isRunning
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50',
)}
>
{isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Power className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2">
<div
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')}
/>
<p>
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded View
return (
<div
className={cn(
"flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors",
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
isRunning
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100"
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100",
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50'
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50',
)}
>
<div className="flex items-center gap-2">
<div className="flex flex-1 items-start gap-3">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
) : isRunning ? (
<div className="h-3 w-3 rounded-full bg-green-500" />
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
) : (
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" />
)}
<span>
{isLoading
? t("status.checking")
: isRunning
? t("status.running")
: t("status.stopped")}
</span>
<div className="flex flex-col gap-1 leading-none">
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
<span className="text-sm leading-tight font-medium">
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</span>
</div>
</div>
<Button
variant="ghost"
@@ -75,18 +124,22 @@ export const StatusBar: React.FC = () => {
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
"h-8 px-3 hover:bg-white/20",
isRunning ? "hover:text-green-900" : "hover:text-red-900",
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
isRunning
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50'
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50',
)}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isRunning ? (
<Square className="mr-2 h-4 w-4 fill-current" />
<Square className="h-3.5 w-3.5 fill-current" />
) : (
<Play className="mr-2 h-4 w-4 fill-current" />
<Play className="h-3.5 w-3.5 fill-current" />
)}
{isRunning ? t("action.stop") : t("action.start")}
<span className="ml-2 font-semibold">
{isRunning ? t('action.stop') : t('action.start')}
</span>
</Button>
</div>
);
+4 -4
View File
@@ -1,12 +1,12 @@
import { Link } from "@tanstack/react-router";
import { Link } from '@tanstack/react-router';
import {
NavigationMenu as NavigationMenuBase,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { useTranslation } from "react-i18next";
} from '@/components/ui/navigation-menu';
import { useTranslation } from 'react-i18next';
export default function NavigationMenu() {
const { t } = useTranslation();
@@ -16,7 +16,7 @@ export default function NavigationMenu() {
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">{t("titleHomePage")}</Link>
<Link to="/">{t('titleHomePage')}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
+3 -3
View File
@@ -1,6 +1,6 @@
import { Moon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toggleTheme } from "@/actions/theme";
import { Moon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toggleTheme } from '@/actions/theme';
export default function ToggleTheme() {
return (
+6 -9
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -8,10 +8,7 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
));
@@ -23,7 +20,7 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
@@ -36,7 +33,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"bg-muted flex h-full w-full items-center justify-center rounded-full",
'bg-muted flex h-full w-full items-center justify-center rounded-full',
className,
)}
{...props}
+11 -15
View File
@@ -1,35 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43 -70
View File
@@ -1,82 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"bg-card text-card-foreground rounded-xl border shadow",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
));
CardDescription.displayName = "CardDescription";
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+21 -34
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -19,8 +19,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none",
inset && "pl-8",
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && 'pl-8',
className,
)}
{...props}
@@ -29,8 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -39,14 +38,13 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -57,8 +55,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
@@ -76,8 +74,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
@@ -92,7 +90,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
@@ -106,8 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -116,7 +113,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -139,11 +136,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
@@ -155,24 +148,18 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
+7 -12
View File
@@ -1,22 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
+19 -26
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)}
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
{...props}
/>
));
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitive.Thumb
className={cn(
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
+15 -16
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
@@ -22,25 +22,24 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: "border bg-background text-foreground",
default: 'border bg-background text-foreground',
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
@@ -59,7 +58,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
'hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50',
className,
)}
{...props}
@@ -74,7 +73,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
'text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
@@ -91,7 +90,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
{...props}
/>
));
@@ -103,7 +102,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+23 -25
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast";
import * as React from 'react';
import { Toast, ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
@@ -12,10 +12,10 @@ type ToasterToast = ToastProps & {
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
@@ -29,20 +29,20 @@ type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
@@ -59,7 +59,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
type: 'REMOVE_TOAST',
toastId,
});
}, TOAST_REMOVE_DELAY);
@@ -69,21 +69,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -108,7 +106,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
@@ -133,20 +131,20 @@ function dispatch(action: Action) {
});
}
type Toast = Omit<ToasterToast, "id">;
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: "ADD_TOAST",
type: 'ADD_TOAST',
toast: {
...props,
id,
@@ -180,7 +178,7 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
+91 -9
View File
@@ -1,7 +1,12 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from '@/components/ui/use-toast';
import { ipc } from '@/ipc/manager';
import { AppConfig } from '@/types/config';
import { toast } from '@/components/ui/use-toast';
const SAVE_DEBOUNCE_MS = 400;
export function useAppConfig() {
const queryClient = useQueryClient();
@@ -20,28 +25,105 @@ export function useAppConfig() {
const updateConfig = useMutation({
mutationFn: async (newConfig: AppConfig) => {
await ipc.client.config.save(newConfig);
return newConfig;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
});
const latestConfigRef = useRef<AppConfig | null>(null);
const lastStableConfigRef = useRef<AppConfig | null>(null);
const pendingResolversRef = useRef<
Array<{ resolve: () => void; reject: (error: Error) => void }>
>([]);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (config) {
lastStableConfigRef.current = config;
}
}, [config]);
const flushPendingSave = useCallback(async () => {
const pendingBatch = pendingResolversRef.current.splice(0);
const nextConfig = latestConfigRef.current;
if (!nextConfig) {
for (const item of pendingBatch) {
item.resolve();
}
return;
}
try {
const savedConfig = await updateConfig.mutateAsync(nextConfig);
queryClient.setQueryData(['appConfig'], savedConfig);
lastStableConfigRef.current = savedConfig;
toast({
title: 'Settings saved',
description: 'Your configuration has been updated.',
});
},
onError: (err) => {
for (const item of pendingBatch) {
item.resolve();
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to save settings');
const fallback = lastStableConfigRef.current;
if (fallback) {
queryClient.setQueryData(['appConfig'], fallback);
} else {
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
}
toast({
title: 'Error saving settings',
description: err.message,
description: error.message,
variant: 'destructive',
});
for (const item of pendingBatch) {
item.reject(error);
}
}
}, [queryClient, updateConfig]);
const saveConfig = useCallback(
(newConfig: AppConfig) => {
latestConfigRef.current = newConfig;
queryClient.setQueryData(['appConfig'], newConfig);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
return new Promise<void>((resolve, reject) => {
pendingResolversRef.current.push({ resolve, reject });
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
flushPendingSave();
}, SAVE_DEBOUNCE_MS);
});
},
});
[flushPendingSave, queryClient],
);
useEffect(() => {
const handleBeforeUnload = () => {
if (!debounceTimerRef.current) {
return;
}
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
void flushPendingSave();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
handleBeforeUnload();
};
}, [flushPendingSave]);
return {
config,
isLoading,
error,
saveConfig: updateConfig.mutateAsync,
saveConfig,
isSaving: updateConfig.isPending,
};
}
+6 -2
View File
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
return useMutation({
mutationFn: refreshAccountQuota,
onSuccess: (updatedAccount: CloudAccount) => {
// Optimistically update the list or just invalidate?
// Invalidating is safer as sorting might change (last_used)
// Optimistically update
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
if (!oldData) return [updatedAccount];
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
},
});
}
@@ -97,8 +97,12 @@ export function useSetAutoSwitchEnabled() {
}
export function useForcePollCloudMonitor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forcePollCloudMonitor,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { app } from 'electron';
import * as Sentry from '@sentry/electron/main';
import path from 'path';
import fs from 'fs';
import { getAppDataDir } from './utils/paths';
import { logger } from './utils/logger';
function getQuickConfig() {
try {
const configPath = path.join(getAppDataDir(), 'gui_config.json');
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(content);
// Default to false (privacy by default)
return config.error_reporting_enabled;
}
} catch (e) {
console.error('Failed to read config for Sentry init:', e);
}
return false;
}
if (getQuickConfig()) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: `antigravity-manager@${app.getVersion()}`,
beforeSend(event) {
if (event.exception?.values?.[0]?.value) {
event.exception.values[0].value = event.exception.values[0].value.replace(
/Users\\\\[^\\\\]+/g,
'Users\\\\***',
);
}
return event;
},
});
logger.setErrorReportingEnabled(true);
logger.setSentryReporter((payload) => {
Sentry.withScope((scope) => {
scope.setTag('log_level', payload.level);
scope.setContext('recent_logs', {
entries: payload.logs.map((entry) => ({
timestamp: new Date(entry.timestamp).toISOString(),
level: entry.level,
message: entry.message,
formatted: entry.formatted,
})),
});
scope.setExtra('log_message', payload.message);
if (payload.error) {
Sentry.captureException(payload.error);
return;
}
Sentry.captureMessage(payload.message, 'error');
});
});
} else {
logger.setErrorReportingEnabled(false);
logger.setSentryReporter(null);
}
+4 -9
View File
@@ -1,12 +1,7 @@
import { z } from "zod";
import { os } from "@orpc/server";
import {
listAccountsData,
addAccountSnapshot,
switchAccount,
deleteAccount,
} from "./handler";
import { AccountSchema } from "../../types/account";
import { z } from 'zod';
import { os } from '@orpc/server';
import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
import { AccountSchema } from '../../types/account';
export const accountRouter = os.router({
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { os } from "@orpc/server";
import { app } from "electron";
import { os } from '@orpc/server';
import { app } from 'electron';
export const currentPlatfom = os.handler(() => {
return process.platform;
+3 -1
View File
@@ -20,11 +20,13 @@ export class AuthServer {
const error = url.searchParams.get('error');
if (code) {
logger.info('AuthServer: Received authorization code');
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
// Send code to renderer
if (ipcContext.mainWindow) {
logger.info('AuthServer: Sending code to renderer via IPC');
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
logger.info('AuthServer: Code sent successfully');
} else {
logger.error('AuthServer: Main window not found, cannot send code');
}
+4
View File
@@ -123,6 +123,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
notifyTrayUpdate(account);
return account;
} catch (error: any) {
@@ -143,6 +145,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
return account;
} catch (refreshError) {
logger.error(
+9 -2
View File
@@ -1,13 +1,20 @@
import { AppConfig } from '../../types/config';
import { ConfigManager } from './manager';
import { syncAutoStart } from '../../utils/autoStart';
import { logger } from '../../utils/logger';
export function loadConfig(): AppConfig {
return ConfigManager.loadConfig();
}
export function saveConfig(config: AppConfig): void {
export async function saveConfig(config: AppConfig): Promise<void> {
// Logic to notify proxy server if configuration changes (hot update)
// Logic to update Tray if language changes
// For now just save
ConfigManager.saveConfig(config);
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
await ConfigManager.saveConfig(config);
logger.setErrorReportingEnabled(config.error_reporting_enabled);
if (previous.auto_startup !== config.auto_startup) {
syncAutoStart(config);
}
}
+29 -12
View File
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
const CONFIG_FILENAME = 'gui_config.json';
export class ConfigManager {
private static cachedConfig: AppConfig | null = null;
private static saveQueue: Promise<void> = Promise.resolve();
private static getConfigPath(): string {
const appDataDir = getAppDataDir();
if (!fs.existsSync(appDataDir)) {
@@ -20,6 +23,7 @@ export class ConfigManager {
const configPath = this.getConfigPath();
if (!fs.existsSync(configPath)) {
logger.info(`Config: File not found at ${configPath}, returning default`);
this.cachedConfig = DEFAULT_APP_CONFIG;
return DEFAULT_APP_CONFIG;
}
@@ -28,7 +32,7 @@ export class ConfigManager {
// Merge with default to ensure new fields are present
// Zod parse helps validate
const merged = {
const merged: AppConfig = {
...DEFAULT_APP_CONFIG,
...raw,
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
@@ -45,22 +49,35 @@ export class ConfigManager {
// Handle Anthropic Mapping Map vs Object
// In JSON it's object
return merged as AppConfig;
this.cachedConfig = merged;
return merged;
} catch (e) {
logger.error('Config: Failed to load config', e);
this.cachedConfig = DEFAULT_APP_CONFIG;
return DEFAULT_APP_CONFIG;
}
}
static saveConfig(config: AppConfig): void {
try {
const configPath = this.getConfigPath();
const content = JSON.stringify(config, null, 2);
fs.writeFileSync(configPath, content, 'utf-8');
logger.info(`Config: Saved to ${configPath}`);
} catch (e) {
logger.error('Config: Failed to save config', e);
throw e;
}
static getCachedConfig(): AppConfig | null {
return this.cachedConfig;
}
static async saveConfig(config: AppConfig): Promise<void> {
const configPath = this.getConfigPath();
const content = JSON.stringify(config, null, 2);
this.saveQueue = this.saveQueue
.catch(() => undefined)
.then(async () => {
await fs.promises.writeFile(configPath, content, 'utf-8');
this.cachedConfig = config;
logger.info(`Config: Saved to ${configPath}`);
})
.catch((e) => {
logger.error('Config: Failed to save config', e);
throw e;
});
return this.saveQueue;
}
}
+1 -1
View File
@@ -12,6 +12,6 @@ export const configRouter = os.router({
.input(AppConfigSchema)
.output(z.void())
.handler(async ({ input }) => {
saveConfig(input);
await saveConfig(input);
}),
});
+184 -27
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
import { logger } from '../../utils/logger';
import { CloudAccount } from '../../types/cloudAccount';
import { encrypt, decrypt } from '../../utils/security';
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
import { ProtobufUtils } from '../../utils/protobuf';
import { GoogleAPIService } from '../../services/GoogleAPIService';
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null;
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Create accounts table
// Storing complex objects (token, quota) as JSON strings for simplicity
@@ -73,9 +74,70 @@ function ensureDatabaseInitialized(dbPath: string): void {
function getDb(): Database.Database {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
return new Database(dbPath);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
return db;
}
interface MigrationStats {
totalFields: number;
fallbackUsedFields: number;
migratedFields: number;
migratedBySource: Record<KeySource, number>;
failedFields: number;
}
function createMigrationStats(): MigrationStats {
return {
totalFields: 0,
fallbackUsedFields: 0,
migratedFields: 0,
migratedBySource: {
safeStorage: 0,
keytar: 0,
file: 0,
},
failedFields: 0,
};
}
async function decryptAndMigrateField(
db: Database.Database,
accountId: string,
field: 'token_json' | 'quota_json',
value: string | null,
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
if (!value) {
return { value: null, migrated: false };
}
const result = await decryptWithMigration(value);
if (result.reencrypted) {
if (field === 'token_json') {
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
} else {
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
}
logger.info(
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
);
}
return {
value: result.value,
migrated: Boolean(result.reencrypted),
usedFallback: result.usedFallback,
};
}
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
export class CloudAccountRepo {
static async init(): Promise<void> {
const dbPath = getCloudAccountsDbPath();
@@ -168,6 +230,7 @@ export class CloudAccountRepo {
static async getAccounts(): Promise<CloudAccount[]> {
const db = getDb();
const migrationStats = createMigrationStats();
try {
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
@@ -180,24 +243,91 @@ export class CloudAccountRepo {
);
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
const accounts = await Promise.all(
rows.map(async (row) => ({
const accounts: CloudAccount[] = [];
for (const row of rows) {
let tokenResult: DecryptFieldResult;
try {
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt token for account ${row.id}`, error);
throw error;
}
let quotaResult: DecryptFieldResult;
try {
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt quota for account ${row.id}`, error);
throw error;
}
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
if (tokenResult.value) {
migrationStats.totalFields += 1;
}
if (tokenResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (tokenResult.migrated) {
migrationStats.migratedFields += 1;
if (tokenResult.usedFallback) {
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
}
}
if (quotaResult.value) {
migrationStats.totalFields += 1;
}
if (quotaResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (quotaResult.migrated) {
migrationStats.migratedFields += 1;
if (quotaResult.usedFallback) {
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
}
}
accounts.push({
id: row.id,
provider: row.provider,
email: row.email,
name: row.name,
avatar_url: row.avatar_url,
token: JSON.parse(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
created_at: row.created_at,
last_used: row.last_used,
status: row.status,
is_active: Boolean(row.is_active),
})),
);
});
}
return accounts;
} finally {
if (
migrationStats.migratedFields > 0 ||
migrationStats.fallbackUsedFields > 0 ||
migrationStats.failedFields > 0
) {
const summary = {
totalFields: migrationStats.totalFields,
fallbackUsedFields: migrationStats.fallbackUsedFields,
migratedFields: migrationStats.migratedFields,
migratedBySource: migrationStats.migratedBySource,
failedFields: migrationStats.failedFields,
};
if (migrationStats.failedFields > 0) {
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
} else {
logger.info('CloudAccountRepo migration summary', summary);
}
}
db.close();
}
}
@@ -211,14 +341,21 @@ export class CloudAccountRepo {
if (!row) return undefined;
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
return {
id: row.id,
provider: row.provider,
email: row.email,
name: row.name,
avatar_url: row.avatar_url,
token: JSON.parse(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
created_at: row.created_at,
last_used: row.last_used,
status: row.status,
@@ -293,27 +430,49 @@ export class CloudAccountRepo {
static injectCloudToken(account: CloudAccount): void {
const dbPaths = getAntigravityDbPaths();
let dbPath: string | null = null;
for (const p of dbPaths) {
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
if (!dbPath) {
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
try {
const row = db
.prepare('SELECT value FROM ItemTable WHERE key = ?')
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
if (!row || !row.value) {
throw new Error('jetskiStateSync.agentManagerInitState not found in database');
logger.warn(
'jetskiStateSync.agentManagerInitState not found. ' +
'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.',
);
// Inject minimal state: auth status and onboarding flag only
const authStatus = {
name: account.name || account.email,
email: account.email,
apiKey: account.token.access_token,
};
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityAuthStatus',
JSON.stringify(authStatus),
);
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityOnboarding',
'true',
);
db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity');
logger.info(
`Injected minimal auth state for ${account.email} (no protobuf state available)`,
);
return; // Early return - skip protobuf manipulation
}
// 1. Decode Base64
@@ -409,14 +568,11 @@ export class CloudAccountRepo {
const dbPaths = getAntigravityDbPaths();
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
let dbPath: string | null = null;
for (const p of dbPaths) {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
const dbPath =
dbPaths.find((p) => {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
return fs.existsSync(p);
}) ?? null;
if (!dbPath) {
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
@@ -426,6 +582,7 @@ export class CloudAccountRepo {
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
const ideDb = new Database(dbPath, { readonly: true });
ideDb.pragma('journal_mode = WAL');
try {
// 1. Read Raw Token Data
const row = ideDb
+32 -1
View File
@@ -8,6 +8,31 @@ import path from 'path';
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database {
const db = new Database(dbPath, options);
db.pragma('journal_mode = WAL');
return db;
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getAntigravityDbPaths();
if (dbPaths.length === 0) {
return;
}
const db = getDatabaseConnection();
db.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/**
* Ensures that the database file exists.
* @param dbPath {string} The path to the database file.
@@ -59,7 +84,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
ensureDatabaseExists(targetPath);
try {
return new Database(targetPath, { readonly: false, fileMustExist: false });
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
} catch (error: unknown) {
const err = error as { code?: string };
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
@@ -69,6 +94,12 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
}
}
/**
* Gets a database connection and enables WAL mode.
* @param dbPath {string} The path to the database file.
* @returns {Database.Database} The database connection.
*/
/**
* Gets the current account info.
* @returns {AccountInfo} The current account info.
+4 -12
View File
@@ -1,15 +1,7 @@
import { z } from "zod";
import { os } from "@orpc/server";
import {
backupAccount,
restoreAccount,
getCurrentAccountInfo,
} from "./handler";
import {
AccountBackupDataSchema,
AccountInfoSchema,
AccountSchema,
} from "../../types/account";
import { z } from 'zod';
import { os } from '@orpc/server';
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
export const databaseRouter = os.router({
backupAccount: os
+1 -1
View File
@@ -53,7 +53,7 @@ export const generateApiKey = async (): Promise<string> => {
// Save to config
const config = ConfigManager.loadConfig();
config.proxy.api_key = newKey;
ConfigManager.saveConfig(config);
await ConfigManager.saveConfig(config);
return newKey;
};
+1 -1
View File
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
pendingRequests.delete(id);
reject(new Error(`Request /${methodPath} timed out`));
}
}, 10000);
}, 60000);
});
}
+147 -40
View File
@@ -1,63 +1,166 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import findProcess, { ProcessInfo } from 'find-process';
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
import { logger } from '../../utils/logger';
const execAsync = promisify(exec);
/**
* Helper process name patterns to exclude (Electron helper processes)
*/
const HELPER_PATTERNS = [
'helper',
'plugin',
'renderer',
'gpu',
'crashpad',
'utility',
'audio',
'sandbox',
'language_server',
];
/**
* Check if a process is a helper/auxiliary process that should be excluded.
* @param name Process name (lowercase)
* @param cmd Process command line (lowercase)
* @returns True if the process is a helper process
*/
function isHelperProcess(name: string, cmd: string): boolean {
const nameLower = name.toLowerCase();
const cmdLower = cmd.toLowerCase();
// Check for --type= argument (Electron helper process indicator)
if (cmdLower.includes('--type=')) {
return true;
}
// Check for helper patterns in process name
for (const pattern of HELPER_PATTERNS) {
if (nameLower.includes(pattern)) {
return true;
}
}
// Check for crashpad in path
if (cmdLower.includes('crashpad')) {
return true;
}
return false;
}
function isPgrepNoMatchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
const code = (error as { code?: number }).code;
return hasPgrep && code === 1;
}
/**
* Checks if the Antigravity process is running.
* Uses find-process package for robust cross-platform process detection.
* @returns {boolean} True if the Antigravity process is running, false otherwise.
*/
export async function isProcessRunning(): Promise<boolean> {
try {
const platform = process.platform;
let command = '';
const currentPid = process.pid;
if (platform === 'win32' || isWsl()) {
// Use absolute path for tasklist to be safe on Windows too
const cmd = isWsl()
? '/mnt/c/Windows/System32/tasklist.exe'
: process.env.SystemRoot // Usually C:\Windows
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
: 'tasklist';
// Use find-process to search for Antigravity processes
// 'name' search type matches process name
const processMap = new Map<number, ProcessInfo>();
const searchNames = ['Antigravity', 'antigravity'];
let sawNoMatch = false;
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
} else {
// macOS and Linux (native)
command = 'pgrep -x Antigravity';
for (const searchName of searchNames) {
try {
const matches = await findProcess('name', searchName, true);
for (const proc of matches) {
if (typeof proc.pid === 'number') {
processMap.set(proc.pid, proc);
}
}
} catch (error) {
if (isPgrepNoMatchError(error)) {
sawNoMatch = true;
continue;
}
throw error;
}
}
logger.debug(`Checking process with command: ${command}`);
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
try {
const { stdout } = await execAsync(command, { timeout: 4000 });
logger.debug(`Process check stdout: ${stdout.trim()}`);
const processes = Array.from(processMap.values());
if (processes.length === 0 && sawNoMatch) {
logger.debug('No Antigravity process found (pgrep returned 1)');
}
if (platform === 'win32' || isWsl()) {
return stdout.includes('Antigravity.exe');
} else {
// pgrep returns PIDs if found, empty if not
return stdout.trim().length > 0;
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
for (const proc of processes) {
// Skip self
if (proc.pid === currentPid) {
continue;
}
} catch (cmdError) {
if (platform === 'win32' && !isWsl()) {
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
// Fallback to wmic (filtered)
try {
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
return stdout.includes('ProcessId');
} catch (wmicError) {
logger.error('wmic fallback also failed', wmicError);
return false;
const name = proc.name?.toLowerCase() || '';
const cmd = proc.cmd?.toLowerCase() || '';
// Skip manager process
if (
name.includes('manager') ||
cmd.includes('manager') ||
cmd.includes('antigravity-manager')
) {
continue;
}
// Skip helper processes
if (isHelperProcess(name, cmd)) {
continue;
}
if (platform === 'darwin') {
// macOS: Check for Antigravity.app in path
if (cmd.includes('antigravity.app')) {
logger.debug(
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
// Also check if the process name is exactly 'Antigravity' (main process)
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
return true;
}
} else if (platform === 'win32') {
// Windows: Check for Antigravity.exe
if (name === 'antigravity.exe' || name === 'antigravity') {
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
return true;
}
} else {
// Linux: Check for antigravity in name or path (but not tools)
if (
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
!name.includes('tools')
) {
logger.debug(
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
}
throw cmdError;
}
return false;
} catch (error) {
logger.error('Error checking process status:', error);
// pgrep returns exit code 1 if no process found
logger.error('Error checking process status with find-process:', error);
return false;
}
}
@@ -189,18 +292,22 @@ export async function closeAntigravity(): Promise<void> {
if (p.pid === currentPid) {
return false;
}
// Exclude this electron app (if named Antigravity Manager)
if (p.cmd.includes('Antigravity Manager')) {
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
return false;
}
// Match Antigravity
// Match Antigravity (but not manager)
if (platform === 'win32') {
return (
p.cmd.includes('Antigravity.exe') ||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
);
} else {
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity');
// Explicit !manager check for Linux/macOS to be defensive
return (
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
!p.cmd.includes('manager')
);
}
});
+8
View File
@@ -1,6 +1,8 @@
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
import { shell } from 'electron';
import { getAgentDir } from '../../utils/paths';
// Schema for IP info
const IpInfoSchema = z.object({
@@ -61,4 +63,10 @@ export const systemHandler = os.router({
return results;
}),
// Open log directory in file explorer
openLogDirectory: os.output(z.void()).handler(async () => {
const logDir = getAgentDir();
await shell.openPath(logDir);
}),
});
+19 -21
View File
@@ -1,6 +1,6 @@
import { os } from "@orpc/server";
import { nativeTheme } from "electron";
import { setThemeModeInputSchema } from "./schemas";
import { os } from '@orpc/server';
import { nativeTheme } from 'electron';
import { setThemeModeInputSchema } from './schemas';
export const getCurrentThemeMode = os.handler(() => {
return nativeTheme.themeSource;
@@ -8,28 +8,26 @@ export const getCurrentThemeMode = os.handler(() => {
export const toggleThemeMode = os.handler(() => {
if (nativeTheme.shouldUseDarkColors) {
nativeTheme.themeSource = "light";
nativeTheme.themeSource = 'light';
} else {
nativeTheme.themeSource = "dark";
nativeTheme.themeSource = 'dark';
}
return nativeTheme.shouldUseDarkColors;
});
export const setThemeMode = os
.input(setThemeModeInputSchema)
.handler(({ input: mode }) => {
switch (mode) {
case "light":
nativeTheme.themeSource = "light";
break;
case "dark":
nativeTheme.themeSource = "dark";
break;
case "system":
nativeTheme.themeSource = "system";
break;
}
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
switch (mode) {
case 'light':
nativeTheme.themeSource = 'light';
break;
case 'dark':
nativeTheme.themeSource = 'dark';
break;
case 'system':
nativeTheme.themeSource = 'system';
break;
}
return nativeTheme.themeSource;
});
return nativeTheme.themeSource;
});
+2 -2
View File
@@ -1,3 +1,3 @@
import { z } from "zod";
import { z } from 'zod';
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]);
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
+37 -1
View File
@@ -38,12 +38,36 @@ function getQuotaText(account: CloudAccount | null, texts: any): string[] {
export function initTray(mainWindow: BrowserWindow) {
globalMainWindow = mainWindow;
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy existing tray', e);
}
tray = null;
logger.info('Destroyed existing tray before creating new one');
}
const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets/tray.png');
: path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
const icon = nativeImage.createFromPath(iconPath);
// Verify icon is valid before creating tray
if (icon.isEmpty()) {
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
return;
}
tray = new Tray(icon);
tray.setToolTip('Antigravity Manager');
@@ -158,3 +182,15 @@ export function updateTrayMenu(account: CloudAccount | null, language?: string)
export function setTrayLanguage(lang: string) {
updateTrayMenu(lastAccount, lang);
}
export function destroyTray() {
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy tray', e);
}
tray = null;
logger.info('Tray destroyed');
}
}
+20 -27
View File
@@ -1,33 +1,26 @@
import { os } from "@orpc/server";
import { ipcContext } from "../context";
import { logger } from "../../utils/logger";
import { os } from '@orpc/server';
import { ipcContext } from '../context';
import { logger } from '../../utils/logger';
export const minimizeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context;
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
const { window } = context;
window.minimize();
});
window.minimize();
});
export const maximizeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context;
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
const { window } = context;
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
});
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
});
export const closeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
logger.info('IPC: closeWindow called');
const { window } = context;
window.close();
});
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
logger.info('IPC: closeWindow called');
const { window } = context;
window.close();
});
+107 -27
View File
@@ -1,14 +1,34 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Link, Outlet, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { StatusBar } from '@/components/StatusBar';
import { LayoutDashboard, Settings, Network, Rocket } from 'lucide-react';
import {
LayoutDashboard,
Settings,
Network,
Rocket,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export const MainLayout: React.FC = () => {
const location = useLocation();
const { t } = useTranslation();
// Initialize state from localStorage if available, default to false (expanded)
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = localStorage.getItem('sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
// Persist state changes
useEffect(() => {
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
}, [isCollapsed]);
const navItems = [
{
to: '/',
@@ -31,42 +51,102 @@ export const MainLayout: React.FC = () => {
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<aside className="bg-muted/10 flex w-64 flex-col border-r">
<div className="p-6">
<h1 className="flex items-center gap-2 text-xl font-bold tracking-tight">
<div className="bg-primary text-primary-foreground flex h-6 w-6 items-center justify-center rounded">
<aside
className={cn(
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out',
isCollapsed ? 'w-[70px]' : 'w-64',
)}
>
<Button
variant="ghost"
size="icon"
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3" />
) : (
<ChevronLeft className="h-3 w-3" />
)}
</Button>
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
<div className="bg-primary text-primary-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded">
<Rocket className="h-4 w-4" />
</div>
Antigravity
</h1>
<p className="text-muted-foreground mt-1 text-xs">Manager</p>
</div>
<nav className="flex-1 space-y-2 px-4">
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
<div
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
location.pathname === item.to
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
'overflow-hidden transition-all duration-300',
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
<h1 className="text-xl font-bold tracking-tight">Antigravity</h1>
</div>
</div>
<div
className={cn(
'text-muted-foreground mt-1 overflow-hidden text-xs whitespace-nowrap transition-all duration-300',
isCollapsed ? 'h-0 opacity-0' : 'h-auto opacity-100',
)}
>
Manager
</div>
</div>
<nav className="flex-1 space-y-2 px-2">
<TooltipProvider>
{navItems.map((item) => {
const isActive = location.pathname === item.to;
if (isCollapsed) {
return (
<Tooltip key={item.to} delayDuration={0}>
<TooltipTrigger asChild>
<Link
to={item.to}
className={cn(
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
)}
>
<item.icon className="h-5 w-5" />
<span className="sr-only">{item.label}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</TooltipProvider>
</nav>
<div className="border-t p-4">
<StatusBar />
<div className="border-t p-2">
<StatusBar isCollapsed={isCollapsed} />
</div>
</aside>
{/* Content Area */}
<main className="flex-1 overflow-auto">
<main className="flex-1 overflow-auto transition-all duration-300">
<Outlet />
</main>
</div>
+3 -3
View File
@@ -51,7 +51,7 @@ export function transformClaudeRequestIn(
const toolIdToName = new Map<string, string>();
// 1. System Instruction
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
const systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
// Map model name
const mappedModel = hasWebSearchTool
@@ -100,7 +100,7 @@ export function transformClaudeRequestIn(
);
// 3. Tools
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
// 5. Safety Settings
const safetySettings = [
@@ -152,7 +152,7 @@ export function transformClaudeRequestIn(
delete innerRequest.systemInstruction;
// 3. Clean generationConfig
let genConfig = innerRequest.generationConfig || {};
const genConfig = innerRequest.generationConfig || {};
delete genConfig.thinkingConfig;
delete genConfig.responseMimeType;
delete genConfig.responseModalities;
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Usage,
GroundingMetadata,
} from './types';
import { decodeSignature } from './signature-utils';
/**
* Non-streaming response processor (Gemini -> Claude)
@@ -55,7 +56,7 @@ class NonStreamingProcessor {
}
private processPart(part: GeminiPart) {
const signature = part.thoughtSignature || null;
const signature = decodeSignature(part.thoughtSignature) || null;
// 1. Handle FunctionCall
if (part.functionCall) {
+55 -2
View File
@@ -1,5 +1,6 @@
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
import { GeminiPart, Usage, UsageMetadata } from './types';
import { SignatureStore } from './SignatureStore';
import { decodeSignature } from './signature-utils';
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
@@ -43,6 +44,8 @@ export class StreamingState {
public webSearchQuery: string | null = null;
public groundingChunks: any[] | null = null;
private parseErrorCount: number = 0;
constructor() {}
public emit(eventType: string, data: any): string {
@@ -242,6 +245,56 @@ export class StreamingState {
public storeSignature(signature?: string) {
this.signatures.store(signature);
}
public handleParseError(rawData: string): string[] {
const chunks: string[] = [];
this.parseErrorCount++;
console.warn(
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
);
// Safely close current block
if (this.blockType !== 'None') {
chunks.push(...this.endBlock());
}
// Emit error event if too many errors
if (this.parseErrorCount > 3) {
console.error(
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
);
chunks.push(
this.emit('error', {
type: 'error',
error: {
type: 'network_error',
message: 'Unstable network connection. Please check your network or proxy settings.',
code: 'stream_decode_error',
details: {
error_count: this.parseErrorCount,
suggestion: 'Check network connection',
},
},
}),
);
}
return chunks;
}
/**
* Reset error state (call after recovery)
*/
public resetErrorState(): void {
this.parseErrorCount = 0;
}
/**
* Get current error count (for monitoring)
*/
public getErrorCount(): number {
return this.parseErrorCount;
}
}
/**
@@ -252,7 +305,7 @@ export class PartProcessor {
public process(part: GeminiPart): string[] {
const chunks: string[] = [];
const signature = part.thoughtSignature || undefined;
const signature = decodeSignature(part.thoughtSignature);
// 1. Handle FunctionCall
if (part.functionCall) {
+2 -2
View File
@@ -67,7 +67,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
// Recursively process all children
for (const k in map) {
if (map.hasOwnProperty(k)) {
if (Object.prototype.hasOwnProperty.call(map, k)) {
const v = map[k];
if (typeof v === 'object' && v !== null) {
flattenRefs(v, defs);
@@ -91,7 +91,7 @@ function cleanJsonSchemaRecursive(value: any) {
// 1. Recursively process all children first to ensure nested structures are cleaned
for (const k in map) {
if (map.hasOwnProperty(k)) {
if (Object.prototype.hasOwnProperty.call(map, k)) {
cleanJsonSchemaRecursive(map[k]);
}
}
+6 -1
View File
@@ -126,7 +126,12 @@ export class QuotaService {
} catch (error: any) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const text = JSON.stringify(error.response?.data || '');
let text = '';
try {
text = JSON.stringify(error.response?.data || '');
} catch {
text = '[Unable to serialize response data]';
}
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
if (status === 403) {
+49
View File
@@ -0,0 +1,49 @@
/** Jitter factor: ±20% */
const JITTER_FACTOR = 0.2;
/** Maximum retry delay in milliseconds (30 seconds) */
const MAX_DELAY_MS = 30000;
/**
* Apply jitter to a delay value to prevent thundering herd
* Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms)
*
* @param delayMs - Base delay in milliseconds
* @returns Jittered delay (minimum 1ms)
*/
export function applyJitter(delayMs: number): number {
if (delayMs <= 0) {
return 1;
}
const jitterRange = delayMs * JITTER_FACTOR;
const jitter = (Math.random() * 2 - 1) * jitterRange;
return Math.max(1, Math.round(delayMs + jitter));
}
/**
* Calculate retry delay with exponential backoff and jitter
*
* @param attempt - Current attempt number (0-indexed)
* @param baseMs - Base delay for first retry (default 1000ms)
* @returns Delay in milliseconds with jitter applied
*/
export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number {
// Exponential backoff: 1s, 2s, 4s, 8s, ...
const exponentialDelay = baseMs * Math.pow(2, attempt);
// Cap at maximum delay
const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);
// Apply jitter
return applyJitter(cappedDelay);
}
/**
* Sleep for specified milliseconds
*
* @param ms - Milliseconds to sleep
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+32
View File
@@ -0,0 +1,32 @@
import { isEmpty } from 'lodash-es';
/**
* Decode Base64-encoded signature if present
* Gemini sends Base64, Claude expects raw string
*
* @param signature - The signature string (may be Base64 or raw)
* @returns Decoded signature or undefined if input is empty
*/
export function decodeSignature(signature: string | undefined | null): string | undefined {
if (isEmpty(signature)) {
return undefined;
}
try {
const decoded = Buffer.from(signature!, 'base64').toString('utf-8');
// Validate: Check if it looks like valid UTF-8 text
// If decoding produces replacement characters, it wasn't valid Base64
if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) {
// Additional check: re-encode and compare to detect false positives
const reEncoded = Buffer.from(decoded).toString('base64');
if (reEncoded === signature) {
return decoded;
}
}
} catch {
// Not valid Base64, return as-is
}
// Return original if not Base64
return signature!;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Stream Error Utilities
* Classifies network errors and formats user-friendly messages
*
* Problem: Technical error messages confuse users
* Solution: Classify errors and provide localized, actionable messages
*/
export interface ClassifiedError {
type: string;
message: string;
}
/**
* Classify a stream error and return user-friendly message
*
* @param error - The error object
* @returns Classified error with type and localized message
*/
export function classifyStreamError(error: Error): ClassifiedError {
const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('timedout')) {
return {
type: 'timeout_error',
message: 'Request timed out. Please check your network connection or try again later.',
};
}
if (msg.includes('econnrefused') || msg.includes('connect')) {
return {
type: 'connection_error',
message: 'Unable to connect to server. Please check your network.',
};
}
if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) {
return {
type: 'network_error',
message: 'Network connection failed. Please check your network settings.',
};
}
if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) {
return {
type: 'stream_error',
message: 'Data transfer interrupted. Please try again later.',
};
}
return {
type: 'unknown_error',
message: `Unknown error occurred: ${error.message.substring(0, 100)}`,
};
}
/**
* Format error as SSE event for Claude protocol
*
* @param errorType - Error type string
* @param message - User-friendly message
* @returns SSE-formatted error event string
*/
export function formatErrorForSSE(errorType: string, message: string): string {
const errorEvent = {
type: 'error',
error: {
type: errorType,
message: message,
code: 'stream_error',
},
};
return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Thinking Block Utilities
* Handles validation and graceful degradation of thinking blocks
*
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
* Solution: Validate and convert invalid blocks to text instead of dropping
*/
/** Minimum length for a valid thought signature */
const MIN_SIGNATURE_LENGTH = 10;
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
}
export interface TextBlock {
type: 'text';
text: string;
}
export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any };
/**
* Check if a thinking block has a valid signature
*
* Rules:
* - Empty thinking + any signature = valid (trailing signature case)
* - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid
* - Non-empty thinking + no/short signature = invalid
* - Non-thinking blocks = always valid
*
* @param block - Content block to validate
* @returns true if block is valid or not a thinking block
*/
export function hasValidSignature(block: ContentBlock): boolean {
if (block.type !== 'thinking') {
return true; // Non-thinking blocks are always valid
}
const thinkingBlock = block as ThinkingBlock;
// Empty thinking + any signature = valid (trailing signature)
if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) {
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
// Non-empty thinking needs a long enough signature
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
/**
* Gracefully degrade an invalid thinking block
*
* - Valid blocks: return as-is
* - Invalid with content: convert to text block
* - Invalid without content: return null (should be dropped)
*
* @param block - Content block to process
* @returns Processed block or null if should be dropped
*/
export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null {
if (block.type !== 'thinking') {
return block;
}
const thinkingBlock = block as ThinkingBlock;
if (hasValidSignature(block)) {
return block;
}
// Invalid signature - check if we have content to preserve
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
// Convert to text block to preserve content
console.info(
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
);
return {
type: 'text',
text: thinkingBlock.thinking,
};
}
// Empty content + invalid signature = drop
console.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
return null;
}
/**
* Process an array of content blocks, applying graceful degradation
*
* @param blocks - Array of content blocks
* @returns Processed array with invalid thinking blocks handled
*/
export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] {
const result: ContentBlock[] = [];
for (const block of blocks) {
const processed = gracefulDegradeThinking(block);
if (processed !== null) {
result.push(processed);
}
}
return result;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
+391 -1
View File
@@ -12,7 +12,7 @@ i18n
caches: ['localStorage'],
lookupLocalStorage: 'lang',
},
supportedLngs: ['en', 'zh-CN'],
supportedLngs: ['en', 'zh-CN', 'ru'],
load: 'currentOnly', // Only load the exact language code, not variants
resources: {
en: {
@@ -30,9 +30,23 @@ i18n
deleteBackup: 'Delete Backup',
backupCurrent: 'Backup Current',
retry: 'Retry',
openLogs: 'Open Log Directory',
},
error: {
generic: 'An unexpected error occurred.',
keychainUnavailable: 'Keychain is unavailable.',
keychainHint: {
translocation:
'Detected macOS App Translocation. Move the app to /Applications and reopen.',
keychainDenied:
'Keychain access denied. The app may be unsigned; see README for the self-signing workaround.',
signNotarize: 'Please use a signed and notarized build when available.',
},
dataMigrationFailed: 'Unable to decrypt legacy account data.',
dataMigrationHint: {
relogin: 'Please re-login or re-add your accounts.',
clearData: 'If the issue persists, clear local account data and sign in again.',
},
},
nav: {
accounts: 'Accounts',
@@ -69,6 +83,7 @@ i18n
description: 'Select your preferred language.',
english: 'English',
chinese: 'Chinese (Simplified)',
russian: 'Russian',
},
about: {
title: 'About',
@@ -77,6 +92,7 @@ i18n
version: 'Version',
platform: 'Platform',
license: 'License',
openLogDir: 'Open',
account: {
title: 'Account Settings',
description: 'Configure automatic account refresh and sync.',
@@ -85,6 +101,23 @@ i18n
auto_sync: 'Auto Sync Current Account',
auto_sync_desc: 'Periodically sync active account information',
},
startup: {
title: 'Startup',
description: 'Control application launch behavior at system startup.',
auto_startup: 'Start with system',
auto_startup_desc: 'Launch at sign-in and keep the app in the system tray',
macos_hint:
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
},
privacy: {
title: 'Privacy',
description: 'Control how your data is used to improve the application.',
error_reporting: 'Error Reporting',
error_reporting_desc:
'Send anonymous error reports to help us improve the app. No personal data is collected.',
restart_note:
'Changes to error reporting will take effect after restarting the application.',
},
proxy: {
title: 'Upstream Proxy',
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
@@ -212,6 +245,9 @@ i18n
list: {
noAccounts: 'No cloud accounts added yet.',
},
error: {
loadFailed: 'Failed to load cloud accounts.',
},
toast: {
syncSuccess: {
title: 'Sync Successful',
@@ -227,6 +263,7 @@ i18n
},
quotaRefreshed: 'Quota refreshed',
refreshFailed: 'Failed to refresh quota',
pollFailed: 'Failed to poll quota for all accounts',
switched: {
title: 'Account switched!',
description: 'Restarting Antigravity...',
@@ -275,6 +312,10 @@ i18n
api_key: 'API Key',
auto_start: 'Auto Start with App',
auto_start_desc: 'Start proxy service when application launches',
local_access: 'Local network access:',
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
show_key: 'Show',
hide_key: 'Hide',
},
mapping: {
title: 'Model Mapping',
@@ -304,9 +345,23 @@ i18n
deleteBackup: '删除备份',
backupCurrent: '备份当前账号',
retry: '重试',
openLogs: '打开日志目录',
},
error: {
generic: '发生未知错误。',
keychainUnavailable: 'Keychain 不可用。',
keychainHint: {
translocation:
'检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
keychainDenied:
'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
signNotarize: '请在可用时使用已签名并公证的版本。',
},
dataMigrationFailed: '旧版账号数据无法解密。',
dataMigrationHint: {
relogin: '请重新登录或重新添加账号。',
clearData: '如仍失败,请清理本地账号数据后再登录。',
},
},
nav: {
accounts: '账号',
@@ -343,6 +398,7 @@ i18n
description: '选择您的首选语言。',
english: '英语',
chinese: '中文 (简体)',
russian: '俄语',
},
about: {
title: '关于',
@@ -351,6 +407,7 @@ i18n
version: '版本',
platform: '平台',
license: '许可证',
openLogDir: '打开',
account: {
title: '账号设置',
description: '配置自动刷新和同步账号。',
@@ -359,6 +416,22 @@ i18n
auto_sync: '自动获取当前账号',
auto_sync_desc: '定期自动同步当前活跃账号信息',
},
startup: {
title: '启动',
description: '控制系统启动时的应用行为。',
auto_startup: '随系统启动',
auto_startup_desc: '登录时自动启动并保持在托盘里',
macos_hint:
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
},
privacy: {
title: '隐私',
description: '控制你的数据如何用于改进应用。',
error_reporting: '错误上报',
error_reporting_desc:
'发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
restart_note: '错误上报设置会在重启应用后生效。',
},
proxy: {
title: '上游代理',
description: '为发往 Google/Gemini API 的请求配置代理。',
@@ -484,6 +557,9 @@ i18n
list: {
noAccounts: '暂无云账号。',
},
error: {
loadFailed: '加载云账号失败。',
},
toast: {
syncSuccess: {
title: '同步成功',
@@ -499,6 +575,7 @@ i18n
},
quotaRefreshed: '配额已刷新',
refreshFailed: '刷新配额失败',
pollFailed: '轮询所有账号配额失败',
switched: {
title: '账号已切换!',
description: '正在重启 Antigravity...',
@@ -564,5 +641,318 @@ i18n
},
},
},
ru: {
translation: {
appName: 'Antigravity Manager',
status: {
checking: 'Проверка статуса...',
running: 'Antigravity работает в фоне',
stopped: 'Служба Antigravity остановлена',
},
action: {
stop: 'Стоп',
start: 'Старт',
switch: 'Переключить',
deleteBackup: 'Удалить резервную копию',
backupCurrent: 'Создать бэкап текущего',
retry: 'Повторить',
openLogs: 'Открыть папку логов',
},
error: {
generic: 'Произошла непредвиденная ошибка.',
keychainUnavailable: 'Keychain недоступен.',
keychainHint: {
translocation:
'Обнаружено App Translocation (macOS). Переместите приложение в /Applications и откройте заново.',
keychainDenied:
'Доступ к Keychain запрещен. Возможно, приложение не подписано. См. README для инструкции.',
signNotarize: 'Пожалуйста, используйте подписанную версию приложения.',
},
dataMigrationFailed: 'Не удалось расшифровать данные старого аккаунта.',
dataMigrationHint: {
relogin: 'Пожалуйста, перезайдите в аккаунт или добавьте его заново.',
clearData: 'Если ошибка сохраняется, очистите локальные данные и войдите снова.',
},
},
nav: {
accounts: 'Аккаунты',
proxy: 'API Прокси',
settings: 'Настройки',
},
account: {
current: 'Текущий',
lastUsed: 'Последнее использование {{time}}',
},
home: {
title: 'Аккаунты',
description: 'Управление вашими аккаунтами Antigravity Google Gemini.',
noBackups: {
title: 'Бэкапы не найдены',
description: 'Создайте резервную копию вашего текущего аккаунта Antigravity, чтобы начать.',
action: 'Создать бэкап текущего аккаунта',
},
},
settings: {
title: 'Настройки',
description: 'Управление настройками приложения.',
general: 'Общие',
connection: 'Подключение',
models: 'Модели',
appearance: {
title: 'Внешний вид',
description: 'Настройте внешний вид Antigravity Manager.',
},
darkMode: 'Темная тема',
darkModeDescription: 'Включить темную тему для комфортной работы ночью.',
language: {
title: 'Язык',
description: 'Выберите язык интерфейса.',
english: 'English',
chinese: '中文 (简体)',
russian: 'Русский',
},
about: {
title: 'О программе',
description: 'Информация о приложении.',
},
version: 'Версия',
platform: 'Платформа',
license: 'Лицензия',
openLogDir: 'Открыть',
account: {
title: 'Настройки аккаунта',
description: 'Настройка автоматического обновления и синхронизации.',
auto_refresh: 'Автообновление квот',
auto_refresh_desc: 'Периодически обновлять информацию о квотах для всех аккаунтов',
auto_sync: 'Автосинхронизация текущего',
auto_sync_desc: 'Периодически синхронизировать активный аккаунт',
},
startup: {
title: 'Автозагрузка',
description: 'Управление запуском приложения при старте системы.',
auto_startup: 'Запускать вместе с системой',
auto_startup_desc: 'Запускать при входе и сворачивать в трей',
macos_hint:
'macOS требует подписанное приложение для работы элементов входа. Если автозапуск не работает, подпишите приложение или включите его вручную в системных настройках.',
},
privacy: {
title: 'Конфиденциальность',
description: 'Управление использованием данных для улучшения приложения.',
error_reporting: 'Отчеты об ошибках',
error_reporting_desc:
'Отправляйте анонимные отчеты об ошибках, чтобы помочь нам улучшить приложение. Личные данные не собираются.',
restart_note: 'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
},
proxy: {
title: 'Верхнеуровневый прокси',
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
enable: 'Включить прокси',
url: 'URL прокси',
timeout: 'Тайм-аут запроса (сек)',
},
modelMapping: {
title: 'Маппинг моделей',
description:
'Сопоставьте модели Claude Code с моделями Antigravity. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
claudeKeyword: 'Модель Claude (Ключевое слово)',
targetGemini: 'Целевая модель Gemini',
addPlaceholderKey: 'например, op-3',
addPlaceholderValue: 'например, gemini-2.0-flash-exp',
noMappings: 'Нет пользовательских сопоставлений.',
mapsTo: 'Мапится в',
default: 'По умолчанию',
restoreDefaults: 'Сбросить по умолчанию',
},
examples: {
title: 'Примеры использования',
description: 'Примеры команд для вызова локального API прокси.',
curl: 'cURL',
python: 'Python',
copy: 'Копировать',
copied: 'Скопировано!',
openai_protocol: 'Протокол OpenAI',
anthropic_protocol: 'Протокол Anthropic',
openai_tools: 'Cursor, Windsurf, NextChat',
anthropic_tools: 'Claude Code CLI',
flash: 'Быстрый (Flash)',
pro: 'Про (Pro)',
flash_preview: 'Превью (Flash Preview)',
pro_high: 'Лучший (Pro High)',
sonnet: 'Мыслящий (Sonnet)',
opus: 'Opus',
},
gateway: {
title: 'Служба API Прокси',
description: 'Управление локальным прокси-сервером API.',
status_running: 'Запущено',
status_stopped: 'Остановлено',
action_start: 'Запустить службу',
action_stop: 'Остановить службу',
accounts_info: '{{count}} аккаунтов доступно',
port: 'Порт прослушивания',
port_hint: 'По умолчанию 8045, требуется перезапуск для применения',
timeout: 'Тайм-аут запроса',
timeout_hint: 'По умолчанию 120с, диапазон 30-600с',
api_key: 'API Ключ',
regenerate_key: 'Перегенерировать',
regenerateConfirm: {
title: 'Перегенерировать API ключ?',
description:
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
cancel: 'Отмена',
confirm: 'Перегенерировать',
},
key_warning: 'Держите ваш API ключ в секрете. Не передавайте его никому.',
auto_start: 'Автозапуск с приложением',
auto_start_desc: 'Запускать прокси-службу при старте приложения',
},
proxy_tab: 'Прокси',
save: 'Сохранить настройки',
},
toast: {
backupSuccess: {
title: 'Успешно',
description: 'Бэкап аккаунта создан.',
},
backupError: {
title: 'Ошибка',
description: 'Не удалось создать бэкап: {{error}}',
},
switchSuccess: {
title: 'Успешно',
description: 'Аккаунт переключен.',
},
switchError: {
title: 'Ошибка',
description: 'Не удалось переключить аккаунт: {{error}}',
},
deleteSuccess: {
title: 'Успешно',
description: 'Бэкап удален.',
},
deleteError: {
title: 'Ошибка',
description: 'Не удалось удалить бэкап: {{error}}',
},
},
cloud: {
title: 'Аккаунты',
description: 'Управление пулом аккаунтов Google Gemini.',
autoSwitch: 'Авто-переключение',
addAccount: 'Добавить аккаунт',
syncFromIDE: 'Синхр. из IDE',
checkQuota: 'Проверить квоту',
polling: 'Опрос запущен',
authDialog: {
title: 'Добавить Google Аккаунт',
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
openLogin: 'Открыть страницу входа',
authCode: 'Код авторизации',
placeholder: 'Вставьте код, начинающийся с 4/...',
instruction:
'Откроется браузер для входа в Google. Скопируйте код со страницы localhost и вставьте сюда.',
verify: 'Проверить и добавить',
},
card: {
active: 'Активен',
use: 'Использовать',
rateLimited: 'Лимит исчерпан',
left: 'ост.',
used: 'Исп.',
unknown: 'Неизвестный',
actions: 'Действия',
useAccount: 'Использовать этот аккаунт',
refresh: 'Обновить квоту',
delete: 'Удалить аккаунт',
noQuota: 'Нет данных',
},
list: {
noAccounts: 'Нет добавленных облачных аккаунтов.',
},
error: {
loadFailed: 'Не удалось загрузить аккаунты.',
},
toast: {
syncSuccess: {
title: 'Синхронизация успешна',
description: 'Импортирован {{email}} из IDE.',
},
syncFailed: {
title: 'Синхронизация не удалась',
description: 'В базе данных IDE не найдено активных аккаунтов.',
},
addSuccess: 'Аккаунт успешно добавлен!',
addFailed: {
title: 'Не удалось добавить аккаунт',
},
quotaRefreshed: 'Квота обновлена',
refreshFailed: 'Не удалось обновить квоту',
pollFailed: 'Не удалось опросить квоты всех аккаунтов',
switched: {
title: 'Аккаунт переключен!',
description: 'Перезапуск Antigravity...',
},
switchFailed: 'Не удалось переключить аккаунт',
deleted: 'Аккаунт удален',
deleteFailed: 'Не удалось удалить аккаунт',
deleteConfirm: 'Вы уверены, что хотите удалить этот аккаунт?',
autoSwitchOn: 'Авто-переключение включено',
autoSwitchOff: 'Авто-переключение выключено',
updateSettingsFailed: 'Не удалось обновить настройки',
},
batch: {
selected: 'Выбрано {{count}}',
delete: 'Удалить выбранные',
refresh: 'Обновить выбранные',
selectAll: 'Выбрать все',
clear: 'Снять выделение',
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
},
},
proxy: {
title: 'API Прокси',
description: 'Управление локальным прокси-сервером API.',
save: 'Сохранить',
copy: 'Копировать',
regenerate: 'Перегенерировать',
regenerateConfirm: {
title: 'Перегенерировать API ключ?',
description:
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
cancel: 'Отмена',
confirm: 'Перегенерировать',
},
service: {
title: 'Статус сервиса',
description: 'Управление локальным сервером.',
running: 'Работает',
stopped: 'Остановлен',
start: 'Запустить',
stop: 'Остановить',
},
config: {
port: 'Порт',
timeout: 'Тайм-аут',
api_key: 'API Ключ',
auto_start: 'Автозапуск',
auto_start_desc: 'Запускать прокси при старте приложения',
local_access: 'Доступ из локальной сети:',
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
show_key: 'Показать',
hide_key: 'Скрыть',
},
mapping: {
title: 'Маппинг моделей',
description: 'Настройка маршрутизации моделей.',
maps_to: '->',
restore: 'Сброс',
},
examples: {
title: 'Примеры',
description: 'Примеры использования локального прокси.',
},
},
},
},
},
});
+110 -13
View File
@@ -1,28 +1,50 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
import { app, BrowserWindow } from 'electron';
import path from 'path';
import fs from 'fs';
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { ipcMain } from 'electron/main';
import { ipcContext } from '@/ipc/context';
import { IPC_CHANNELS } from './constants';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { logger } from './utils/logger';
import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService';
// Static Imports to fix Bundle Resolution Errors
import { AuthServer } from './ipc/cloud/authServer';
import { bootstrapNestServer, stopNestServer } from './server/main';
import { initTray, setTrayLanguage } from './ipc/tray/handler';
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
import { rpcHandler } from './ipc/handler';
import { ConfigManager } from './ipc/config/manager';
import { AppConfig } from './types/config';
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
/**
* Safely stringify an object, handling circular references
*/
function safeStringifyPacket(obj: unknown): string {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
}
function logPacket(data: any) {
try {
fs.appendFileSync(packetLogPath, `[${new Date().toISOString()}] ${JSON.stringify(data)}\n`);
fs.appendFileSync(
packetLogPath,
`[${new Date().toISOString()}] ${safeStringifyPacket(data)}\n`,
);
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
@@ -43,6 +65,25 @@ const inDevelopment = process.env.NODE_ENV === 'development';
let globalMainWindow: BrowserWindow | null = null;
// let tray: Tray | null = null; // Moved to tray/handler.ts
let isQuitting = false;
let startupConfig: AppConfig | null = null;
let shouldStartHidden = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
logger.info('Second instance detected, focusing existing window');
if (app.isReady()) {
createWindow({ startHidden: false });
return;
}
app.whenReady().then(() => {
createWindow({ startHidden: false });
});
});
}
process.on('exit', (code) => {
logger.info(`Process exit event triggered with code: ${code}`);
@@ -55,7 +96,22 @@ process.on('before-exit', (code) => {
// let tray: Tray | null = null; // Moved to tray/handler.ts
function createWindow() {
function createWindow({ startHidden }: { startHidden: boolean }) {
if (globalMainWindow && !globalMainWindow.isDestroyed()) {
if (startHidden) {
globalMainWindow.hide();
return;
}
if (globalMainWindow.isMinimized()) {
globalMainWindow.restore();
}
if (!globalMainWindow.isVisible()) {
globalMainWindow.show();
}
globalMainWindow.focus();
return;
}
logger.info('createWindow: start');
const preload = path.join(__dirname, 'preload.js');
logger.info(`createWindow: preload path: ${preload}`);
@@ -64,6 +120,7 @@ function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: !startHidden,
autoHideMenuBar: true,
webPreferences: {
devTools: inDevelopment,
@@ -79,6 +136,10 @@ function createWindow() {
});
globalMainWindow = mainWindow;
logger.info('createWindow: BrowserWindow instance created');
if (startHidden) {
mainWindow.hide();
logger.info('createWindow: startHidden enabled, window hidden');
}
logger.info('createWindow: setting main window in ipcContext');
ipcContext.setMainWindow(mainWindow);
@@ -106,6 +167,7 @@ function createWindow() {
mainWindow.on('closed', () => {
logger.info('Window closed event triggered');
globalMainWindow = null;
});
mainWindow.webContents.on('render-process-gone', (event, details) => {
@@ -120,8 +182,13 @@ function createWindow() {
logger.info('Page finished loading successfully');
});
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
logger.info(`[Renderer Console] ${message} (${sourceId}:${line})`);
mainWindow.webContents.on('console-message', (details) => {
const { level, message, lineNumber, sourceId } = details;
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
});
mainWindow.on('focus', () => {
CloudMonitorService.handleAppFocus();
});
}
@@ -129,8 +196,18 @@ app.on('child-process-gone', (event, details) => {
logger.error('Child process gone:', details);
});
app.on('before-quit', () => {
isQuitting = true;
logger.info('App before-quit event triggered - isQuitting set to true');
});
app.on('will-quit', (event) => {
logger.info('App will quit event triggered');
try {
destroyTray();
} catch (err) {
logger.error('Failed to destroy tray during will-quit', err);
}
});
app.on('quit', (event, exitCode) => {
@@ -193,13 +270,36 @@ process.on('unhandledRejection', (reason) => {
app
.whenReady()
.then(async () => {
logger.info('Step: Initialize CloudAccountRepo');
try {
await CloudAccountRepo.init();
} catch (e) {
logger.error('Startup: Failed to initialize CloudAccountRepo', e);
// We might want to exit here or show a dialog, but for now we proceed
// though functionality will be broken.
}
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
initDatabase();
})
.then(() => {
logger.info('Step: Load Config');
const config = ConfigManager.loadConfig();
startupConfig = config;
syncAutoStart(config);
shouldStartHidden = isAutoStartLaunch() && config.auto_startup;
if (shouldStartHidden) {
logger.info('Startup: Auto-start detected, window will start hidden');
}
})
.then(() => {
logger.info('Step: setupORPC');
return setupORPC();
})
.then(async () => {
logger.info('Step: createWindow');
await createWindow();
await createWindow({ startHidden: shouldStartHidden });
})
.then(() => {
logger.info('Step: installExtensions (SKIPPED)');
@@ -212,14 +312,11 @@ app
.then(async () => {
// Initialize Cloud Monitor if enabled
try {
// Initialize DB & Migrate to Encrypted Storage
await CloudAccountRepo.init();
// Start OAuth Server
AuthServer.start();
// Gateway Server (NestJS) - auto-start if enabled
const config = ConfigManager.loadConfig();
const config = startupConfig || ConfigManager.loadConfig();
if (config.proxy?.auto_start) {
const port = config.proxy?.port || 8045;
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
@@ -261,7 +358,7 @@ app.on('window-all-closed', () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
createWindow({ startHidden: false });
}
});
//osX only ends
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+39
View File
@@ -1,6 +1,44 @@
import { ipcRenderer, contextBridge } from 'electron';
import * as Sentry from '@sentry/electron/renderer';
import { IPC_CHANNELS } from './constants';
import path from 'path';
import fs from 'fs';
import os from 'os';
// Config check logic (duplicate of instrument.ts logic but adapted for preload)
let sentryEnabled = false;
try {
const home = os.homedir();
let appDataPath = '';
if (process.platform === 'win32') {
appDataPath = path.join(
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
'Antigravity',
);
} else if (process.platform === 'darwin') {
appDataPath = path.join(home, 'Library', 'Application Support', 'Antigravity');
} else {
appDataPath = path.join(home, '.config', 'Antigravity');
}
const configPath = path.join(appDataPath, 'gui_config.json');
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(content);
sentryEnabled = config.error_reporting_enabled === true;
}
} catch (e) {
// console.error('Preload: Failed to read config', e);
}
if (sentryEnabled) {
// Defer Sentry init to avoid blocking main thread during startup (white screen fix)
setTimeout(() => {
// console.log('[Preload] Initializing Sentry (Deferred)');
Sentry.init({});
}, 2000);
}
window.addEventListener('message', (event) => {
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
const [serverPort] = event.ports;
@@ -10,6 +48,7 @@ window.addEventListener('message', (event) => {
});
contextBridge.exposeInMainWorld('electron', {
SENTRY_ENABLED: sentryEnabled,
onGoogleAuthCode: (callback: (code: string) => void) => {
const handler = (_event: any, code: string) => callback(code);
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
+12 -1
View File
@@ -1 +1,12 @@
import "@/App";
import * as Sentry from '@sentry/electron/renderer';
if (window.electron.SENTRY_ENABLED) {
// Defer initialization to prioritize initial render
setTimeout(() => {
Sentry.init({
dsn: process.env.SENTRY_DSN,
});
}, 2000);
}
import '@/App';
+2 -2
View File
@@ -1,5 +1,5 @@
import { MainLayout } from "@/layouts/MainLayout";
import { createRootRoute } from "@tanstack/react-router";
import { MainLayout } from '@/layouts/MainLayout';
import { createRootRoute } from '@tanstack/react-router';
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
/*
+100 -1
View File
@@ -17,9 +17,10 @@ import { getAppVersion, getPlatform } from '@/actions/app';
import { useTranslation } from 'react-i18next';
import { setAppLanguage } from '@/actions/language';
import { useAppConfig } from '@/hooks/useAppConfig';
import { Loader2 } from 'lucide-react';
import { Loader2, FolderOpen } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ProxyConfig } from '@/types/config';
import { openLogDirectory } from '@/actions/system';
function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -32,6 +33,7 @@ function SettingsPage() {
// Sync config to local state when loaded
useEffect(() => {
if (config) {
// eslint-disable-next-line
setProxyConfig(config.proxy);
}
}, [config]);
@@ -46,6 +48,10 @@ function SettingsPage() {
queryFn: getPlatform,
});
const isAutoStartSupported =
platform === 'win32' || platform === 'darwin' || platform === 'linux';
const isMac = platform === 'darwin';
const handleLanguageChange = (value: string) => {
setAppLanguage(value, i18n);
};
@@ -119,6 +125,7 @@ function SettingsPage() {
<SelectContent>
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -178,6 +185,49 @@ function SettingsPage() {
</CardContent>
</Card>
{isAutoStartSupported && (
<Card>
<CardHeader>
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
<CardDescription>
{t(
'settings.startup.description',
'Control application launch behavior at system startup.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.startup.auto_startup_desc',
'Launch at sign-in and keep the app in the system tray',
)}
</p>
</div>
<Switch
checked={config?.auto_startup || false}
onCheckedChange={async (checked) => {
if (config) {
await saveConfig({ ...config, auto_startup: checked });
}
}}
/>
</div>
{isMac && (
<p className="text-muted-foreground text-xs">
{t(
'settings.startup.macos_hint',
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
)}
</p>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t('settings.about.title')}</CardTitle>
@@ -193,9 +243,58 @@ function SettingsPage() {
<div className="text-muted-foreground">{t('settings.license')}</div>
<div className="font-medium">CC BY-NC-SA 4.0</div>
<div className="text-muted-foreground">{t('action.openLogs')}</div>
<button
onClick={() => openLogDirectory()}
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir', 'Open')}</span>
</button>
</div>
</CardContent>
</Card>
{/* Privacy & Error Reporting Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
<CardDescription>
{t(
'settings.privacy.description',
'Control how your data is used to improve the application.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.privacy.error_reporting_desc',
'Send anonymous error reports to help us improve the app. No personal data is collected.',
)}
</p>
</div>
<Switch
checked={config?.error_reporting_enabled || false}
onCheckedChange={async (checked) => {
if (config) {
await saveConfig({ ...config, error_reporting_enabled: checked });
}
}}
/>
</div>
<p className="text-muted-foreground text-xs">
{t(
'settings.privacy.restart_note',
'Changes to error reporting will take effect after restarting the application.',
)}
</p>
</CardContent>
</Card>
</TabsContent>
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
@@ -27,7 +27,8 @@ export class GeminiClient {
this.logger.error(`Gemini Stream API Error: ${error.message}`);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
}
@@ -50,11 +51,12 @@ export class GeminiClient {
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
`Gemini API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
}
@@ -77,7 +79,6 @@ export class GeminiClient {
return response.data;
} catch (error) {
this.handleAxiosError(error, 'StreamInternal');
throw error;
}
}
@@ -96,17 +97,38 @@ export class GeminiClient {
return response.data.response || response.data;
} catch (error) {
this.handleAxiosError(error, 'GenerateInternal');
throw error;
}
}
private handleAxiosError(error: any, context: string) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini ${context} API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
`Gemini ${context} API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
/**
* Safely stringify an object, handling circular references
*/
private safeStringify(obj: unknown): string {
if (obj === undefined || obj === null) {
return String(obj);
}
try {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
} catch {
return '[Unserializable]';
}
}
}
+43 -2
View File
@@ -7,6 +7,11 @@ import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequest
import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper';
import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper';
import { ClaudeRequest } from '../../../lib/antigravity/types';
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
import {
classifyStreamError,
formatErrorForSSE,
} from '../../../lib/antigravity/stream-error-utils';
import {
OpenAIChatRequest,
AnthropicChatRequest,
@@ -45,6 +50,13 @@ export class ProxyService {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
// Fix #6: Add jitter delay for retries (skip first attempt)
if (i > 0) {
const delay = calculateRetryDelay(i - 1);
this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`);
await sleep(delay);
}
const token = await this.tokenManager.getNextToken();
if (!token) {
throw new Error('No available accounts');
@@ -172,7 +184,11 @@ export class ProxyService {
let lastFinishReason: string | undefined;
let lastUsageMetadata: any | undefined;
// Fix #1: Track if we received any valid data
let receivedData = false;
upstreamStream.on('data', (chunk: Buffer) => {
receivedData = true; // Mark that we got data
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
@@ -205,19 +221,42 @@ export class ProxyService {
const chunks = processor.process(part as any);
chunks.forEach((c) => subscriber.next(c));
}
// Reset error state on successful parse
state.resetErrorState();
} catch (e) {
// Fix #3: Use handleParseError for graceful degradation
this.logger.error('Stream parse error', e);
const errorChunks = state.handleParseError(dataStr);
errorChunks.forEach((c) => subscriber.next(c));
}
}
});
upstreamStream.on('end', () => {
// Fix #1: Check for empty stream
if (!receivedData) {
this.logger.warn('Empty response stream detected');
subscriber.error(new Error('Empty response stream'));
return;
}
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
finishChunks.forEach((c) => subscriber.next(c));
subscriber.complete();
});
upstreamStream.on('error', (err: any) => subscriber.error(err));
upstreamStream.on('error', (err: any) => {
// Fix #2: Classify error and send friendly message
const cleanError = err instanceof Error ? err : new Error(String(err));
const { type, message } = classifyStreamError(cleanError);
this.logger.error(`Stream error: ${type} - ${cleanError.message}`);
// Send SSE error event before closing
subscriber.next(formatErrorForSSE(type, message));
subscriber.error(cleanError);
});
});
}
@@ -372,7 +411,9 @@ export class ProxyService {
});
upstreamStream.on('error', (err: any) => {
subscriber.error(err);
// Convert to clean Error to avoid circular reference issues (socket objects)
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
subscriber.error(cleanError);
});
});
}
+101 -34
View File
@@ -6,17 +6,28 @@ import { logger } from '../utils/logger';
export class CloudMonitorService {
private static intervalId: NodeJS.Timeout | null = null;
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
private static DEBOUNCE_TIME = 10000; // 10 seconds
private static lastFocusTime: number = 0;
private static isPolling: boolean = false;
// Helper for testing
static resetStateForTesting() {
this.lastFocusTime = 0;
this.isPolling = false;
this.stop();
}
static start() {
if (this.intervalId) return;
logger.info('Starting CloudMonitorService...');
// Set lastFocusTime to now to prevent "double-dip" on startup (focus event immediately after start)
this.lastFocusTime = Date.now();
// Initial Poll
this.poll().catch((e) => logger.error('Initial poll failed', e));
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
this.startInterval();
}
static stop() {
@@ -27,39 +38,95 @@ export class CloudMonitorService {
}
}
static async poll() {
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
/**
* Called when the application window gains focus.
* Triggers an immediate poll if not rate-limited by debounce.
*/
static async handleAppFocus() {
const now = Date.now();
for (const account of accounts) {
try {
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
let accessToken = account.token.access_token;
if (account.token.expiry_timestamp < now + 600) {
logger.info(`Monitor: Refreshing token for ${account.email}`);
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token;
}
// 2. Fetch Quota
// We delay slightly between requests to act human/avoid spike
await new Promise((r) => setTimeout(r, 1000));
const quota = await GoogleAPIService.fetchQuota(accessToken);
// 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota);
} catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429
}
// 1. Concurrency Guard: If we are already polling, don't pile up requests
if (this.isPolling) {
logger.info('Monitor: App focused, but polling is already in progress. Skipping.');
return;
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
// 2. Debounce: If we focused recently, don't poll again
if (now - this.lastFocusTime < this.DEBOUNCE_TIME) {
logger.info('Monitor: App focused, skipping poll (debounce active).');
return;
}
logger.info('Monitor: App focused, triggering immediate poll...');
this.lastFocusTime = now;
// 3. Trigger Poll
await this.poll().catch((e) => {
logger.error('Monitor: Focus poll failed', e);
});
// 4. Reset the background interval so we don't double-poll shortly after
this.resetInterval();
}
private static startInterval() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
}
private static resetInterval() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.startInterval(); // Restart the 5-minute timer
}
}
static async poll() {
if (this.isPolling) {
return; // Extra safety
}
this.isPolling = true;
try {
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
for (const account of accounts) {
try {
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
let accessToken = account.token.access_token;
if (account.token.expiry_timestamp < now + 600) {
logger.info(`Monitor: Refreshing token for ${account.email}`);
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token;
}
// 2. Fetch Quota
// We delay slightly between requests to act human/avoid spike
await new Promise((r) => setTimeout(r, 1000));
const quota = await GoogleAPIService.fetchQuota(accessToken);
// 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
} catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429
}
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
} finally {
this.isPolling = false;
}
}
}
+51 -9
View File
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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';
declare const fetch: any;
const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
@@ -20,6 +18,18 @@ const URLS = {
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
// Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000;
/**
* Creates an AbortSignal that times out after the specified duration.
*/
function createTimeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
// --- Types ---
export interface TokenResponse {
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
// --- Service Implementation ---
export class GoogleAPIService {
private static getFetchOptions(): any {
private static getFetchOptions() {
try {
const config = ConfigManager.loadConfig();
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
@@ -121,7 +131,17 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -147,7 +167,17 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token refresh timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -166,7 +196,17 @@ export class GoogleAPIService {
static async getUserInfo(accessToken: string): Promise<UserInfo> {
const response = await fetch(URLS.USER_INFO, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'User info request timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -221,7 +261,7 @@ export class GoogleAPIService {
const projectId = await this.fetchProjectId(accessToken);
// 2. Build Payload
const payload: Record<string, any> = {};
const payload: Record<string, unknown> = {};
if (projectId) {
payload['project'] = projectId;
}
@@ -285,11 +325,13 @@ export class GoogleAPIService {
}
return result;
} catch (e: any) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
lastError = e;
} catch (e: unknown) {
if (e instanceof Error) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
}
lastError = e instanceof Error ? e : new Error(String(e));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
}
+15 -16
View File
@@ -1,6 +1,6 @@
@import "tailwindcss";
@import 'tailwindcss';
@plugin "tailwindcss-animate";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@@ -32,7 +32,7 @@
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--font-sans: Geist, "Microsoft YaHei", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
--font-sans: Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
--font-mono: Geist Mono, monospace;
--font-tomorrow: Tomorrow, sans-serif;
@@ -74,45 +74,44 @@
@layer base {
@font-face {
font-family: "Geist";
font-family: 'Geist';
src: url("../assets/fonts/geist/geist.ttf") format("truetype");
src: url('../assets/fonts/geist/geist.ttf') format('truetype');
}
@font-face {
font-family: "Geist Mono";
font-family: 'Geist Mono';
font-display: swap;
src: url("../assets/fonts/geist-mono/geist-mono.ttf") format("truetype");
src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 400;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-regular.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 400;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-italic.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 700;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-bold.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 700;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-bold-italic.ttf")
format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
}
}
+23 -23
View File
@@ -1,45 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
listAccountsData,
addAccountSnapshot,
switchAccount,
deleteAccount,
} from "../../ipc/account/handler";
import fs from "fs";
import path from "path";
} from '../../ipc/account/handler';
import fs from 'fs';
import path from 'path';
// Mock dependencies
vi.mock("../../utils/paths", async () => {
const path = await import("path");
const agentDir = path.join(process.cwd(), "temp_test_agent");
vi.mock('../../utils/paths', async () => {
const path = await import('path');
const agentDir = path.join(process.cwd(), 'temp_test_agent');
return {
getAgentDir: vi.fn(() => agentDir),
getAccountsFilePath: vi.fn(() => path.join(agentDir, "accounts.json")),
getBackupsDir: vi.fn(() => path.join(agentDir, "backups")),
getAntigravityDbPath: vi.fn(() => path.join(agentDir, "state.vscdb")),
getAntigravityExecutablePath: vi.fn(() => "mock_exec_path"),
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
getAntigravityDbPath: vi.fn(() => path.join(agentDir, 'state.vscdb')),
getAntigravityExecutablePath: vi.fn(() => 'mock_exec_path'),
};
});
vi.mock("../../ipc/database/handler", () => ({
vi.mock('../../ipc/database/handler', () => ({
getCurrentAccountInfo: vi.fn(() => ({
email: "[email protected]",
name: "Test User",
email: '[email protected]',
name: 'Test User',
isAuthenticated: true,
})),
backupAccount: vi.fn((account) => ({ version: "1.0", account, data: {} })),
backupAccount: vi.fn((account) => ({ version: '1.0', account, data: {} })),
restoreAccount: vi.fn(),
getDatabaseConnection: vi.fn(),
}));
vi.mock("../../ipc/process/handler", () => ({
vi.mock('../../ipc/process/handler', () => ({
closeAntigravity: vi.fn(),
startAntigravity: vi.fn(),
isProcessRunning: vi.fn(() => Promise.resolve(false)),
}));
describe("Account Handler", () => {
const testAgentDir = path.join(process.cwd(), "temp_test_agent");
describe('Account Handler', () => {
const testAgentDir = path.join(process.cwd(), 'temp_test_agent');
beforeEach(() => {
if (fs.existsSync(testAgentDir)) {
@@ -54,22 +54,22 @@ describe("Account Handler", () => {
}
});
it("should add account snapshot", async () => {
it('should add account snapshot', async () => {
const account = await addAccountSnapshot();
expect(account.email).toBe("[email protected]");
expect(account.email).toBe('[email protected]');
const accounts = await listAccountsData();
expect(accounts).toHaveLength(1);
expect(accounts[0].email).toBe("[email protected]");
expect(accounts[0].email).toBe('[email protected]');
});
it("should switch account", async () => {
it('should switch account', async () => {
const account = await addAccountSnapshot();
await switchAccount(account.id);
// Mocks should be called, verified by implementation logic not throwing
});
it("should delete account", async () => {
it('should delete account', async () => {
const account = await addAccountSnapshot();
await deleteAccount(account.id);
+194
View File
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CloudMonitorService } from '../../services/CloudMonitorService';
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
import { GoogleAPIService } from '../../services/GoogleAPIService';
import { AutoSwitchService } from '../../services/AutoSwitchService';
import { logger } from '../../utils/logger';
// Mock dependencies
vi.mock('../../ipc/database/cloudHandler');
vi.mock('../../services/GoogleAPIService');
vi.mock('../../services/AutoSwitchService');
vi.mock('../../utils/logger');
describe('CloudMonitorService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
CloudMonitorService.resetStateForTesting();
});
afterEach(() => {
CloudMonitorService.stop();
vi.useRealTimers();
});
it('should start polling on start() and set up interval', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
// Should call initial poll
expect(pollSpy).toHaveBeenCalledTimes(1);
// Fast forward 5 minutes
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
expect(pollSpy).toHaveBeenCalledTimes(2);
pollSpy.mockRestore();
});
it('should poll accounts correctly', async () => {
const mockAccounts = [
{
id: 'acc1',
email: '[email protected]',
token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 },
},
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
// Start poll but don't await immediately, as it pauses
const pollPromise = CloudMonitorService.poll();
// Advance time to pass the 1s sleep
await vi.advanceTimersByTimeAsync(1000);
// Now await
await pollPromise;
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
});
it('should refresh token if expired during poll', async () => {
const mockAccounts = [
{
id: 'acc1',
email: '[email protected]',
token: {
access_token: 'old_token',
refresh_token: 'ref_token',
expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired
},
},
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({
access_token: 'new_token',
expires_in: 3600,
token_type: 'Bearer',
});
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
// Same async pattern
const pollPromise = CloudMonitorService.poll();
await vi.advanceTimersByTimeAsync(1000);
await pollPromise;
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
});
describe('handleAppFocus (Smart Refresh)', () => {
it('should trigger poll when focused after debounce time', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
vi.setSystemTime(Date.now() + 20000);
await CloudMonitorService.handleAppFocus();
// Called once by start, once by focus
expect(pollSpy).toHaveBeenCalledTimes(2);
});
it('should NOT trigger poll if debounced (focused too soon)', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
vi.setSystemTime(Date.now() + 1000);
await CloudMonitorService.handleAppFocus();
// Called once by start, 0 by focus
expect(pollSpy).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active'));
});
it('should NOT trigger poll if already polling (concurrency guard)', async () => {
// Let's spy on poll to silence start's poll
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
pollSpy.mockRestore(); // Restore so we can test the real guard logic
vi.setSystemTime(Date.now() + 20000);
// 2. Mock getAccounts to delay
let resolveGetAccounts: (value: unknown) => void;
const getAccountsPromise = new Promise((resolve) => {
resolveGetAccounts = resolve;
});
vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never);
// 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep
// Actually poll() calls getAccounts first thing.
const p1 = CloudMonitorService.handleAppFocus();
// Allow p1 to start execution and enter poll()
// We invoke one run loop
// But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await.
// 4. Trigger second focus immediately
const p2 = CloudMonitorService.handleAppFocus();
// 5. Release the first poll
resolveGetAccounts!([
{ id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } },
]);
// Advance timer for the 1s sleep inside poll
await vi.advanceTimersByTimeAsync(1000);
await Promise.all([p1, p2]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('polling is already in progress'),
);
// getAccounts should be called once (by the first unblocked poll)
// The second one blocked by guard before calling poll
expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1);
});
it('should reset interval after successful focus poll', async () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const setIntervalSpy = vi.spyOn(global, 'setInterval');
// Spy on poll to consume the start() call
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
pollSpy.mockRestore(); // Restore real poll
vi.setSystemTime(Date.now() + 20000);
// Needs to handle async poll
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
const focusPromise = CloudMonitorService.handleAppFocus();
await vi.advanceTimersByTimeAsync(1000); // For poll sleep
await focusPromise;
expect(clearIntervalSpy).toHaveBeenCalled();
// 1 for start (initial), 1 for reset = 2
expect(setIntervalSpy).toHaveBeenCalledTimes(2);
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock module to test timeout behavior
describe('GoogleAPIService Timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should abort request after timeout', async () => {
const TIMEOUT_MS = 100;
// Recreate the same logic used in GoogleAPIService
const createTimeoutSignal = (ms: number): AbortSignal => {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
};
const signal = createTimeoutSignal(TIMEOUT_MS);
// Verify signal is not aborted initially
expect(signal.aborted).toBe(false);
// Fast-forward time
vi.advanceTimersByTime(TIMEOUT_MS + 10);
// Signal should now be aborted
expect(signal.aborted).toBe(true);
});
it('should set aborted state when controller.abort() is called', () => {
const controller = new AbortController();
// Initially not aborted
expect(controller.signal.aborted).toBe(false);
// Abort the controller
controller.abort();
// Signal should now be aborted
expect(controller.signal.aborted).toBe(true);
});
it('should transform AbortError to user-friendly message', () => {
// This tests our error transformation logic from GoogleAPIService
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
expect(() => handleAbortError(abortError)).toThrow(
'Token exchange timed out. Please check your network connection and try again.',
);
});
it('should not transform non-AbortError', () => {
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const networkError = new Error('Network failure');
networkError.name = 'NetworkError';
expect(() => handleAbortError(networkError)).toThrow('Network failure');
});
});
+22 -22
View File
@@ -1,26 +1,26 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { logger } from "../../utils/logger";
import fs from "fs";
import path from "path";
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { logger } from '../../utils/logger';
import fs from 'fs';
import path from 'path';
vi.mock("../../utils/paths", async () => {
const path = await import("path");
vi.mock('../../utils/paths', async () => {
const path = await import('path');
return {
getAgentDir: vi.fn(() => path.join(process.cwd(), "temp_test_logs")),
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
};
});
describe("Logger Utilities", () => {
const testLogDir = path.join(process.cwd(), "temp_test_logs");
const testLogFile = path.join(testLogDir, "app.log");
describe('Logger Utilities', () => {
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
const testLogFile = path.join(testLogDir, 'app.log');
beforeEach(() => {
console.log("Test Log Dir:", testLogDir);
console.log('Test Log Dir:', testLogDir);
if (fs.existsSync(testLogDir)) {
fs.rmSync(testLogDir, { recursive: true, force: true });
}
fs.mkdirSync(testLogDir, { recursive: true });
console.log("Dir created:", fs.existsSync(testLogDir));
console.log('Dir created:', fs.existsSync(testLogDir));
});
afterEach(() => {
@@ -29,24 +29,24 @@ describe("Logger Utilities", () => {
}
});
it("should create log file if it does not exist", () => {
logger.info("Test message");
it('should create log file if it does not exist', () => {
logger.info('Test message');
expect(fs.existsSync(testLogFile)).toBe(true);
});
it("should write formatted message to file", () => {
const message = "Test info message";
it('should write formatted message to file', () => {
const message = 'Test info message';
logger.info(message);
const content = fs.readFileSync(testLogFile, "utf-8");
expect(content).toContain("[INFO]");
const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain('[INFO]');
expect(content).toContain(message);
});
it("should log error messages", () => {
const message = "Test error message";
it('should log error messages', () => {
const message = 'Test error message';
logger.error(message);
const content = fs.readFileSync(testLogFile, "utf-8");
expect(content).toContain("[ERROR]");
const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain('[ERROR]');
expect(content).toContain(message);
});
});
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more