Compare commits

..
18 Commits
Author SHA1 Message Date
semantic-release-bot 36848b46dc chore(release): 0.6.0 [skip ci]
## [0.6.0](https://github.com/Draculabo/AntigravityManager/compare/v0.5.0...v0.6.0) (2026-02-04)

### ✨ Features

*  add cloud reset time UI ([#56](https://github.com/Draculabo/AntigravityManager/issues/56)) ([f6f8069](https://github.com/Draculabo/AntigravityManager/commit/f6f8069ce4673ae7027c6ba248daa18c8b602218))
* windows install guidance ([#63](https://github.com/Draculabo/AntigravityManager/issues/63)) ([ce71470](https://github.com/Draculabo/AntigravityManager/commit/ce7147025d37648a82f6f6b300502ef4462bfde6))

### 🐛 Bug Fixes

* correct Windows install notice path ([5bda4b1](https://github.com/Draculabo/AntigravityManager/commit/5bda4b19dfc9ce64735cd39600d41a5deefc26ee))
* **proxy:** route Claude Code CLI requests on /v1/chat/completions to Anthropic handler ([#61](https://github.com/Draculabo/AntigravityManager/issues/61)) ([476d297](https://github.com/Draculabo/AntigravityManager/commit/476d297e4b2dd3015f7ebcbb915b643730816dc6))
2026-02-04 12:35:15 +00:00
Draculabo 5bda4b19df fix: correct Windows install notice path 2026-02-04 20:33:26 +08:00
saianilsingi 476d297e4b fix(proxy): route Claude Code CLI requests on /v1/chat/completions to Anthropic handler (#61)
* fix(proxy): route Claude models from chat completions to anthropic handler

* style(proxy): minor formatting cleanup
2026-02-04 18:22:53 +08:00
vin ce7147025d feat: windows install guidance (#63)
* build: add dmg/appimage and artifact metadata

* feat: enhance packaging outputs and add Windows install-path notice
2026-02-04 18:10:26 +08:00
vin 080fb67974 chore: add Windows ARM64 publish runner and update readme (#57) 2026-02-01 18:08:13 +08:00
vin f6f8069ce4 feat: add cloud reset time UI (#56)
* feat: show quota reset time on cloud account card

* chore: ignore changelog file  in prettier

* style: format account card
2026-02-01 18:03:27 +08:00
Draculabo 6328378da5 chore: make release manual and publish on release 2026-02-01 17:55:06 +08:00
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
60 changed files with 5921 additions and 525 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'
+5 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
os: [windows-latest, windows-11-arm, macos-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }}
@@ -39,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
+1 -4
View File
@@ -1,10 +1,7 @@
name: Release
on:
push:
branches:
- main
- beta
workflow_dispatch:
permissions:
contents: write
+3
View File
@@ -102,3 +102,6 @@ out/
# Tanstack
.tanstack/
# Sentry Config File
.env.sentry-build-plugin
+1
View File
@@ -3,6 +3,7 @@
/coverage
/.vite
README.md
CHANGELOG.md
/src/routeTree.gen.ts
.agents/
openspec/
-1
View File
@@ -106,7 +106,6 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- `npm run test:e2e` - 运行 Playwright 端到端测试
- `npm run test:all` - 运行所有测试
### 运行单个测试 (Running Single Tests)
- 单元测试: `npm run test:unit path/to/test.test.ts`
+51 -10
View File
@@ -2,47 +2,88 @@
# Changelog
## [0.6.0](https://github.com/Draculabo/AntigravityManager/compare/v0.5.0...v0.6.0) (2026-02-04)
### ✨ Features
* add cloud reset time UI ([#56](https://github.com/Draculabo/AntigravityManager/issues/56)) ([f6f8069](https://github.com/Draculabo/AntigravityManager/commit/f6f8069ce4673ae7027c6ba248daa18c8b602218))
* windows install guidance ([#63](https://github.com/Draculabo/AntigravityManager/issues/63)) ([ce71470](https://github.com/Draculabo/AntigravityManager/commit/ce7147025d37648a82f6f6b300502ef4462bfde6))
### 🐛 Bug Fixes
* correct Windows install notice path ([5bda4b1](https://github.com/Draculabo/AntigravityManager/commit/5bda4b19dfc9ce64735cd39600d41a5deefc26ee))
* **proxy:** route Claude Code CLI requests on /v1/chat/completions to Anthropic handler ([#61](https://github.com/Draculabo/AntigravityManager/issues/61)) ([476d297](https://github.com/Draculabo/AntigravityManager/commit/476d297e4b2dd3015f7ebcbb915b643730816dc6))
## [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))
## [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))
- **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))
- 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))
- **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))
- 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))
- 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))
- 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))
- 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))
- 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
+1 -8
View File
@@ -33,13 +33,6 @@
</a>
</p>
<p align="center">
<strong>🧪 Beta:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.4-beta.1">
Download pre-release v0.3.4-beta.1
</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
@@ -192,7 +185,7 @@ Download the latest release for your platform from the [Releases](https://github
| Platform | Download |
|----------|----------|
| Windows | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
+1 -8
View File
@@ -33,13 +33,6 @@
</a>
</p>
<p align="center">
<strong>🧪 内测版:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.4-beta.1">
下载 pre-release v0.3.4-beta.1
</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
@@ -191,7 +184,7 @@
| 平台 | 下载链接 |
| ------- | ------------------------------------------------------------------------------ |
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Windows (x64/ARM64) | [.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) |
+10
View File
@@ -25,4 +25,14 @@ export default defineConfig([
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',
},
},
]);
+233 -7
View File
@@ -1,19 +1,115 @@
import type { ForgeConfig } from '@electron-forge/shared-types';
import crypto from 'crypto';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import type {
HookFunction,
HookFunctionErrorCallback,
TargetArch,
TargetPlatform,
} from '@electron/packager';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { VitePlugin } from '@electron-forge/plugin-vite';
import type { ForgeConfig } from '@electron-forge/shared-types';
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
import setLanguages from 'electron-packager-languages';
import * as fs from 'fs';
import * as path from 'path';
import { stringify as yamlStringify } from 'yaml';
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
const ResolvedMakerAppImage = MakerAppImage;
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm)$/;
const platformNamesMap: Record<string, string> = {
darwin: 'macos',
linux: 'linux',
win32: 'windows',
};
const ymlMapsMap: Record<string, string> = {
darwin: 'latest-mac.yml',
linux: 'latest-linux.yml',
win32: 'latest.yml',
};
const ignorePatterns = [
/^\/\.git/,
/^\/\.github/,
/^\/\.vscode/,
/^\/\.idea/,
/^\/openspec/,
/^\/docs?/,
/^\/scripts?/,
/^\/tests?/,
/^\/mocks?/,
/^\/src/,
/^\/node_modules\/\.cache/,
];
const setLanguagesHook = setLanguages([...keepLanguages.values()]);
const packagerAfterCopy: HookFunction[] = [
(
buildPath: string,
electronVersion: string,
platform: TargetPlatform,
arch: TargetArch,
callback: HookFunctionErrorCallback,
) => {
if (platform !== 'win32') {
callback();
return;
}
setLanguagesHook(buildPath, electronVersion, platform, arch, callback);
},
];
function normalizeArtifactName(value?: string) {
if (!value) {
return 'app';
}
return value.trim().replace(/\s+/g, '-');
}
function isSquirrelArtifact(artifactPath: string) {
const fileName = path.basename(artifactPath);
if (fileName === 'RELEASES') {
return true;
}
return artifactPath.endsWith('.nupkg');
}
const appImageMaker = new ResolvedMakerAppImage({
config: {
icons: [
{
file: 'images/32x32.png',
size: 32,
},
{
file: 'images/64x64.png',
size: 64,
},
{
file: 'images/128x128.png',
size: 128,
},
{
file: 'images/[email protected]',
size: 256,
},
],
},
});
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
const config: ForgeConfig = {
packagerConfig: {
asar: {
@@ -23,6 +119,9 @@ const config: ForgeConfig = {
executableName: 'antigravity-manager',
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
extraResource: ['src/assets'], // Copy assets folder to resources/assets
afterCopy: packagerAfterCopy,
ignore: ignorePatterns,
prune: true,
},
rebuildConfig: {},
hooks: {
@@ -63,8 +162,135 @@ const config: ForgeConfig = {
console.warn(`Assets directory not found: ${assetsSrc}`);
}
},
postMake: async (_config, makeResults) => {
if (!makeResults?.length) {
return makeResults;
}
const ymlByPlatform = new Map<
string,
{
basePath: string;
yml: {
version?: string;
files: {
url: string;
sha512: string;
size: number;
}[];
releaseDate?: string;
};
}
>();
makeResults = makeResults.map((result) => {
const productName = normalizeArtifactName(result.packageJSON.productName);
const platformName = platformNamesMap[result.platform] || result.platform;
const version = result.packageJSON.version;
const platformKey = result.platform;
if (!ymlByPlatform.has(platformKey)) {
ymlByPlatform.set(platformKey, {
basePath: '',
yml: {
version,
files: [],
},
});
}
const platformState = ymlByPlatform.get(platformKey)!;
result.artifacts = result.artifacts
.map((artifact) => {
if (!artifact) {
return null;
}
if (isSquirrelArtifact(artifact)) {
return artifact;
}
if (!artifactRegex.test(artifact)) {
return artifact;
}
if (!platformState.basePath) {
platformState.basePath = path.dirname(artifact);
}
const newArtifact = `${path.dirname(artifact)}/${productName}-${version}-${platformName}-${result.arch}${path.extname(artifact)}`;
if (newArtifact !== artifact) {
fs.renameSync(artifact, newArtifact);
}
try {
const fileData = fs.readFileSync(newArtifact);
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
const { size } = fs.statSync(newArtifact);
platformState.yml.files.push({
url: path.basename(newArtifact),
sha512: hash,
size,
});
} catch {
console.error(`Failed to hash ${newArtifact}`);
}
return newArtifact;
})
.filter((artifact) => artifact !== null);
return result;
});
const releaseDate = new Date().toISOString();
for (const [platform, platformState] of ymlByPlatform.entries()) {
const ymlFileName = ymlMapsMap[platform];
if (!ymlFileName || !platformState.basePath) {
continue;
}
platformState.yml.releaseDate = releaseDate;
const ymlPath = path.join(platformState.basePath, ymlFileName);
fs.writeFileSync(ymlPath, yamlStringify(platformState.yml));
const sampleResult = makeResults.find((result) => result.platform === platform);
if (!sampleResult) {
continue;
}
makeResults.push({
artifacts: [ymlPath],
platform: sampleResult.platform,
arch: sampleResult.arch,
packageJSON: sampleResult.packageJSON,
});
}
return makeResults;
},
},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl:
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
}),
new MakerDMG(
{
overwrite: true,
icon: 'images/icon.icns',
iconSize: 160,
},
['darwin'],
),
new MakerZIP({}, ['darwin']),
appImageMaker,
new MakerRpm({}),
new MakerDeb({}),
],
publishers: [
{
/*
+3547 -298
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.3.4",
"version": "0.6.0",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -25,6 +25,7 @@
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
@@ -33,6 +34,7 @@
"@electron-forge/plugin-vite": "^7.10.2",
"@electron-forge/publisher-github": "^7.10.2",
"@electron-forge/shared-types": "^7.8.1",
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
"@electron/fuses": "~1.8.0",
"@electron/rebuild": "^3.7.1",
"@eslint/compat": "^1.4.1",
@@ -41,6 +43,7 @@
"@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",
@@ -60,6 +63,7 @@
"electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
"electron-packager-languages": "^0.6.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
@@ -91,7 +95,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",
@@ -108,6 +111,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",
@@ -139,6 +145,7 @@
"undici": "^7.16.0",
"update-electron-app": "^3.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.1.12"
}
}
+3 -1
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,7 +33,7 @@ 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 });
@@ -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>
+77 -9
View File
@@ -19,7 +19,7 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
@@ -50,11 +50,64 @@ export function CloudAccountCard({
// Helpers to get quota color
const getQuotaColor = (percentage: number) => {
if (percentage > 80) return 'text-green-500';
if (percentage > 20) return 'text-yellow-500';
if (percentage > 80) {
return 'text-green-500';
}
if (percentage > 20) {
return 'text-yellow-500';
}
return 'text-red-500';
};
const formatTimeRemaining = (dateStr: string) => {
const targetDate = new Date(dateStr);
if (Number.isNaN(targetDate.getTime())) {
return null;
}
const now = new Date();
if (isBefore(targetDate, now)) {
return '0h 0m';
}
// Calculate difference in hours and minutes
const diffHrs = differenceInHours(targetDate, now);
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
if (diffHrs >= 24) {
const diffDays = Math.floor(diffHrs / 24);
const remainingHrs = diffHrs % 24;
return `${diffDays}d ${remainingHrs}h`;
}
return `${diffHrs}h ${diffMins}m`;
};
const getResetTimeLabel = (resetTime?: string) => {
if (!resetTime) {
return t('cloud.card.resetUnknown');
}
const remaining = formatTimeRemaining(resetTime);
if (!remaining) {
return t('cloud.card.resetUnknown');
}
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
};
const getResetTimeTitle = (resetTime?: string) => {
if (!resetTime) {
return undefined;
}
const resetDate = new Date(resetTime);
if (Number.isNaN(resetDate.getTime())) {
return undefined;
}
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
};
const modelQuotas = Object.entries(account.quota?.models || {});
return (
@@ -169,15 +222,30 @@ export function CloudAccountCard({
<div className="space-y-2">
{modelQuotas.length > 0 ? (
modelQuotas.map(([modelName, info]) => (
<div key={modelName} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
<div
key={modelName}
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
>
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
{modelName.replace('models/', '')}
</span>
<div className="flex items-center gap-2">
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
{info.percentage}%
<div className="flex flex-col items-end gap-1">
<span
className="text-muted-foreground text-[10px] leading-none"
title={getResetTimeTitle(info.resetTime)}
>
{getResetTimeLabel(info.resetTime)}
</span>
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
<div className="flex items-baseline gap-1.5">
<span
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
>
{info.percentage}%
</span>
<span className="text-muted-foreground text-[10px]">
{t('cloud.card.left')}
</span>
</div>
</div>
</div>
))
+8 -1
View File
@@ -176,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',
}),
});
};
@@ -387,7 +394,7 @@ export function CloudAccountList() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{accounts?.map((account) => (
<CloudAccountCard
key={account.id}
+79 -18
View File
@@ -3,10 +3,15 @@ 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 { 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();
@@ -40,26 +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"
@@ -67,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>
);
+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 };
+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
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);
}),
});
+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);
});
}
+1 -3
View File
@@ -100,9 +100,7 @@ export async function isProcessRunning(): Promise<boolean> {
logger.debug('No Antigravity process found (pgrep returned 1)');
}
logger.debug(
`Found ${processes.length} processes matching 'Antigravity/antigravity'`,
);
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
for (const proc of processes) {
// Skip self
+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 -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]);
}
}
+369 -3
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: {
@@ -83,6 +83,7 @@ i18n
description: 'Select your preferred language.',
english: 'English',
chinese: 'Chinese (Simplified)',
russian: 'Russian',
},
about: {
title: 'About',
@@ -100,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.',
@@ -223,6 +241,9 @@ i18n
refresh: 'Refresh Quota',
delete: 'Delete Account',
noQuota: 'No quota data',
resetPrefix: 'reset',
resetTime: 'Reset time',
resetUnknown: 'Unknown',
},
list: {
noAccounts: 'No cloud accounts added yet.',
@@ -245,6 +266,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...',
@@ -293,6 +315,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',
@@ -328,8 +354,10 @@ i18n
generic: '发生未知错误。',
keychainUnavailable: 'Keychain 不可用。',
keychainHint: {
translocation: '检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
keychainDenied: 'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
translocation:
'检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
keychainDenied:
'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
signNotarize: '请在可用时使用已签名并公证的版本。',
},
dataMigrationFailed: '旧版账号数据无法解密。',
@@ -373,6 +401,7 @@ i18n
description: '选择您的首选语言。',
english: '英语',
chinese: '中文 (简体)',
russian: '俄语',
},
about: {
title: '关于',
@@ -390,6 +419,21 @@ 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 的请求配置代理。',
@@ -511,6 +555,9 @@ i18n
refresh: '刷新配额',
delete: '删除账号',
noQuota: '无配额数据',
resetPrefix: '重置',
resetTime: '重置时间',
resetUnknown: '未知',
},
list: {
noAccounts: '暂无云账号。',
@@ -533,6 +580,7 @@ i18n
},
quotaRefreshed: '配额已刷新',
refreshFailed: '刷新配额失败',
pollFailed: '轮询所有账号配额失败',
switched: {
title: '账号已切换!',
description: '正在重启 Antigravity...',
@@ -598,5 +646,323 @@ 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: 'Нет данных',
resetPrefix: 'Сброс',
resetTime: 'Время сброса',
resetUnknown: 'Неизвестно',
},
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: 'Примеры использования локального прокси.',
},
},
},
},
},
});
+132 -6
View File
@@ -1,13 +1,21 @@
import { app, BrowserWindow } from 'electron';
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
import { app, BrowserWindow, dialog, shell } from 'electron';
import type { MessageBoxOptions } from 'electron';
import path from 'path';
import fs from 'fs';
import squirrelStartup from 'electron-squirrel-startup';
// 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 {
getExpectedInstallRoot,
getInstallNoticeText,
isRunningFromExpectedInstallDir as isRunningFromExpectedInstallDirUtil,
resolveInstallNoticeLanguage,
} from './utils/installNotice';
import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService';
@@ -18,6 +26,8 @@ import { bootstrapNestServer, stopNestServer } from './server/main';
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');
@@ -56,6 +66,11 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
app.disableHardwareAcceleration();
if (squirrelStartup) {
app.quit();
process.exit(0);
}
const inDevelopment = process.env.NODE_ENV === 'development';
// Keep a global reference of the window object, if you don't, the window will
@@ -63,6 +78,81 @@ 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;
let hasShownInstallNotice = false;
function isRunningFromExpectedInstallDir() {
return isRunningFromExpectedInstallDirUtil({
platform: process.platform,
isPackaged: app.isPackaged,
localAppData: process.env.LOCALAPPDATA,
appName: app.getName(),
execPath: process.execPath,
});
}
function showWindowsInstallNoticeIfNeeded() {
if (hasShownInstallNotice) {
return;
}
if (isRunningFromExpectedInstallDir()) {
return;
}
const expectedRoot = getExpectedInstallRoot({
platform: process.platform,
localAppData: process.env.LOCALAPPDATA,
appName: app.getName(),
});
if (!expectedRoot) {
return;
}
hasShownInstallNotice = true;
const language = resolveInstallNoticeLanguage({
configLanguage: startupConfig?.language,
locale: app.getLocale(),
});
const text = getInstallNoticeText(language);
const options: MessageBoxOptions = {
type: 'info',
title: text.title,
message: text.message,
detail: `${text.detailPrefix}${expectedRoot}`,
buttons: [...text.buttons],
defaultId: 1,
};
const showPromise = globalMainWindow
? dialog.showMessageBox(globalMainWindow, options)
: dialog.showMessageBox(options);
showPromise.then(({ response }) => {
if (response === 0) {
shell.openPath(expectedRoot);
}
});
}
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}`);
@@ -75,7 +165,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}`);
@@ -84,6 +189,7 @@ function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: !startHidden,
autoHideMenuBar: true,
webPreferences: {
devTools: inDevelopment,
@@ -99,6 +205,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);
@@ -113,6 +223,7 @@ function createWindow() {
}
logger.info('Window created');
showWindowsInstallNoticeIfNeeded();
mainWindow.on('close', (event) => {
if (!isQuitting) {
@@ -126,6 +237,7 @@ function createWindow() {
mainWindow.on('closed', () => {
logger.info('Window closed event triggered');
globalMainWindow = null;
});
mainWindow.webContents.on('render-process-gone', (event, details) => {
@@ -144,6 +256,10 @@ function createWindow() {
const { level, message, lineNumber, sourceId } = details;
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
});
mainWindow.on('focus', () => {
CloudMonitorService.handleAppFocus();
});
}
app.on('child-process-gone', (event, details) => {
@@ -237,13 +353,23 @@ app
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)');
@@ -260,7 +386,7 @@ app
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
@@ -302,7 +428,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);
+11
View File
@@ -1 +1,12 @@
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';
+89
View File
@@ -33,6 +33,7 @@ function SettingsPage() {
// Sync config to local state when loaded
useEffect(() => {
if (config) {
// eslint-disable-next-line
setProxyConfig(config.proxy);
}
}, [config]);
@@ -47,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);
};
@@ -120,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>
@@ -179,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>
@@ -206,6 +255,46 @@ function SettingsPage() {
</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) --- */}
@@ -44,6 +44,24 @@ export class ProxyController {
@Post('chat/completions')
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
try {
// Claude Code CLI compatibility:
// Claude CLI uses OpenAI-style /v1/chat/completions
if (body.model?.startsWith('claude-')) {
const result = await this.proxyService.handleAnthropicMessages(
body as unknown as AnthropicChatRequest,
);
if (body.stream && result instanceof Observable) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
res.send(result);
} else {
res.status(HttpStatus.OK).send(result);
}
return;
}
const result = await this.proxyService.handleChatCompletions(body);
if (body.stream && result instanceof Observable) {
+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;
}
}
}
+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);
});
});
});
+1
View File
@@ -7,6 +7,7 @@ declare global {
interface Window {
electron: {
SENTRY_ENABLED: boolean;
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
changeLanguage: (lang: string) => void;
};
+7 -1
View File
@@ -20,8 +20,11 @@ export const AppConfigSchema = z.object({
theme: z.string(),
auto_refresh: z.boolean(),
refresh_interval: z.number(), // minutes
auto_sync: z.boolean(), // 是否自动同步
auto_sync: z.boolean(),
sync_interval: z.number(), // minutes
auto_startup: z.boolean(),
error_reporting_enabled: z.boolean(),
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
default_export_path: z.string().nullable().optional(), // 导出路径
proxy: ProxyConfigSchema,
});
@@ -37,6 +40,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
refresh_interval: 15,
auto_sync: false,
sync_interval: 5,
auto_startup: false,
error_reporting_enabled: true, // Default to disabled for privacy
privacy_consent_asked: false, // Whether the user has been asked for consent
default_export_path: null,
proxy: {
enabled: false,
+126
View File
@@ -0,0 +1,126 @@
import { app } from 'electron';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from './logger';
import { AppConfig } from '../types/config';
export const AUTO_START_ARG = '--autostart';
const LINUX_AUTOSTART_FILENAME = 'antigravity-manager.desktop';
function getLinuxAutoStartPath() {
const dir = path.join(os.homedir(), '.config', 'autostart');
return path.join(dir, LINUX_AUTOSTART_FILENAME);
}
export function isAutoStartLaunch(argv = process.argv) {
if (process.platform === 'linux') {
return argv.includes(AUTO_START_ARG);
}
if (process.platform === 'win32' || process.platform === 'darwin') {
try {
const settings = app.getLoginItemSettings();
return settings.wasOpenedAtLogin;
} catch (error) {
logger.error('AutoStart: Failed to read login item settings', error);
}
}
return false;
}
function syncWindowsOrMacAutoStart(enabled: boolean) {
try {
app.setLoginItemSettings({
openAtLogin: enabled,
openAsHidden: enabled,
path: process.execPath,
args: enabled ? [AUTO_START_ARG] : [],
});
const settings = app.getLoginItemSettings();
if (settings.openAtLogin !== enabled) {
logger.warn(
`AutoStart: Login item mismatch (expected ${enabled}, actual ${settings.openAtLogin})`,
);
}
logger.info(`AutoStart: Login item set to ${enabled}`);
} catch (error) {
logger.error('AutoStart: Failed to update login item settings', error);
}
}
function cleanupLinuxAutoStartEntries(currentPath: string) {
const autoStartDir = path.dirname(currentPath);
if (!fs.existsSync(autoStartDir)) {
return;
}
const legacyNames = [
`${app.getName()}.desktop`,
`${app.getName().replace(/\s+/g, '')}.desktop`,
'Antigravity Manager.desktop',
'AntigravityManager.desktop',
];
for (const name of legacyNames) {
const candidate = path.join(autoStartDir, name);
if (candidate === currentPath) {
continue;
}
if (fs.existsSync(candidate)) {
try {
fs.unlinkSync(candidate);
logger.info(`AutoStart: Removed legacy autostart entry ${candidate}`);
} catch (error) {
logger.warn('AutoStart: Failed to remove legacy autostart entry', error);
}
}
}
}
function syncLinuxAutoStart(enabled: boolean) {
const autoStartPath = getLinuxAutoStartPath();
if (!enabled) {
if (fs.existsSync(autoStartPath)) {
fs.unlinkSync(autoStartPath);
logger.info('AutoStart: Removed Linux autostart entry');
}
cleanupLinuxAutoStartEntries(autoStartPath);
return;
}
const autoStartDir = path.dirname(autoStartPath);
if (!fs.existsSync(autoStartDir)) {
fs.mkdirSync(autoStartDir, { recursive: true });
}
const execPath = process.execPath.replace(/"/g, '\\"');
const content = [
'[Desktop Entry]',
'Type=Application',
`Name=${app.getName()}`,
`Exec="${execPath}" ${AUTO_START_ARG}`,
'X-GNOME-Autostart-enabled=true',
'Hidden=false',
'NoDisplay=false',
].join('\n');
cleanupLinuxAutoStartEntries(autoStartPath);
fs.writeFileSync(autoStartPath, content, 'utf-8');
logger.info('AutoStart: Created Linux autostart entry');
}
export function syncAutoStart(config: AppConfig) {
const enabled = Boolean(config.auto_startup);
if (process.platform === 'win32' || process.platform === 'darwin') {
syncWindowsOrMacAutoStart(enabled);
return;
}
if (process.platform === 'linux') {
syncLinuxAutoStart(enabled);
}
}
+129
View File
@@ -0,0 +1,129 @@
import path from 'path';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru';
const installNoticeText: Record<
InstallNoticeLanguage,
{
title: string;
message: string;
detailPrefix: string;
buttons: [string, string];
}
> = {
'zh-CN': {
title: '建议从开始菜单启动',
message:
'检测到你正在从非安装路径启动应用。为了确保自动更新生效,请从开始菜单或桌面快捷方式启动。如果没有快捷方式,请重新运行安装包。',
detailPrefix: '安装目录:',
buttons: ['打开安装目录', '知道了'],
},
en: {
title: 'Please launch from the Start menu',
message:
'We detected the app is running from a non-install location. To ensure auto-updates work, launch it from the Start menu or desktop shortcut. If no shortcut exists, run the installer again.',
detailPrefix: 'Install location: ',
buttons: ['Open install folder', 'OK'],
},
ru: {
title: 'Запускайте приложение из меню «Пуск»',
message:
'Обнаружен запуск из неустановленного пути. Чтобы автообновления работали, запускайте приложение из меню «Пуск» или ярлыка. Если ярлыка нет, запустите установщик ещё раз.',
detailPrefix: 'Папка установки: ',
buttons: ['Открыть папку', 'Понятно'],
},
};
export function resolveInstallNoticeLanguage({
configLanguage,
locale,
}: {
configLanguage?: string | null;
locale?: string | null;
}): InstallNoticeLanguage {
const rawLanguage = configLanguage || locale;
if (!rawLanguage) {
return 'en';
}
const normalized = rawLanguage.toLowerCase();
if (normalized.startsWith('zh')) {
return 'zh-CN';
}
if (normalized.startsWith('ru')) {
return 'ru';
}
return 'en';
}
export function getInstallNoticeText(language: InstallNoticeLanguage) {
return installNoticeText[language] || installNoticeText.en;
}
function getPathApi(platform: string) {
if (platform === 'win32') {
return path.win32;
}
return path;
}
function normalizeWindowsInstallDirName(appName: string) {
return appName
.trim()
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
}
export function getExpectedInstallRoot({
platform,
localAppData,
appName,
}: {
platform: string;
localAppData?: string | null;
appName: string;
}) {
if (platform !== 'win32') {
return null;
}
if (!localAppData) {
return null;
}
const pathApi = getPathApi(platform);
const installDirName = normalizeWindowsInstallDirName(appName);
return pathApi.resolve(pathApi.join(localAppData, installDirName));
}
export function isRunningFromExpectedInstallDir({
platform,
isPackaged,
localAppData,
appName,
execPath,
}: {
platform: string;
isPackaged: boolean;
localAppData?: string | null;
appName: string;
execPath: string;
}) {
if (platform !== 'win32' || !isPackaged) {
return true;
}
const expectedRoot = getExpectedInstallRoot({ platform, localAppData, appName });
if (!expectedRoot) {
return true;
}
const pathApi = getPathApi(platform);
const normalizedExecPath = pathApi.resolve(execPath);
return normalizedExecPath.toLowerCase().startsWith(expectedRoot.toLowerCase() + pathApi.sep);
}
+65
View File
@@ -4,6 +4,23 @@ import { getAgentDir } from './paths';
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
const LOG_WINDOW_MS = 30_000;
const MAX_LOG_ENTRIES = 200;
interface LogEntry {
timestamp: number;
level: LogLevel;
message: string;
formatted: string;
}
type SentryReporter = (payload: {
level: LogLevel;
message: string;
error?: Error;
logs: LogEntry[];
}) => void;
/**
* Safely stringify an object, handling circular references
* This prevents "Converting circular structure to JSON" errors
@@ -33,6 +50,9 @@ function safeStringify(obj: unknown): string {
class Logger {
private logFilePath: string;
private recentLogs: LogEntry[] = [];
private sentryReporter: SentryReporter | null = null;
private sentryEnabled = false;
constructor() {
const agentDir = getAgentDir();
@@ -75,10 +95,55 @@ class Logger {
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
}
private pruneLogs(now: number) {
while (this.recentLogs.length > 0 && now - this.recentLogs[0].timestamp > LOG_WINDOW_MS) {
this.recentLogs.shift();
}
if (this.recentLogs.length > MAX_LOG_ENTRIES) {
this.recentLogs = this.recentLogs.slice(-MAX_LOG_ENTRIES);
}
}
private extractError(args: unknown[]): Error | undefined {
for (const arg of args) {
if (arg instanceof Error) {
return arg;
}
}
return undefined;
}
setSentryReporter(reporter: SentryReporter | null) {
this.sentryReporter = reporter;
}
setErrorReportingEnabled(enabled: boolean) {
this.sentryEnabled = enabled;
}
log(level: LogLevel, message: string, ...args: unknown[]) {
const formattedMessage = this.formatMessage(level, message, ...args);
const now = Date.now();
this.recentLogs.push({
timestamp: now,
level,
message,
formatted: formattedMessage,
});
this.pruneLogs(now);
this.writeToConsole(level, message, ...args);
this.writeToFile(formattedMessage);
if (level === 'error' && this.sentryEnabled && this.sentryReporter) {
this.sentryReporter({
level,
message,
error: this.extractError(args),
logs: [...this.recentLogs],
});
}
}
info(message: string, ...args: unknown[]) {
+3 -1
View File
@@ -40,9 +40,11 @@ export class ProtobufUtils {
return this.readVarint(data, offset).nextOffset;
case 1: // 64-bit
return offset + 8;
case 2: // Length-delimited
case 2: {
// Length-delimited
const { value: length, nextOffset } = this.readVarint(data, offset);
return nextOffset + Number(length);
}
case 5: // 32-bit
return offset + 4;
default:
+1 -3
View File
@@ -446,9 +446,7 @@ export async function decryptWithMigration(
);
}
if (reencrypted) {
logger.info(
`Security: Re-encrypted data from ${fallback.source} to ${primary.source}`,
);
logger.info(`Security: Re-encrypted data from ${fallback.source} to ${primary.source}`);
}
return { value, reencrypted, usedFallback: fallback.source };
} catch {
+1
View File
@@ -26,6 +26,7 @@
},
"include": [
"src/**/*",
"types/**/*.d.ts",
"./package.json",
"./forge.config.ts",
"*.mts",
+38
View File
@@ -0,0 +1,38 @@
declare module 'electron-packager-languages' {
type AfterCopyHook = (
buildPath: string,
electronVersion: string,
platform: string,
arch: string,
callback: () => void,
) => void;
interface SetLanguagesOptions {
allowRemovingAll?: boolean;
}
function setLanguages(languages: string[], options?: SetLanguagesOptions): AfterCopyHook;
export = setLanguages;
}
declare module '@pengx17/electron-forge-maker-appimage' {
import type MakerBase from '@electron-forge/maker-base';
import type { ForgePlatform } from '@electron-forge/shared-types';
export interface AppImageForgeConfig {
template?: string;
chmodChromeSandbox?: string;
icons?: { file: string; size: number }[];
}
export interface MakerAppImageConfig {
config?: AppImageForgeConfig;
}
export default class MakerAppImage extends MakerBase<MakerAppImageConfig> {
name: string;
defaultPlatforms: ForgePlatform[];
isSupportedOnCurrentPlatform(): boolean;
}
}
+39 -19
View File
@@ -1,25 +1,45 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import path from 'path';
import { sentryVitePlugin } from '@sentry/vite-plugin';
// https://vitejs.dev/config
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
kafkajs: path.resolve(process.cwd(), './src/mocks/empty.ts'),
mqtt: path.resolve(process.cwd(), './src/mocks/empty.ts'),
amqplib: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'amqp-connection-manager': path.resolve(process.cwd(), './src/mocks/empty.ts'),
nats: path.resolve(process.cwd(), './src/mocks/empty.ts'),
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
release: {
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
},
}),
],
define: {
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
},
},
build: {
sourcemap: true,
rollupOptions: {
external: ['better-sqlite3', 'keytar'],
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
kafkajs: path.resolve(process.cwd(), './src/mocks/empty.ts'),
mqtt: path.resolve(process.cwd(), './src/mocks/empty.ts'),
amqplib: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'amqp-connection-manager': path.resolve(process.cwd(), './src/mocks/empty.ts'),
nats: path.resolve(process.cwd(), './src/mocks/empty.ts'),
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@nestjs/microservices': path.resolve(process.cwd(), './src/mocks/nestjs-microservices'),
'@nestjs/websockets': path.resolve(process.cwd(), './src/mocks/nestjs-websockets'),
},
},
},
build: {
sourcemap: true,
rollupOptions: {
external: ['better-sqlite3', 'keytar'],
},
},
};
});
+30 -3
View File
@@ -1,4 +1,31 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import path from 'path';
// https://vitejs.dev/config
export default defineConfig({});
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
release: {
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
},
}),
],
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
},
},
build: {
sourcemap: true,
rollupOptions: {
external: ['better-sqlite3', 'keytar'],
},
},
};
});
+45 -20
View File
@@ -1,28 +1,53 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import path from 'path';
import { codeInspectorPlugin } from 'code-inspector-plugin';
import { sentryVitePlugin } from '@sentry/vite-plugin';
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
tailwindcss(),
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
codeInspectorPlugin({ bundler: 'vite' }),
],
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(process.cwd(), './src'),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
release: {
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
},
// Electron loads files from app://, so we need to normalize paths for Source Map matching
sourcemaps: {
// Rewrite the source paths to strip the Electron app:// protocol
rewriteSources: (source) => {
// Transform: app:///.vite/renderer/main_window/assets/index.js
// Into: ~/.vite/renderer/main_window/assets/index.js
return source.replace(/^app:\/\/\//, '~/');
},
},
}),
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
tailwindcss(),
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
codeInspectorPlugin({ bundler: 'vite' }),
],
define: {
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
},
},
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(process.cwd(), './src'),
},
},
};
});