Compare commits

..
2 Commits
Author SHA1 Message Date
semantic-release-bot 995d565912 chore(release): 0.3.4-beta.1 [skip ci]
## [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))
2026-01-25 18:04:50 +00:00
Erdogan AbacıandDraculabo 92dc2f6f21 fix(security): add safeStorage fallback for production builds (#38)
* fix(security): add safeStorage fallback for production builds

- Replace hard keytar dependency with multi-strategy key storage:
  1. safeStorage (Electron built-in) - preferred
  2. keytar (system keychain) - fallback
  3. File-based encryption - last resort
- Dynamically import keytar to avoid hard failure in production
- Refactor cloudHandler to use const instead of let (Clean Code)
- Fix 'Failed to load cloud accounts' error in packaged app

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

* refactor(security): address code review feedback

- Replace sync file ops with async (fs/promises)
- Add atomic file writes to prevent data races
- Implement concurrency lock for key generation
- Add SecurityError class with specific error codes:
  - KEY_STORAGE_FAILED, ENCRYPTION_FAILED, DECRYPTION_FAILED
  - INVALID_FORMAT, AUTH_TAG_MISMATCH, CORRUPTED_DATA
- Enhance error categorization in decrypt() for better diagnostics
- Add security warnings for file-based fallback mode

* fix: align error handling after merge

* feat: migrate legacy encrypted account data

* chore: enable beta prerelease workflow *skip release*

---------

Co-authored-by: Draculabo <[email protected]>
2026-01-26 01:53:25 +08:00
60 changed files with 521 additions and 5927 deletions

No files matched your search

-88
View File
@@ -1,88 +0,0 @@
---
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,18 +1,14 @@
---
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.
@@ -20,6 +16,5 @@ 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,17 +1,13 @@
---
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.
@@ -23,7 +19,6 @@ 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,11 +1,8 @@
---
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.
@@ -13,7 +10,6 @@ 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.
@@ -23,7 +19,6 @@ 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'
+1 -5
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, windows-11-arm, macos-latest, macos-14, ubuntu-latest]
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }}
@@ -39,8 +39,4 @@ 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
+4 -1
View File
@@ -1,7 +1,10 @@
name: Release
on:
workflow_dispatch:
push:
branches:
- main
- beta
permissions:
contents: write
-3
View File
@@ -102,6 +102,3 @@ out/
# Tanstack
.tanstack/
# Sentry Config File
.env.sentry-build-plugin
-1
View File
@@ -3,7 +3,6 @@
/coverage
/.vite
README.md
CHANGELOG.md
/src/routeTree.gen.ts
.agents/
openspec/
+1
View File
@@ -106,6 +106,7 @@ 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`
+8 -59
View File
@@ -2,88 +2,37 @@
# 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))
### 📝 Documentation
- update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
### 🐛 Bug Fixes
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
* **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
+8 -1
View File
@@ -33,6 +33,13 @@
</a>
</p>
<p align="center">
<strong>🧪 Beta:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.3-beta.1">
Download pre-release v0.3.3-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" />
@@ -185,7 +192,7 @@ Download the latest release for your platform from the [Releases](https://github
| Platform | Download |
|----------|----------|
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Windows | [.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) |
+8 -1
View File
@@ -33,6 +33,13 @@
</a>
</p>
<p align="center">
<strong>🧪 内测版:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.3-beta.1">
下载 pre-release v0.3.3-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" />
@@ -184,7 +191,7 @@
| 平台 | 下载链接 |
| ------- | ------------------------------------------------------------------------------ |
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
-10
View File
@@ -25,14 +25,4 @@ 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',
},
},
]);
+7 -233
View File
@@ -1,115 +1,19 @@
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 type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
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 { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
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: {
@@ -119,9 +23,6 @@ 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: {
@@ -162,135 +63,8 @@ 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({
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({}),
],
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
publishers: [
{
/*
+298 -3547
View File
File diff suppressed because it is too large. Load diff
+2 -9
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.6.0",
"version": "0.3.4-beta.1",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -25,7 +25,6 @@
"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",
@@ -34,7 +33,6 @@
"@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",
@@ -43,7 +41,6 @@
"@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",
@@ -63,7 +60,6 @@
"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",
@@ -95,6 +91,7 @@
"@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",
@@ -111,9 +108,6 @@
"@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",
@@ -145,7 +139,6 @@
"undici": "^7.16.0",
"update-electron-app": "^3.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.1.12"
}
}
+1 -3
View File
@@ -12,7 +12,6 @@ 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';
@@ -33,7 +32,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
isSwitching,
isDeleting,
}) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
const [transform, setTransform] = useState('');
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
@@ -137,7 +136,6 @@ 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>
+9 -77
View File
@@ -19,7 +19,7 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
import { formatDistanceToNow } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
@@ -50,64 +50,11 @@ 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 (
@@ -222,30 +169,15 @@ export function CloudAccountCard({
<div className="space-y-2">
{modelQuotas.length > 0 ? (
modelQuotas.map(([modelName, info]) => (
<div
key={modelName}
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
>
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
<div key={modelName} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
{modelName.replace('models/', '')}
</span>
<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)}
<div className="flex items-center gap-2">
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
{info.percentage}%
</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>
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
</div>
</div>
))
+1 -8
View File
@@ -176,15 +176,8 @@ 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',
}),
});
};
@@ -394,7 +387,7 @@ export function CloudAccountList() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accounts?.map((account) => (
<CloudAccountCard
key={account.id}
+18 -79
View File
@@ -3,15 +3,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Play, Square, Loader2, Power } from 'lucide-react';
import { Play, Square, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface StatusBarProps {
isCollapsed?: boolean;
}
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
export const StatusBar: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -45,78 +40,26 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
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 overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
'flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors',
isRunning
? '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',
? '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',
)}
>
<div className="flex flex-1 items-start gap-3">
<div className="flex items-center gap-2">
{isLoading ? (
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : isRunning ? (
<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-green-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)]" />
<div className="h-3 w-3 rounded-full bg-red-500" />
)}
<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>
<span>
{isLoading ? t('status.checking') : isRunning ? t('status.running') : t('status.stopped')}
</span>
</div>
<Button
variant="ghost"
@@ -124,22 +67,18 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'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',
'h-8 px-3 hover:bg-white/20',
isRunning ? 'hover:text-green-900' : 'hover:text-red-900',
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : isRunning ? (
<Square className="h-3.5 w-3.5 fill-current" />
<Square className="mr-2 h-4 w-4 fill-current" />
) : (
<Play className="h-3.5 w-3.5 fill-current" />
<Play className="mr-2 h-4 w-4 fill-current" />
)}
<span className="ml-2 font-semibold">
{isRunning ? t('action.stop') : t('action.start')}
</span>
{isRunning ? t('action.stop') : t('action.start')}
</Button>
</div>
);
-28
View File
@@ -1,28 +0,0 @@
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 };
+9 -91
View File
@@ -1,12 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from '@/components/ui/use-toast';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { AppConfig } from '@/types/config';
const SAVE_DEBOUNCE_MS = 400;
import { toast } from '@/components/ui/use-toast';
export function useAppConfig() {
const queryClient = useQueryClient();
@@ -25,105 +20,28 @@ export function useAppConfig() {
const updateConfig = useMutation({
mutationFn: async (newConfig: AppConfig) => {
await ipc.client.config.save(newConfig);
return newConfig;
},
});
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;
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
toast({
title: 'Settings saved',
description: 'Your configuration has been updated.',
});
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'] });
}
},
onError: (err) => {
toast({
title: 'Error saving settings',
description: error.message,
description: err.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,
saveConfig: updateConfig.mutateAsync,
isSaving: updateConfig.isPending,
};
}
+2 -6
View File
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
return useMutation({
mutationFn: refreshAccountQuota,
onSuccess: (updatedAccount: CloudAccount) => {
// Optimistically update
// Optimistically update the list or just invalidate?
// Invalidating is safer as sorting might change (last_used)
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,12 +97,8 @@ export function useSetAutoSwitchEnabled() {
}
export function useForcePollCloudMonitor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forcePollCloudMonitor,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
},
});
}
-60
View File
@@ -1,60 +0,0 @@
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,8 +123,6 @@ 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) {
@@ -145,8 +143,6 @@ 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(
+2 -9
View File
@@ -1,20 +1,13 @@
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 async function saveConfig(config: AppConfig): Promise<void> {
export function saveConfig(config: AppConfig): void {
// Logic to notify proxy server if configuration changes (hot update)
// Logic to update Tray if language changes
// For now just save
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);
}
ConfigManager.saveConfig(config);
}
+12 -29
View File
@@ -7,9 +7,6 @@ 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)) {
@@ -23,7 +20,6 @@ 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;
}
@@ -32,7 +28,7 @@ export class ConfigManager {
// Merge with default to ensure new fields are present
// Zod parse helps validate
const merged: AppConfig = {
const merged = {
...DEFAULT_APP_CONFIG,
...raw,
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
@@ -49,35 +45,22 @@ export class ConfigManager {
// Handle Anthropic Mapping Map vs Object
// In JSON it's object
this.cachedConfig = merged;
return merged;
return merged as AppConfig;
} catch (e) {
logger.error('Config: Failed to load config', e);
this.cachedConfig = DEFAULT_APP_CONFIG;
return DEFAULT_APP_CONFIG;
}
}
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;
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;
}
}
}
+1 -1
View File
@@ -12,6 +12,6 @@ export const configRouter = os.router({
.input(AppConfigSchema)
.output(z.void())
.handler(async ({ input }) => {
await saveConfig(input);
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;
await ConfigManager.saveConfig(config);
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`));
}
}, 60000);
}, 10000);
});
}
+3 -1
View File
@@ -100,7 +100,9 @@ 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
+28 -108
View File
@@ -1,34 +1,14 @@
import React, { useState, useEffect } from 'react';
import React 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,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { LayoutDashboard, Settings, Network, Rocket } 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: '/',
@@ -51,102 +31,42 @@ 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={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">
<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">
<Rocket className="h-4 w-4" />
</div>
<div
className={cn(
'overflow-hidden transition-all duration-300',
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
)}
>
<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>
Antigravity
</h1>
<p className="text-muted-foreground mt-1 text-xs">Manager</p>
</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 className="flex-1 space-y-2 px-4">
{navItems.map((item) => (
<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',
location.pathname === item.to
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
<div className="border-t p-2">
<StatusBar isCollapsed={isCollapsed} />
<div className="border-t p-4">
<StatusBar />
</div>
</aside>
{/* Content Area */}
<main className="flex-1 overflow-auto transition-all duration-300">
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
+3 -3
View File
@@ -51,7 +51,7 @@ export function transformClaudeRequestIn(
const toolIdToName = new Map<string, string>();
// 1. System Instruction
const systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
// Map model name
const mappedModel = hasWebSearchTool
@@ -100,7 +100,7 @@ export function transformClaudeRequestIn(
);
// 3. Tools
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
// 5. Safety Settings
const safetySettings = [
@@ -152,7 +152,7 @@ export function transformClaudeRequestIn(
delete innerRequest.systemInstruction;
// 3. Clean generationConfig
const genConfig = innerRequest.generationConfig || {};
let 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 (Object.prototype.hasOwnProperty.call(map, k)) {
if (map.hasOwnProperty(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 (Object.prototype.hasOwnProperty.call(map, k)) {
if (map.hasOwnProperty(k)) {
cleanJsonSchemaRecursive(map[k]);
}
}
+3 -369
View File
@@ -12,7 +12,7 @@ i18n
caches: ['localStorage'],
lookupLocalStorage: 'lang',
},
supportedLngs: ['en', 'zh-CN', 'ru'],
supportedLngs: ['en', 'zh-CN'],
load: 'currentOnly', // Only load the exact language code, not variants
resources: {
en: {
@@ -83,7 +83,6 @@ i18n
description: 'Select your preferred language.',
english: 'English',
chinese: 'Chinese (Simplified)',
russian: 'Russian',
},
about: {
title: 'About',
@@ -101,23 +100,6 @@ 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.',
@@ -241,9 +223,6 @@ 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.',
@@ -266,7 +245,6 @@ i18n
},
quotaRefreshed: 'Quota refreshed',
refreshFailed: 'Failed to refresh quota',
pollFailed: 'Failed to poll quota for all accounts',
switched: {
title: 'Account switched!',
description: 'Restarting Antigravity...',
@@ -315,10 +293,6 @@ 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',
@@ -354,10 +328,8 @@ 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: '旧版账号数据无法解密。',
@@ -401,7 +373,6 @@ i18n
description: '选择您的首选语言。',
english: '英语',
chinese: '中文 (简体)',
russian: '俄语',
},
about: {
title: '关于',
@@ -419,21 +390,6 @@ 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 的请求配置代理。',
@@ -555,9 +511,6 @@ i18n
refresh: '刷新配额',
delete: '删除账号',
noQuota: '无配额数据',
resetPrefix: '重置',
resetTime: '重置时间',
resetUnknown: '未知',
},
list: {
noAccounts: '暂无云账号。',
@@ -580,7 +533,6 @@ i18n
},
quotaRefreshed: '配额已刷新',
refreshFailed: '刷新配额失败',
pollFailed: '轮询所有账号配额失败',
switched: {
title: '账号已切换!',
description: '正在重启 Antigravity...',
@@ -646,323 +598,5 @@ 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: 'Примеры использования локального прокси.',
},
},
},
},
},
});
+6 -132
View File
@@ -1,21 +1,13 @@
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 { app, BrowserWindow } 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';
@@ -26,8 +18,6 @@ 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');
@@ -66,11 +56,6 @@ 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
@@ -78,81 +63,6 @@ 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}`);
@@ -165,22 +75,7 @@ process.on('before-exit', (code) => {
// let tray: Tray | null = null; // Moved to tray/handler.ts
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;
}
function createWindow() {
logger.info('createWindow: start');
const preload = path.join(__dirname, 'preload.js');
logger.info(`createWindow: preload path: ${preload}`);
@@ -189,7 +84,6 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: !startHidden,
autoHideMenuBar: true,
webPreferences: {
devTools: inDevelopment,
@@ -205,10 +99,6 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
});
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);
@@ -223,7 +113,6 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
}
logger.info('Window created');
showWindowsInstallNoticeIfNeeded();
mainWindow.on('close', (event) => {
if (!isQuitting) {
@@ -237,7 +126,6 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
mainWindow.on('closed', () => {
logger.info('Window closed event triggered');
globalMainWindow = null;
});
mainWindow.webContents.on('render-process-gone', (event, details) => {
@@ -256,10 +144,6 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
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) => {
@@ -353,23 +237,13 @@ 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({ startHidden: shouldStartHidden });
await createWindow();
})
.then(() => {
logger.info('Step: installExtensions (SKIPPED)');
@@ -386,7 +260,7 @@ app
AuthServer.start();
// Gateway Server (NestJS) - auto-start if enabled
const config = startupConfig || ConfigManager.loadConfig();
const config = 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
@@ -428,7 +302,7 @@ app.on('window-all-closed', () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow({ startHidden: false });
createWindow();
}
});
//osX only ends
-1
View File
@@ -1 +0,0 @@
export {};
@@ -1 +0,0 @@
export {};
-1
View File
@@ -1 +0,0 @@
export {};
@@ -1 +0,0 @@
export {};
-39
View File
@@ -1,44 +1,6 @@
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;
@@ -48,7 +10,6 @@ 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,12 +1 @@
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,7 +33,6 @@ function SettingsPage() {
// Sync config to local state when loaded
useEffect(() => {
if (config) {
// eslint-disable-next-line
setProxyConfig(config.proxy);
}
}, [config]);
@@ -48,10 +47,6 @@ function SettingsPage() {
queryFn: getPlatform,
});
const isAutoStartSupported =
platform === 'win32' || platform === 'darwin' || platform === 'linux';
const isMac = platform === 'darwin';
const handleLanguageChange = (value: string) => {
setAppLanguage(value, i18n);
};
@@ -125,7 +120,6 @@ 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>
@@ -185,49 +179,6 @@ 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>
@@ -255,46 +206,6 @@ 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,24 +44,6 @@ 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) {
+32 -99
View File
@@ -6,28 +6,17 @@ 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.startInterval();
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
}
static stop() {
@@ -38,95 +27,39 @@ export class CloudMonitorService {
}
}
/**
* Called when the application window gains focus.
* Triggers an immediate poll if not rate-limited by debounce.
*/
static async handleAppFocus() {
const now = Date.now();
// 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;
}
// 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;
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
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
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;
}
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
} finally {
this.isPolling = false;
// 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
}
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
}
}
-194
View File
@@ -1,194 +0,0 @@
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,7 +7,6 @@ declare global {
interface Window {
electron: {
SENTRY_ENABLED: boolean;
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
changeLanguage: (lang: string) => void;
};
+1 -7
View File
@@ -20,11 +20,8 @@ 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,
});
@@ -40,9 +37,6 @@ 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
@@ -1,126 +0,0 @@
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
@@ -1,129 +0,0 @@
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,23 +4,6 @@ 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
@@ -50,9 +33,6 @@ 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();
@@ -95,55 +75,10 @@ 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[]) {
+1 -3
View File
@@ -40,11 +40,9 @@ 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:
+3 -1
View File
@@ -446,7 +446,9 @@ 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,7 +26,6 @@
},
"include": [
"src/**/*",
"types/**/*.d.ts",
"./package.json",
"./forge.config.ts",
"*.mts",
-38
View File
@@ -1,38 +0,0 @@
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;
}
}
+19 -39
View File
@@ -1,45 +1,25 @@
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import path from 'path';
import { sentryVitePlugin } from '@sentry/vite-plugin';
// https://vitejs.dev/config
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),
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'),
},
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'],
},
build: {
sourcemap: true,
rollupOptions: {
external: ['better-sqlite3', 'keytar'],
},
},
};
},
});
+3 -30
View File
@@ -1,31 +1,4 @@
import { defineConfig, loadEnv } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import path from 'path';
import { defineConfig } from 'vite';
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'],
},
},
};
});
// https://vitejs.dev/config
export default defineConfig({});
+19 -44
View File
@@ -1,53 +1,28 @@
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } 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(({ 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'),
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'),
},
};
},
});