Compare commits

..
43 Commits
Author SHA1 Message Date
semantic-release-bot ad39a735b5 chore(release): 0.3.2 [skip ci]
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)

### 🐛 Bug Fixes

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

### 📝 Documentation

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

### 🔧 Continuous Integration

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

### 🐛 Bug Fixes

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

### 📝 Documentation

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

### 🔧 Continuous Integration

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

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

Fixes #23

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

* Fix: Address code review feedback

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

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

---------

Co-authored-by: Jirka <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-19 16:11:48 +08:00
Draculabo f5c44c549f chore: release v0.2.0
Publish Release / publish (ubuntu-latest) (push) Failing after 6m12s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-16 22:26:55 +08:00
Draculabo f37127d385 build: conditionally include plugins based on start command in forge.config.ts 2026-01-16 22:00:33 +08:00
Draculabo f636dd61a5 feat: enhance cloudHandler to inject minimal auth state when database entry is missing 2026-01-16 20:30:49 +08:00
Draculabo 42973561fb chore: upgrade electron from 32.3.3 to 37.3.1 2026-01-16 02:58:26 +08:00
Draculabo 5772e2e90e feat: implement stability fixes and enhance error handling 2026-01-16 02:51:29 +08:00
Draculabo b0ec96d4a6 fix: Converting circular structure to JSON 2026-01-15 22:58:05 +08:00
Draculabo 5a004d9d16 docs: add curly brace constraints for conditional statements 2026-01-15 22:54:23 +08:00
Draculabo ccc5c91969 docs: fix incorrect reference documentation name 2026-01-15 22:37:35 +08:00
Draculabo dcfed908a5 chore: reelease 0.1.1
Publish Release / publish (ubuntu-latest) (push) Failing after 5m50s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-11 20:48:27 +08:00
Draculabo 1336de679f fix: resolve Antigravity visibility issue on account switch 2026-01-11 20:39:29 +08:00
Draculabo bc43cc101c docs: add AGENTS.md for AI assistant guidance 2026-01-10 04:45:59 +08:00
Draculabo 01f6a5aaee docs: update v0.1.0 changelog 2026-01-10 04:41:39 +08:00
Draculabo f8e06efcca docs: update readme 2026-01-10 04:35:37 +08:00
Draculabo 13f02c7ac2 feat: update logo image 2026-01-10 04:26:07 +08:00
Draculabo b425dc3758 feat: update license 2026-01-10 04:24:03 +08:00
Draculabo fb13826afd feat: identity injection 2026-01-10 04:08:55 +08:00
98 changed files with 9591 additions and 931 deletions

No files matched your search

+3 -4
View File
@@ -1,9 +1,8 @@
name: Publish Release
on:
push:
tags:
- 'v*'
release:
types: [published]
workflow_dispatch:
jobs:
@@ -11,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }}
+37
View File
@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
- master
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for semantic-release to analyze commits
persist-credentials: false # We use a token in the next steps
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
run: npx semantic-release
+3 -1
View File
@@ -3,4 +3,6 @@
/coverage
/.vite
README.md
/src/routeTree.gen.ts
/src/routeTree.gen.ts
.agents/
openspec/
-1
View File
@@ -8,5 +8,4 @@
"tabWidth": 2,
"proseWrap": "preserve",
"tailwindFunctions": ["clsx", "tw"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}
+490 -1
View File
@@ -1,18 +1,507 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
<!-- OPENSPEC:END -->
# 用户指南
本文是 AI 助手在本仓库工作的用户级指南。中文为主、工具与脚本偏好统一、并纳入你补充的技术栈与开发注意事项。
## 💬 沟通约定
- **语言**: 对话、待办、代码相关内容(注释、UI 文案、提交信息、PR 描述等)统一使用中文。
- **结论先行**: 回答先给出核心结论/摘要,再补充细节。
- **引用**: 引用具体代码时,务必给出完整文件路径(如 `src/main.ts:42`)。
## 💻 运行环境与工具
- **Runtime**: Node.js (Electron 环境)
- **Node**: 建议 Node.js 20+
- **包管理器**: `npm` (本项目包含 package-lock.json,强制使用 npm)
- **构建工具**: Electron Forge + Vite
- **终端环境**: Windows (PowerShell) / 可安全使用 VSCode MCP 工具
## 🧩 技术栈总览 (Tech Stack)
- **Frontend**:
- React 19, TypeScript
- Tailwind CSS v4, `clsx`, `tailwind-merge`, `tailwindcss-animate`
- Radix UI (Primitives), Lucide React (Icons), Sonner (Toast)
- TanStack Router (Routing), TanStack Query (State Management)
- Components: 模块化设计,`src/components`
- **Backend (Electron Main/Server)**:
- Electron (Main/Preload/Renderer 架构)
- NestJS (用于内部代理/网关服务,由 Main Process 启动)
- Better-SQLite3 (本地数据库), Drizzle ORM (如果后续引入) / Raw SQL
- ORPC (Type-safe RPC)
- Zod (Validation)
- **Testing**:
- Vitest (Unit/Integration Tests)
- Playwright (E2E Tests)
## 📁 目录结构
```plaintext
.
├─ src/
│ ├─ components/ # React UI 组件 (ui/ 存放基础组件)
│ ├─ hooks/ # Custom React Hooks
│ ├─ ipc/ # Electron IPC 处理逻辑 (Database, Config, etc.)
│ ├─ layouts/ # 页面布局组件
│ ├─ lib/ # 通用工具库
│ ├─ routes/ # TanStack Router 路由定义
│ ├─ server/ # NestJS 后端服务逻辑 (Gateway/Proxy)
│ ├─ services/ # 业务服务层
│ ├─ styles/ # 全局样式 (Tailwind class)
│ ├─ types/ # TypeScript 类型定义
│ ├─ utils/ # 通用工具函数
│ ├─ App.tsx # React 应用入口
│ ├─ main.ts # Electron 主进程入口
│ ├─ preload.ts # Electron 预加载脚本
│ └─ renderer.ts # Electron 渲染进程入口
├─ forge.config.ts # Electron Forge 配置
└─ package.json
```
## 🧱 组件架构(Component Architecture)
- **模块化组件**: 每个组件使用独立目录,至少包含 `.tsx` 以及可能的样式或子组件。
- **共享能力**: 通用函数置于 `src/utils/`,底层通用封装置于 `src/lib/`。
- **服务层**: 统一在 `src/services/` 或 `src/ipc/` 中封装数据访问,前端仅依赖 IPC 或 RPC 调用。
## 📦 常用脚本 (Scripts)
所有命令使用 `npm` 执行:
- **开发 (Dev)**:
- `npm start` - 启动 Electron 开发环境 (Electron Forge)
- `npm run lint` - 运行 ESLint 检查
- `npm run format` - 运行 Prettier 格式化检查
- **构建 (Build)**:
- `npm run package` - 打包应用 (生成的只有应用包)
- `npm run make` - 构建并生成分发安装包 (Make)
- `npm run publish` - 发布应用
- **测试 (Test)**:
- `npm test` - 运行 Vitest 单元测试
- `npm run test:unit` - 同上
- `npm run test:e2e` - 运行 Playwright 端到端测试
- `npm run test:all` - 运行所有测试
### 运行单个测试 (Running Single Tests)
- 单元测试: `npm run test:unit path/to/test.test.ts`
- E2E 测试: `npm run test:e2e path/to/test.spec.ts`
- 类型检查: `npm run type-check`
## 🧪 开发注意事项(Development Notes)
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
- **React**: React Strict Mode 关闭。
- **NestJS**: 作为 Electron 子进程运行,日志输出在主进程控制台。
## �️ 安全与数据 (Security & Data)
- **安全**: 绝不提交密钥;敏感配置使用环境变量;验证所有用户输入;敏感数据加密存储。
- **数据库**: 使用 Better-SQLite3;所有操作封装在 Services 层;必须使用 Prepared Statements;独立测试 DB 操作。
- **国际化**: 使用 `react-i18next`;Key 使用 kebab-case;翻译文件存放在 `src/localization/`。
## �📝 代码规范 (Conventions)
- **文件命名**:
- 组件: PascalCase (e.g., `Button.tsx`)
- 工具/配置: camelCase 或 kebab-case
- **导入路径**: 使用 `@/` 别名指向 `src/` 目录。
- **类型安全**: 严禁使用 `any`,利用 Zod 和 TypeScript 确保全链路类型安全。
- **组件设计**:
- 优先使用 Radix UI Primitives。
- 样式使用 Tailwind Utility Classes,避免 CSS Modules (除非必要)。
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
### 命名约定 (Naming Specifics)
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
- **Files**:
- Services: `ServiceName.service.ts`
- Types: `type-name.ts`
### 导入组织 (Import Organization)
```typescript
// 1. React 和核心库
import React, { useEffect } from 'react';
import { createRoot } from 'react-dom/client';
// 2. 外部依赖 (按字母顺序)
import { useTranslation } from 'react-i18next';
import { formatDistanceToNow } from 'date-fns';
// 3. 内部导入 (使用 @ 别名)
import { Account } from '@/types/account';
import { Card, CardContent } from '@/components/ui/card';
```
### 组件结构 (Component Structure)
```typescript
// 1. 导入
import React, { useState } from 'react';
// 2. 类型定义
interface ComponentProps { /* props */ }
// 3. 组件实现
export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
// 4. Hooks
const { t } = useTranslation();
// 5. Render
return <div>{/* JSX */}</div>;
};
```
> 提交前请确保执行 `npm run lint` 和 `npm run format`。
## 📝 终端输出与引用规范
- 代码块优先,避免 Markdown 表格与 mermaid(Claude 代码块渲染更稳定)。
- 表格(如需)左对齐;中文字符等宽注意显示差异。
示例:
```plaintext
+------+---------+---------+
| ID | Name | Role |
+------+---------+---------+
| 1 | Alice | Admin |
| 2 | Bob | User |
+------+---------+---------+
```
### 引用规范(References)
- 外部资源:使用完整可点击链接(Issue、文档、API 参考)。
- 源码位置:使用完整文件路径(可附行号)。
示例:
```plaintext
- “resolveFilePath 负责该逻辑”
- “VSCode 在撤销操作上存在既知限制”
🔗 References:
- resolveFilePath: src/utils/workspace.ts:40
- VSCode undo limitation: https://github.com/microsoft/vscode/issues/77190
```
## 🏷️ Markdown 书写
- 代码块语言不要留空,无法确定时使用 `plaintext`。
- 标题后保留一行空行,便于渲染与阅读。
## 换行规范
return 等语句不要与其他语句在同一行,而是单独保持一行
## 💭 注释规范
- 必注释场景:复杂业务/算法、特殊行为、重要设计取舍、关键参考链接。
- 原则:
- 注释“为什么”(Why),非“做了什么”(What)、非变更记录(Changelog)。
- 修改代码时同步更新注释(过时注释比无注释更糟)。
- 优先使用 JSDoc,复杂函数先给高层概览,函数体内按步骤(1、2、3…)标注关键过程。
- 中英文之间适当空格,提升可读性;不要为删除的旧代码写注释。
“质量自检”问题:六个月后新同事看到这条注释能得到什么有用信息?若答案是“没有”,删掉它。
示例:
```typescript
/**
* 处理支付请求,多步骤校验
*/
function processPayment(request: PaymentRequest) {
// 1. 数据校验
// 2. 风控评估(低/中/高差异处理)
// 3. 网关调用
// 4. 用户通知
}
export enum BudgetType {
Free = 'free',
/** ✅ 推荐使用 JSDoc,而不是行尾注释 */
Package = 'package',
}
```
## 🛠️ 开发指南
### 通用原则
- 优先稳定与可维护性,其次再谈性能优化。
- 面对不确定性:明确假设、取舍与验证方案;先沟通再实施。
- 信任既定前置条件,避免对承诺不变量的过度防御;若出现冲突,更新方案而非额外兜底。
- 老代码重构保守推进,新功能实现可采用更现代方式。
- 避免过早优化:先以简单直接的实现达成功能,确需时再优化(避免先行加缓存/抖动或拆分成过多文件)。
- 对于 if,while 等条件、循环语句必须加上花括号,不能与return、break、continue 等语句同级。
### 错误处理 (Error Handling)
```typescript
// 异步操作使用 try-catch
try {
const result = await someOperation();
return result;
} catch (error) {
console.error('Operation failed:', error);
throw new Error('Failed to complete operation');
}
// 使用正确的错误类型
if (error instanceof Error) {
/* 处理 Error 实例 */
}
```
### 新功能实现
- 代码应清晰、可读、可复用、有效率且可测试。
- 倾向选用成熟可靠的现代 API。
### 重构与修 Bug
- 倾向增量式改动;如需大重构,先就范围对齐再推进。
- 保持原结构与风格,避免过度抽象引入新风险。
### 开发生命周期(Checklist)
探索/规划:
- \[ ] 充分理解需求,分步思考与列计划(3–6 步)
- \[ ] 优先查阅文档与既有方案
- \[ ] 通过阅读实际代码验证想法
- \[ ] 形成 TODO 列表
实现/重构/修复:
- [ ] 阅读相关模板与周边代码,沿用既有模式
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
- [ ] 在约束内尽力提升前端交互与体验
验收/校验:
- \[ ] 通过测试或临时脚本验证实现
- \[ ] 多次增量修改后回看是否可合并为一次更连贯的修改
- \[ ] 运行质量检查
- \[ ] 更新相关文档
总结/输出:
- \[ ] 检查输出格式要求
- \[ ] 列出与原计划偏差与关键决策,便于人工复核
- \[ ] 提供优化建议
- \[ ] 在结尾提供完整引用链接
## 🔍 代码质量与 Lint
- 变量命名具描述性(如 `mutationObserver`、`button`、`element`),避免 `mo`、`btn`、`el`。
- 检查缺失的重要注释,并保持注释语言一致。
- 使用 VSCode MCP 诊断工具查看 TS/ESLint 报告并修复关键问题。
- 若新增/修改测试,需运行并修复测试后再提交。
## ⛔ 需要显式确认的操作
- 运行具有破坏性的命令
- 执行 `git commit`、`git push`
- 新建测试文件(先由维护者进行人工审核)
## 🔧 工具偏好与命令
包与脚本:
- `npm install` (或简写 `npm i`)
Shell:
- 在工作区根目录执行命令。
- 路径建议使用引号包裹。
Web 搜索:
- `WebSearch` 获取最新内容;不足时用 `mcp__SearXNG__search` 聚合搜索
文档/用法检索:
- `context7` 获取依赖的最新用法
VSCode MCP (如可用):
- `mcp__vscode-mcp__get_references` 辅助重构范围判定
- `mcp__vscode-mcp__rename_symbol` 安全重命名
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
## 🚨 本地质量检查(可选流程)
在完成一组修改后并行执行三类检查,而非直接跑全量 lint:
```plaintext
Task(subagent_type: "quick-code-review", description: "Code review", prompt: "[change description]")
Task(subagent_type: "diagnostics", description: "Diagnostics", prompt: "[same as above]")
Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[same as above]")
```
change description 示例:
```plaintext
- Modified files: 相对路径列表
- Context: 需求/业务背景
```
流程:初检 → 修复关键问题 → 复检 → 迭代至关键问题清零。
注意:上述工具为只读分析,需自行修复;传入精确文件路径,避免用笼统目录。
<skills_system priority="1">
## Available Skills
<!-- SKILLS_TABLE_START -->
<usage>
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke: Bash("openskills read <skill-name>")
- The skill content will load with detailed instructions on how to complete the task
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
Usage notes:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already loaded in your context
- Each skill invocation is stateless
</usage>
<available_skills>
<skill>
<name>algorithmic-art</name>
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
<location>project</location>
</skill>
<skill>
<name>brand-guidelines</name>
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
<location>project</location>
</skill>
<skill>
<name>canvas-design</name>
<description>Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.</description>
<location>project</location>
</skill>
<skill>
<name>doc-coauthoring</name>
<description>Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.</description>
<location>project</location>
</skill>
<skill>
<name>docx</name>
<description>"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"</description>
<location>project</location>
</skill>
<skill>
<name>frontend-design</name>
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
<location>project</location>
</skill>
<skill>
<name>internal-comms</name>
<description>A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).</description>
<location>project</location>
</skill>
<skill>
<name>mcp-builder</name>
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
<location>project</location>
</skill>
<skill>
<name>pdf</name>
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.</description>
<location>project</location>
</skill>
<skill>
<name>pptx</name>
<description>"Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"</description>
<location>project</location>
</skill>
<skill>
<name>skill-creator</name>
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
<location>project</location>
</skill>
<skill>
<name>slack-gif-creator</name>
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
<location>project</location>
</skill>
<skill>
<name>theme-factory</name>
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
<location>project</location>
</skill>
<skill>
<name>web-artifacts-builder</name>
<description>Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.</description>
<location>project</location>
</skill>
<skill>
<name>webapp-testing</name>
<description>Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.</description>
<location>project</location>
</skill>
<skill>
<name>xlsx</name>
<description>"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"</description>
<location>project</location>
</skill>
</available_skills>
<!-- SKILLS_TABLE_END -->
</skills_system>
+117 -9
View File
@@ -1,3 +1,36 @@
<a name="readme-top"></a>
# Changelog
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
### 🐛 Bug Fixes
* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
### 📝 Documentation
* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
### 🔧 Continuous Integration
* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
* trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
### 🐛 Bug Fixes
* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
### 📝 Documentation
* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
### 🔧 Continuous Integration
* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
# Changelog
All notable changes to this project will be documented in this file.
@@ -5,20 +38,95 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.1] - 2026-01-25
### Added
- Bilingual README (English and Simplified Chinese)
- Project Logo
- GitHub Issue Templates (Bug Report and Feature Request)
- Code of Conduct
- Star History chart in README
- Detailed FAQ section in README
- Technical stack and project structure documentation
### Bug Fixes
- Fixed startup race condition causing cloud accounts verify failure ([f0718db]
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
## [0.3.0] - 2026-01-23
### New Features
- Verify Google OAuth code automatically after receipt
- Add button to open logs folder
- Add expiration warning for Google OAuth authentication
### Bug Fixes
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
### Maintenance
- Add VS Code settings for auto-formatting and ESLint
## [0.2.2] - 2026-01-19
### Bug Fixes
- Fixed tray icon not appearing in production builds on Windows
- Used `extraResource` config to properly copy assets outside of ASAR package
- Added debug logging for tray icon path resolution
## [0.2.1] - 2026-01-19
### Bug Fixes
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
### Maintenance
- Applied Prettier formatting to entire codebase (68 files)
- Added node globals to ESLint configuration
## [0.2.0] - 2026-01-16
### New Features
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
- Implemented stability fixes and enhanced error handling across the application.
### Improvements
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
### Bug Fixes
- Fixed "Converting circular structure to JSON" error.
### Documentation
- Added curly brace constraints for conditional statements.
- Fixed incorrect reference documentation name.
## [0.1.1] - 2026-01-11
### Bug Fixes
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
## [0.1.0] - 2026-01-10
### New Features
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
### Bug Fixes
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
## [0.0.1] - 2025-12-22
### Added
- Initial release of Antigravity Manager
- Multi-account management for Google Gemini and Claude
- Real-time quota monitoring
+1
View File
@@ -0,0 +1 @@
请始终参考 @AGENTS.md 文件
+11 -10
View File
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
@@ -45,4 +45,5 @@ This Code of Conduct is adapted from the [Contributor Covenant][homepage], versi
[homepage]: https://www.contributor-covenant.org
---
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
+14 -10
View File
@@ -52,20 +52,21 @@ npm start
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
| Command | Description |
| ---------------------- | ----------------------------------- |
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
## ✏️ Making Changes
1. **Create a new branch** from `main`:
```bash
git checkout -b feature/your-feature-name
# or
@@ -73,6 +74,7 @@ npm start
```
2. **Make your changes** and commit them:
```bash
git add .
git commit -m "feat: add amazing new feature"
@@ -97,6 +99,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
@@ -107,6 +110,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
- `chore`: Changes to build process or auxiliary tools
**Examples:**
```
feat(auth): add Google OAuth support
fix(quota): resolve quota refresh timeout issue
+43
View File
@@ -64,12 +64,14 @@ When using Antigravity IDE, have you ever encountered these problems?
- 🔄 Managing multiple Google/Claude accounts is cumbersome
- 📊 Don't know how much quota is left on the current account
- ⏰ Worried about missing quota reset times
- 🔌 Need a reliable local API proxy for development tools
**Antigravity Manager** is here to solve these problems! It's a professional Electron desktop app that helps you:
- ✅ **Unlimited Account Pool** - Add any number of Google Gemini / Claude accounts
- ✅ **Smart Auto-Switching** - Automatically switch to the next available account when quota is low or rate-limited
- ✅ **Real-time Monitoring** - Visualize quota usage for all accounts
- ✅ **Local API Proxy** - Built-in OpenAI/Anthropic compatible proxy server
- ✅ **Secure Encryption** - AES-256-GCM encryption for sensitive data
---
@@ -131,6 +133,24 @@ When using Antigravity IDE, have you ever encountered these problems?
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔌 Local API Proxy</h3>
<ul>
<li>OpenAI & Anthropic API compatible</li>
<li>Configurable port and request timeout</li>
<li>Model mapping (e.g. Claude → Gemini)</li>
</ul>
</td>
<td width="50%">
<h3>🛠️ Developer Tools</h3>
<ul>
<li>Built-in cURL & Python code generation</li>
<li>Visual service status monitoring</li>
<li>One-click API Key regeneration</li>
</ul>
</td>
</tr>
</table>
### Additional Features
@@ -149,6 +169,11 @@ When using Antigravity IDE, have you ever encountered these problems?
<img src="docs/assets/screenshot-main.png" alt="Main Interface" width="80%" />
</p>
<p align="center">
<img src="docs/assets/screenshot-proxy.png" alt="Proxy Interface" width="48%" />
<img src="docs/assets/screenshot-setting.png" alt="Settings Interface" width="48%" />
</p>
---
@@ -255,6 +280,24 @@ Please check:
</details>
<details>
<summary><b>Q: macOS shows Keychain/Credential error and OAuth cannot be saved?</b></summary>
This is a common macOS security behavior, usually when the app is unsigned or run directly from Downloads.
This is a **temporary workaround** for personal use:
1. Move the app to `/Applications`
2. Run the following commands in Terminal (repeat after every update)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
Reopen the app and allow Keychain access if prompted.
</details>
<details>
<summary><b>Q: How to report issues or suggestions?</b></summary>
+64 -21
View File
@@ -64,12 +64,14 @@
- 🔄 管理多个 Google/Claude 账号非常麻烦
- 📊 不知道当前账号还剩多少额度
- ⏰ 担心错过额度重置时间
- 🔌 需要可靠的本地 API 代理用于开发工具
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
- ✅ **本地 API 代理** - 内置兼容 OpenAI/Anthropic 协议的代理服务器
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
---
@@ -131,6 +133,24 @@
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔌 本地 API 代理</h3>
<ul>
<li>兼容 OpenAI & Anthropic API 协议</li>
<li>可配置端口和请求超时</li>
<li>模型映射(如:Claude → Gemini)</li>
</ul>
</td>
<td width="50%">
<h3>🛠️ 开发者工具</h3>
<ul>
<li>内置 cURL & Python 代码生成</li>
<li>可视化服务状态监控</li>
<li>一键重新生成 API Key</li>
</ul>
</td>
</tr>
</table>
### 更多功能
@@ -149,6 +169,10 @@
<img src="docs/assets/screenshot-main.png" alt="主界面" width="80%" />
</p>
<p align="center">
<img src="docs/assets/screenshot-proxy.png" alt="代理界面" width="48%" />
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
</p>
---
@@ -158,11 +182,11 @@
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
| 平台 | 下载链接 |
|------|----------|
| 平台 | 下载链接 |
| ------- | ------------------------------------------------------------------------------ |
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
### 从源码构建
@@ -192,14 +216,14 @@ npm run make
## 🛠️ 技术栈
| 类别 | 技术 |
|------|------|
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
| 类别 | 技术 |
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
---
@@ -207,16 +231,16 @@ npm run make
### 可用脚本
| 命令 | 描述 |
|------|------|
| `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 |
| 命令 | 描述 |
| ---------------------- | ------------------------ |
| `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 |
| `npm run format:write` | 使用 Prettier 格式化代码 |
| `npm run test:unit` | 运行单元测试 |
| `npm run test:e2e` | 运行 E2E 测试 |
| `npm run test:all` | 运行所有测试 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run make` | 构建生产包 |
| `npm run test:unit` | 运行单元测试 |
| `npm run test:e2e` | 运行 E2E 测试 |
| `npm run test:all` | 运行所有测试 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run make` | 构建生产包 |
### 项目结构
@@ -240,6 +264,7 @@ AntigravityManager/
<summary><b>Q: 程序无法启动怎么办?</b></summary>
请检查:
1. 确保已安装所有依赖:`npm install`
2. 检查 Node.js 版本是否 >= 18
3. 尝试删除 `node_modules` 后重新安装
@@ -255,6 +280,24 @@ AntigravityManager/
</details>
<details>
<summary><b>Q: macOS 上提示 Keychain/凭据管理不可用,OAuth 无法保存?</b></summary>
这是 macOS 安全机制导致的常见问题,通常出现在未签名的应用或从 Downloads 直接运行的场景。
这是**临时方案**,仅适合个人设备:
1. 将应用移动到 `/Applications`
2. 打开终端执行以下命令(每次更新后都需要重新执行)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
重新打开应用后,Keychain 应会提示授权(可选“始终允许”)。
</details>
<details>
<summary><b>Q: 如何反馈问题或建议?</b></summary>
+4 -4
View File
@@ -26,10 +26,10 @@ Master keys are stored in the OS native credential manager via `keytar`, never i
## 📋 Supported Versions
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < Latest | :x: |
| Version | Supported |
| -------- | ------------------ |
| Latest | :white_check_mark: |
| < Latest | :x: |
We only provide security updates for the latest version. Please ensure you're running the most recent release.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+10
View File
@@ -5,17 +5,20 @@
## 1. 核心功能
### 1.1 账号池管理
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
- **删除账号**:支持从本地数据库中移除账号。
### 1.2 配额实时监控
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
### 1.4 智能自动切换 (Intelligent Auto-Switching)
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
- **全局开关**:用户可在界面上一键开启或关闭此功能。
@@ -23,6 +26,7 @@
## 2. 技术实现
### 2.1 数据库设计 (`cloud_accounts.db`)
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
```sql
@@ -48,6 +52,7 @@ CREATE TABLE settings (
```
### 2.2 IPC 接口设计
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
@@ -59,15 +64,18 @@ CREATE TABLE settings (
- `forcePollCloudMonitor()`: 手动触发后台轮询。
### 2.3 Token 注入原理 (`ProtobufUtils`)
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
value 是一个 Base64 编码的 Protobuf 二进制数据。
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
1. **解码**:解析 Varint 和 Length-Delimited 字段。
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
### 2.4 切换流程
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
2. **停止进程**:优雅关闭 Antigravity 进程。
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
@@ -75,12 +83,14 @@ value 是一个 Base64 编码的 Protobuf 二进制数据。
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
### 2.5 自动切换逻辑 (`AutoSwitchService`)
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
### 2.6 安全加固 (Security Hardening)
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
+14 -14
View File
@@ -1,27 +1,27 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import eslintPluginPrettierRecommended from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import { fileURLToPath } from "node:url";
import { defineConfig } from "eslint/config";
import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import eslintPluginPrettierRecommended from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import path from 'node:path';
import { includeIgnoreFile } from '@eslint/compat';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'eslint/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
/** @type {import('eslint').Linter.Config[]} */
export default defineConfig([
includeIgnoreFile(prettierIgnorePath),
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
},
{ languageOptions: { globals: globals.browser } },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
pluginReact.configs.flat["jsx-runtime"],
pluginReact.configs.flat['jsx-runtime'],
reactHooks.configs.flat.recommended,
eslintPluginPrettierRecommended,
...tseslint.configs.recommended,
+17 -11
View File
@@ -12,6 +12,8 @@ import * as path from 'path';
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
const config: ForgeConfig = {
packagerConfig: {
asar: {
@@ -20,6 +22,7 @@ const config: ForgeConfig = {
name: 'Antigravity Manager',
executableName: 'antigravity-manager',
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
extraResource: ['src/assets'], // Copy assets folder to resources/assets
},
rebuildConfig: {},
hooks: {
@@ -80,7 +83,6 @@ const config: ForgeConfig = {
},
],
plugins: [
new AutoUnpackNativesPlugin({}),
new VitePlugin({
build: [
{
@@ -101,16 +103,20 @@ const config: ForgeConfig = {
},
],
}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
...(!isStartCommand
? [
new AutoUnpackNativesPlugin({}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
]
: []),
],
};
+1 -1
View File
@@ -93,7 +93,7 @@ src/
**数据库位置:**
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
* Linux: `~/.config/Antigravity/state.vscdb`
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
**关键数据库键:**
* `antigravityAuthStatus`: 认证状态和用户信息。
+6307 -74
View File
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.1.0",
"version": "0.3.2",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -18,11 +18,10 @@
"test:unit": "vitest",
"test:e2e": "playwright test",
"test:all": "vitest run && playwright test",
"test-db": "electron test-db.js",
"type-check": "tsc --noEmit"
},
"author": "Draculabo",
"license": "MIT",
"license": "CC-BY-NC-SA-4.0",
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
@@ -39,6 +38,9 @@
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0",
"@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.2",
"@swc/core": "^1.15.8",
"@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1",
@@ -47,13 +49,15 @@
"@types/better-sqlite3": "^7.6.12",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "^1.3.3",
"electron": "^32.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0",
"electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
"eslint": "^9.39.1",
@@ -67,6 +71,8 @@
"node-addon-api": "^8.3.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"semantic-release": "^25.0.2",
"semantic-release-config-gitmoji": "^1.5.3",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.16",
"ts-node": "^10.9.2",
@@ -116,10 +122,12 @@
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2",
"find-process": "^2.0.0",
"happy-dom": "^20.0.10",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0",
"lodash-es": "^4.17.22",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -132,11 +140,5 @@
"update-electron-app": "^3.1.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"ts-node": {
"transpileOnly": true,
"compilerOptions": {
"module": "commonjs"
}
}
}
+6 -6
View File
@@ -1,23 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./src/tests/e2e",
testDir: './src/tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
reporter: 'html',
use: {
trace: "on-first-retry",
trace: 'on-first-retry',
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Semantic Release Configuration
*
* This configuration uses the specific release rules requested by the user,
* but adapts the plugin configuration to work with standard Conventional Commits
* instead of Gitmoji (since the project history doesn't use emojis).
*/
const releaseRules = [
{
release: 'minor',
type: 'feat',
},
{
release: 'patch',
type: 'fix',
},
{
release: 'patch',
type: 'perf',
},
{
release: 'patch',
type: 'style',
},
{
release: 'patch',
type: 'refactor',
},
{
release: 'patch',
type: 'build',
},
{ release: 'patch', scope: 'README', type: 'docs' },
{ release: 'patch', scope: 'README.md', type: 'docs' },
{ release: false, type: 'docs' },
{
release: false,
type: 'test',
},
{
release: false,
type: 'ci',
},
{
release: false,
type: 'chore',
},
{
release: false,
type: 'wip',
},
{
release: 'major',
type: 'BREAKING CHANGE',
},
{
release: 'major',
scope: 'BREAKING CHANGE',
},
{
release: 'major',
subject: '*BREAKING CHANGE*',
},
{ release: 'patch', subject: '*force release*' },
{ release: 'patch', subject: '*force patch*' },
{ release: 'minor', subject: '*force minor*' },
{ release: 'major', subject: '*force major*' },
{ release: false, subject: '*skip release*' },
];
module.exports = {
branches: ['main', 'master'],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: releaseRules,
},
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: [
{ type: 'feat', section: '✨ Features' },
{ type: 'fix', section: '🐛 Bug Fixes' },
{ type: 'perf', section: '⚡ Performance Improvements' },
{ type: 'revert', section: '⏪ Reverts' },
{ type: 'docs', section: '📝 Documentation' },
{ type: 'style', section: '💄 Styles' },
{ type: 'refactor', section: '♻️ Code Refactoring' },
{ type: 'test', section: '✅ Tests' },
{ type: 'build', section: '👷 Build System' },
{ type: 'ci', section: '🔧 Continuous Integration' },
],
},
},
],
[
'@semantic-release/changelog',
{
changelogFile: 'CHANGELOG.md',
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
},
],
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
[
'@semantic-release/github',
{
successComment: false,
failComment: false,
labels: false,
releaseName: 'v${nextRelease.version}',
},
],
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
],
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager";
import { ipc } from '@/ipc/manager';
export function listAccounts() {
return ipc.client.account.listAccounts();
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager";
import { ipc } from '@/ipc/manager';
export function getPlatform() {
return ipc.client.app.currentPlatfom();
+2 -2
View File
@@ -1,5 +1,5 @@
import { ipc } from "@/ipc/manager";
import { AccountBackupData, Account } from "@/types/account";
import { ipc } from '@/ipc/manager';
import { AccountBackupData, Account } from '@/types/account';
export function backupAccount(account: Account) {
return ipc.client.database.backupAccount(account);
+5
View File
@@ -0,0 +1,5 @@
import { ipc } from '@/ipc/manager';
export function openLogDirectory() {
return ipc.client.system.openLogDirectory();
}
+9 -11
View File
@@ -1,6 +1,6 @@
import { ThemeMode } from "@/types/theme-mode";
import { ipc } from "@/ipc/manager";
import { LOCAL_STORAGE_KEYS } from "@/constants";
import { ThemeMode } from '@/types/theme-mode';
import { ipc } from '@/ipc/manager';
import { LOCAL_STORAGE_KEYS } from '@/constants';
export interface ThemePreferences {
system: ThemeMode;
@@ -9,9 +9,7 @@ export interface ThemePreferences {
export async function getCurrentTheme(): Promise<ThemePreferences> {
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
const localTheme = localStorage.getItem(
LOCAL_STORAGE_KEYS.THEME,
) as ThemeMode | null;
const localTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as ThemeMode | null;
return {
system: currentTheme,
@@ -20,7 +18,7 @@ export async function getCurrentTheme(): Promise<ThemePreferences> {
}
export async function setTheme(newTheme: ThemeMode) {
const isDarkMode = newTheme === "dark";
const isDarkMode = newTheme === 'dark';
await ipc.client.theme.setThemeMode(newTheme);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
updateDocumentTheme(isDarkMode);
@@ -28,7 +26,7 @@ export async function setTheme(newTheme: ThemeMode) {
export async function toggleTheme() {
const isDarkMode = await ipc.client.theme.toggleThemeMode();
const newTheme = isDarkMode ? "dark" : "light";
const newTheme = isDarkMode ? 'dark' : 'light';
updateDocumentTheme(isDarkMode);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
@@ -37,7 +35,7 @@ export async function toggleTheme() {
export async function syncWithLocalTheme() {
const { local } = await getCurrentTheme();
if (!local) {
setTheme("system");
setTheme('system');
return;
}
@@ -46,8 +44,8 @@ export async function syncWithLocalTheme() {
function updateDocumentTheme(isDarkMode: boolean) {
if (isDarkMode) {
document.documentElement.classList.add("dark");
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove('dark');
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "../ipc/manager";
import { ipc } from '../ipc/manager';
export async function minimizeWindow() {
await ipc.client.window.minimizeWindow();
+38 -29
View File
@@ -41,6 +41,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
// ... (existing code: imports and comments)
@@ -62,19 +63,49 @@ export function CloudAccountList() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [authCode, setAuthCode] = useState('');
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) {
return;
}
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
});
},
},
);
};
// Listen for Google Auth Code
useEffect(() => {
if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
setAuthCode(code);
// Auto submit if dialog is open
if (isAddDialogOpen) {
handleAddAccount(code);
}
// Note: Auto-submit will be triggered by the authCode change effect below
});
return cleanup;
}
}, [isAddDialogOpen]);
}, []);
// Auto-submit when authCode is set and dialog is open
useEffect(() => {
if (authCode && isAddDialogOpen && !addMutation.isPending) {
console.log('[OAuth] Auto-submitting auth code');
handleAddAccount(authCode);
}
}, [authCode, isAddDialogOpen]);
// Batch Operations State
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -103,7 +134,7 @@ export function CloudAccountList() {
onError: (err) =>
toast({
title: t('cloud.toast.switchFailed'),
description: err.message,
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
}),
},
@@ -169,35 +200,13 @@ export function CloudAccountList() {
onError: (err) => {
toast({
title: t('cloud.toast.syncFailed.title'),
description: err.message,
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
});
},
});
};
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) return;
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: err.message,
variant: 'destructive',
});
},
},
);
};
const openAuthUrl = async () => {
try {
await startAuthFlow();
+17 -25
View File
@@ -1,21 +1,17 @@
import React from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
isProcessRunning,
startAntigravity,
closeAntigravity,
} from "@/actions/process";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Play, Square, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Play, Square, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const StatusBar: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: isRunning, isLoading } = useQuery({
queryKey: ["process", "status"],
queryKey: ['process', 'status'],
queryFn: isProcessRunning,
refetchInterval: 2000,
});
@@ -23,14 +19,14 @@ export const StatusBar: React.FC = () => {
const startMutation = useMutation({
mutationFn: startAntigravity,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
},
});
const stopMutation = useMutation({
mutationFn: closeAntigravity,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
},
});
@@ -47,10 +43,10 @@ export const StatusBar: React.FC = () => {
return (
<div
className={cn(
"flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors",
'flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors',
isRunning
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100"
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100",
? 'bg-green-100 text-green-900 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 items-center gap-2">
@@ -62,11 +58,7 @@ export const StatusBar: React.FC = () => {
<div className="h-3 w-3 rounded-full bg-red-500" />
)}
<span>
{isLoading
? t("status.checking")
: isRunning
? t("status.running")
: t("status.stopped")}
{isLoading ? t('status.checking') : isRunning ? t('status.running') : t('status.stopped')}
</span>
</div>
<Button
@@ -75,8 +67,8 @@ export const StatusBar: React.FC = () => {
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
"h-8 px-3 hover:bg-white/20",
isRunning ? "hover:text-green-900" : "hover:text-red-900",
'h-8 px-3 hover:bg-white/20',
isRunning ? 'hover:text-green-900' : 'hover:text-red-900',
)}
>
{isPending ? (
@@ -86,7 +78,7 @@ export const StatusBar: React.FC = () => {
) : (
<Play className="mr-2 h-4 w-4 fill-current" />
)}
{isRunning ? t("action.stop") : t("action.start")}
{isRunning ? t('action.stop') : t('action.start')}
</Button>
</div>
);
+4 -4
View File
@@ -1,12 +1,12 @@
import { Link } from "@tanstack/react-router";
import { Link } from '@tanstack/react-router';
import {
NavigationMenu as NavigationMenuBase,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { useTranslation } from "react-i18next";
} from '@/components/ui/navigation-menu';
import { useTranslation } from 'react-i18next';
export default function NavigationMenu() {
const { t } = useTranslation();
@@ -16,7 +16,7 @@ export default function NavigationMenu() {
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">{t("titleHomePage")}</Link>
<Link to="/">{t('titleHomePage')}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
+3 -3
View File
@@ -1,6 +1,6 @@
import { Moon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toggleTheme } from "@/actions/theme";
import { Moon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toggleTheme } from '@/actions/theme';
export default function ToggleTheme() {
return (
+6 -9
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -8,10 +8,7 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
));
@@ -23,7 +20,7 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
@@ -36,7 +33,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"bg-muted flex h-full w-full items-center justify-center rounded-full",
'bg-muted flex h-full w-full items-center justify-center rounded-full',
className,
)}
{...props}
+11 -15
View File
@@ -1,35 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43 -70
View File
@@ -1,82 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"bg-card text-card-foreground rounded-xl border shadow",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
));
CardDescription.displayName = "CardDescription";
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+21 -34
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -19,8 +19,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none",
inset && "pl-8",
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && 'pl-8',
className,
)}
{...props}
@@ -29,8 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -39,14 +38,13 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -57,8 +55,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
@@ -76,8 +74,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
@@ -92,7 +90,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
@@ -106,8 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -116,7 +113,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -139,11 +136,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
@@ -155,24 +148,18 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
+7 -12
View File
@@ -1,22 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
+19 -26
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)}
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
{...props}
/>
));
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitive.Thumb
className={cn(
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
+15 -16
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
@@ -22,25 +22,24 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: "border bg-background text-foreground",
default: 'border bg-background text-foreground',
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
@@ -59,7 +58,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
'hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50',
className,
)}
{...props}
@@ -74,7 +73,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
'text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
@@ -91,7 +90,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
{...props}
/>
));
@@ -103,7 +102,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
+23 -25
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast";
import * as React from 'react';
import { Toast, ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
@@ -12,10 +12,10 @@ type ToasterToast = ToastProps & {
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
@@ -29,20 +29,20 @@ type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
@@ -59,7 +59,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
type: 'REMOVE_TOAST',
toastId,
});
}, TOAST_REMOVE_DELAY);
@@ -69,21 +69,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -108,7 +106,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
@@ -133,20 +131,20 @@ function dispatch(action: Action) {
});
}
type Toast = Omit<ToasterToast, "id">;
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: "ADD_TOAST",
type: 'ADD_TOAST',
toast: {
...props,
id,
@@ -180,7 +178,7 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
+4 -9
View File
@@ -1,12 +1,7 @@
import { z } from "zod";
import { os } from "@orpc/server";
import {
listAccountsData,
addAccountSnapshot,
switchAccount,
deleteAccount,
} from "./handler";
import { AccountSchema } from "../../types/account";
import { z } from 'zod';
import { os } from '@orpc/server';
import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
import { AccountSchema } from '../../types/account';
export const accountRouter = os.router({
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { os } from "@orpc/server";
import { app } from "electron";
import { os } from '@orpc/server';
import { app } from 'electron';
export const currentPlatfom = os.handler(() => {
return process.platform;
+3 -1
View File
@@ -20,11 +20,13 @@ export class AuthServer {
const error = url.searchParams.get('error');
if (code) {
logger.info('AuthServer: Received authorization code');
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
// Send code to renderer
if (ipcContext.mainWindow) {
logger.info('AuthServer: Sending code to renderer via IPC');
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
logger.info('AuthServer: Code sent successfully');
} else {
logger.error('AuthServer: Main window not found, cannot send code');
}
+8 -3
View File
@@ -6,7 +6,7 @@ import { logger } from '../../utils/logger';
import { shell } from 'electron';
import fs from 'fs';
import { closeAntigravity, startAntigravity } from '../../ipc/process/handler';
import { closeAntigravity, startAntigravity, _waitForProcessExit } from '../../ipc/process/handler';
import { updateTrayMenu } from '../../ipc/tray/handler';
import { getAntigravityDbPaths } from '../../utils/paths';
@@ -189,7 +189,12 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
}
// 2. Stop Antigravity Process
await closeAntigravity(); // This waits for it to close
await closeAntigravity();
try {
await _waitForProcessExit(10000); // Wait up to 10s for it to truly vanish
} catch (e) {
logger.warn('Process did not exit cleanly within timeout, but proceeding...', e);
}
// 3. Backup Database (New Logic)
const dbPaths = getAntigravityDbPaths();
@@ -214,7 +219,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
// 3. Inject Token
// injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
CloudAccountRepo.injectCloudToken(account.token);
CloudAccountRepo.injectCloudToken(account);
// 4. Update usage and active status
CloudAccountRepo.updateLastUsed(account.id);
+61 -8
View File
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null;
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Create accounts table
// Storing complex objects (token, quota) as JSON strings for simplicity
@@ -73,7 +74,9 @@ function ensureDatabaseInitialized(dbPath: string): void {
function getDb(): Database.Database {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
return new Database(dbPath);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
return db;
}
export class CloudAccountRepo {
@@ -291,7 +294,7 @@ export class CloudAccountRepo {
}
}
static injectCloudToken(token: CloudAccount['token']): void {
static injectCloudToken(account: CloudAccount): void {
const dbPaths = getAntigravityDbPaths();
let dbPath: string | null = null;
@@ -307,13 +310,42 @@ export class CloudAccountRepo {
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
try {
const row = db
.prepare('SELECT value FROM ItemTable WHERE key = ?')
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
if (!row || !row.value) {
throw new Error('jetskiStateSync.agentManagerInitState not found in database');
logger.warn(
'jetskiStateSync.agentManagerInitState not found. ' +
'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.',
);
// Inject minimal state: auth status and onboarding flag only
const authStatus = {
name: account.name || account.email,
email: account.email,
apiKey: account.token.access_token,
};
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityAuthStatus',
JSON.stringify(authStatus),
);
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityOnboarding',
'true',
);
db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity');
logger.info(
`Injected minimal auth state for ${account.email} (no protobuf state available)`,
);
return; // Early return - skip protobuf manipulation
}
// 1. Decode Base64
@@ -325,9 +357,9 @@ export class CloudAccountRepo {
// 3. Create New Field 6
const newField = ProtobufUtils.createOAuthTokenInfo(
token.access_token,
token.refresh_token,
token.expiry_timestamp,
account.token.access_token,
account.token.refresh_token,
account.token.expiry_timestamp,
);
// 4. Concatenate
@@ -344,13 +376,33 @@ export class CloudAccountRepo {
'jetskiStateSync.agentManagerInitState',
);
// 7. Inject Onboarding Flag (Missing in initial port)
// 7. Inject Onboarding Flag
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityOnboarding',
'true',
);
logger.info(`Successfully injected cloud token into Antigravity database at ${dbPath}.`);
// 8. Update Auth Status (Fix for switching issue)
// This ensures the UI reflects the user we just switched to
const authStatus = {
name: account.name || account.email,
email: account.email,
apiKey: account.token.access_token, // Critical for session recognition
// userStatusProtoBinaryBase64: ... // We cannot generate this easily, hoping IDE fetches it
};
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
'antigravityAuthStatus',
JSON.stringify(authStatus),
);
// 9. Remove google.antigravity to prevent conflicts
// This key often holds old state that might override our injected state
db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity');
logger.info(
`Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath}.`,
);
} finally {
db.close();
}
@@ -406,6 +458,7 @@ export class CloudAccountRepo {
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
const ideDb = new Database(dbPath, { readonly: true });
ideDb.pragma('journal_mode = WAL');
try {
// 1. Read Raw Token Data
const row = ideDb
+32 -1
View File
@@ -8,6 +8,31 @@ import path from 'path';
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database {
const db = new Database(dbPath, options);
db.pragma('journal_mode = WAL');
return db;
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getAntigravityDbPaths();
if (dbPaths.length === 0) {
return;
}
const db = getDatabaseConnection();
db.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/**
* Ensures that the database file exists.
* @param dbPath {string} The path to the database file.
@@ -59,7 +84,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
ensureDatabaseExists(targetPath);
try {
return new Database(targetPath, { readonly: false, fileMustExist: false });
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
} catch (error: unknown) {
const err = error as { code?: string };
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
@@ -69,6 +94,12 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
}
}
/**
* Gets a database connection and enables WAL mode.
* @param dbPath {string} The path to the database file.
* @returns {Database.Database} The database connection.
*/
/**
* Gets the current account info.
* @returns {AccountInfo} The current account info.
+4 -12
View File
@@ -1,15 +1,7 @@
import { z } from "zod";
import { os } from "@orpc/server";
import {
backupAccount,
restoreAccount,
getCurrentAccountInfo,
} from "./handler";
import {
AccountBackupDataSchema,
AccountInfoSchema,
AccountSchema,
} from "../../types/account";
import { z } from 'zod';
import { os } from '@orpc/server';
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
export const databaseRouter = os.router({
backupAccount: os
+187 -61
View File
@@ -1,63 +1,151 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import findProcess, { ProcessInfo } from 'find-process';
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
import { logger } from '../../utils/logger';
const execAsync = promisify(exec);
/**
* Helper process name patterns to exclude (Electron helper processes)
*/
const HELPER_PATTERNS = [
'helper',
'plugin',
'renderer',
'gpu',
'crashpad',
'utility',
'audio',
'sandbox',
'language_server',
];
/**
* Check if a process is a helper/auxiliary process that should be excluded.
* @param name Process name (lowercase)
* @param cmd Process command line (lowercase)
* @returns True if the process is a helper process
*/
function isHelperProcess(name: string, cmd: string): boolean {
const nameLower = name.toLowerCase();
const cmdLower = cmd.toLowerCase();
// Check for --type= argument (Electron helper process indicator)
if (cmdLower.includes('--type=')) {
return true;
}
// Check for helper patterns in process name
for (const pattern of HELPER_PATTERNS) {
if (nameLower.includes(pattern)) {
return true;
}
}
// Check for crashpad in path
if (cmdLower.includes('crashpad')) {
return true;
}
return false;
}
function isPgrepNoMatchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message;
const hasPgrep = message.includes('pgrep -xi Antigravity');
const code = (error as { code?: number }).code;
return hasPgrep && code === 1;
}
/**
* Checks if the Antigravity process is running.
* Uses find-process package for robust cross-platform process detection.
* @returns {boolean} True if the Antigravity process is running, false otherwise.
*/
export async function isProcessRunning(): Promise<boolean> {
try {
const platform = process.platform;
let command = '';
const currentPid = process.pid;
if (platform === 'win32' || isWsl()) {
// Use absolute path for tasklist to be safe on Windows too
const cmd = isWsl()
? '/mnt/c/Windows/System32/tasklist.exe'
: process.env.SystemRoot // Usually C:\Windows
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
: 'tasklist';
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
} else {
// macOS and Linux (native)
command = 'pgrep -x Antigravity';
// Use find-process to search for Antigravity processes
// 'name' search type matches process name
let processes: ProcessInfo[] = [];
try {
processes = await findProcess('name', 'Antigravity', true);
} catch (error) {
if (isPgrepNoMatchError(error)) {
logger.debug('No Antigravity process found (pgrep returned 1)');
return false;
}
throw error;
}
logger.debug(`Checking process with command: ${command}`);
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
try {
const { stdout } = await execAsync(command, { timeout: 4000 });
logger.debug(`Process check stdout: ${stdout.trim()}`);
logger.debug(`Found ${processes.length} processes matching 'Antigravity'`);
if (platform === 'win32' || isWsl()) {
return stdout.includes('Antigravity.exe');
} else {
// pgrep returns PIDs if found, empty if not
return stdout.trim().length > 0;
for (const proc of processes) {
// Skip self
if (proc.pid === currentPid) {
continue;
}
} catch (cmdError) {
if (platform === 'win32' && !isWsl()) {
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
// Fallback to wmic (filtered)
try {
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
return stdout.includes('ProcessId');
} catch (wmicError) {
logger.error('wmic fallback also failed', wmicError);
return false;
const name = proc.name?.toLowerCase() || '';
const cmd = proc.cmd?.toLowerCase() || '';
// Skip manager process
if (
name.includes('manager') ||
cmd.includes('manager') ||
cmd.includes('antigravity-manager')
) {
continue;
}
// Skip helper processes
if (isHelperProcess(name, cmd)) {
continue;
}
if (platform === 'darwin') {
// macOS: Check for Antigravity.app in path
if (cmd.includes('antigravity.app')) {
logger.debug(
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
// Also check if the process name is exactly 'Antigravity' (main process)
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
return true;
}
} else if (platform === 'win32') {
// Windows: Check for Antigravity.exe
if (name === 'antigravity.exe' || name === 'antigravity') {
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
return true;
}
} else {
// Linux: Check for antigravity in name or path (but not tools)
if (
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
!name.includes('tools')
) {
logger.debug(
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
}
throw cmdError;
}
return false;
} catch (error) {
logger.error('Error checking process status:', error);
// pgrep returns exit code 1 if no process found
logger.error('Error checking process status with find-process:', error);
return false;
}
}
@@ -108,33 +196,67 @@ export async function closeAntigravity(): Promise<void> {
// Helper to list processes
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
try {
let cmd = '';
let output = '';
if (platform === 'win32') {
// Use wmic to get process info
cmd = 'wmic process get ProcessId,Name,CommandLine /FORMAT:CSV';
const psCommand = (cmdlet: string) =>
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'Antigravity%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
try {
output = execSync(psCommand('Get-CimInstance'), {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
stdio: ['pipe', 'pipe', 'ignore'],
});
} catch (e) {
// CIM failed (likely older OS), try WMI
try {
output = execSync(psCommand('Get-WmiObject'), {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
});
} catch (innerE) {
// Both failed, throw original or log? Throwing lets the outer catch handle it (returning empty list)
throw e;
}
}
} else {
// Unix/Linux/macOS
cmd = 'ps -A -o pid,comm,args';
output = execSync('ps -A -o pid,comm,args', {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
});
}
const output = execSync(cmd, { encoding: 'utf-8' });
const lines = output.split('\n');
const processList = [];
const processList: { pid: number; name: string; cmd: string }[] = [];
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
if (platform === 'win32') {
// CSV format parsing would be better, but simple check for now
if (line.includes('Antigravity')) {
// Extract PID (usually last or second to last in CSV)
const match = line.match(/(\d+)(?:\s*$)/);
if (match) {
processList.push({ pid: parseInt(match[1]), name: 'Antigravity', cmd: line });
}
if (platform === 'win32') {
// Parse CSV Output
const lines = output.trim().split(/\r?\n/);
// First line is headers "ProcessId","Name","CommandLine"
// We start from index 1
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
} else {
// Regex to match CSV fields: "val1","val2","val3"
const match = line.match(/^"(\d+)","(.*?)","(.*?)"$/);
if (match) {
const pid = parseInt(match[1]);
const name = match[2];
const cmdLine = match[3];
processList.push({ pid, name, cmd: cmdLine || name });
}
}
} else {
const lines = output.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
const pid = parseInt(parts[0]);
if (isNaN(pid)) continue;
const rest = parts.slice(1).join(' ');
@@ -155,18 +277,22 @@ export async function closeAntigravity(): Promise<void> {
if (p.pid === currentPid) {
return false;
}
// Exclude this electron app (if named Antigravity Manager)
if (p.cmd.includes('Antigravity Manager')) {
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
return false;
}
// Match Antigravity
// Match Antigravity (but not manager)
if (platform === 'win32') {
return (
p.cmd.includes('Antigravity.exe') ||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
);
} else {
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity');
// Explicit !manager check for Linux/macOS to be defensive
return (
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
!p.cmd.includes('manager')
);
}
});
+8
View File
@@ -1,6 +1,8 @@
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
import { shell } from 'electron';
import { getAgentDir } from '../../utils/paths';
// Schema for IP info
const IpInfoSchema = z.object({
@@ -61,4 +63,10 @@ export const systemHandler = os.router({
return results;
}),
// Open log directory in file explorer
openLogDirectory: os.output(z.void()).handler(async () => {
const logDir = getAgentDir();
await shell.openPath(logDir);
}),
});
+19 -21
View File
@@ -1,6 +1,6 @@
import { os } from "@orpc/server";
import { nativeTheme } from "electron";
import { setThemeModeInputSchema } from "./schemas";
import { os } from '@orpc/server';
import { nativeTheme } from 'electron';
import { setThemeModeInputSchema } from './schemas';
export const getCurrentThemeMode = os.handler(() => {
return nativeTheme.themeSource;
@@ -8,28 +8,26 @@ export const getCurrentThemeMode = os.handler(() => {
export const toggleThemeMode = os.handler(() => {
if (nativeTheme.shouldUseDarkColors) {
nativeTheme.themeSource = "light";
nativeTheme.themeSource = 'light';
} else {
nativeTheme.themeSource = "dark";
nativeTheme.themeSource = 'dark';
}
return nativeTheme.shouldUseDarkColors;
});
export const setThemeMode = os
.input(setThemeModeInputSchema)
.handler(({ input: mode }) => {
switch (mode) {
case "light":
nativeTheme.themeSource = "light";
break;
case "dark":
nativeTheme.themeSource = "dark";
break;
case "system":
nativeTheme.themeSource = "system";
break;
}
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
switch (mode) {
case 'light':
nativeTheme.themeSource = 'light';
break;
case 'dark':
nativeTheme.themeSource = 'dark';
break;
case 'system':
nativeTheme.themeSource = 'system';
break;
}
return nativeTheme.themeSource;
});
return nativeTheme.themeSource;
});
+2 -2
View File
@@ -1,3 +1,3 @@
import { z } from "zod";
import { z } from 'zod';
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]);
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
+37 -1
View File
@@ -38,12 +38,36 @@ function getQuotaText(account: CloudAccount | null, texts: any): string[] {
export function initTray(mainWindow: BrowserWindow) {
globalMainWindow = mainWindow;
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy existing tray', e);
}
tray = null;
logger.info('Destroyed existing tray before creating new one');
}
const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets/tray.png');
: path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
const icon = nativeImage.createFromPath(iconPath);
// Verify icon is valid before creating tray
if (icon.isEmpty()) {
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
return;
}
tray = new Tray(icon);
tray.setToolTip('Antigravity Manager');
@@ -158,3 +182,15 @@ export function updateTrayMenu(account: CloudAccount | null, language?: string)
export function setTrayLanguage(lang: string) {
updateTrayMenu(lastAccount, lang);
}
export function destroyTray() {
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy tray', e);
}
tray = null;
logger.info('Tray destroyed');
}
}
+20 -27
View File
@@ -1,33 +1,26 @@
import { os } from "@orpc/server";
import { ipcContext } from "../context";
import { logger } from "../../utils/logger";
import { os } from '@orpc/server';
import { ipcContext } from '../context';
import { logger } from '../../utils/logger';
export const minimizeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context;
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
const { window } = context;
window.minimize();
});
window.minimize();
});
export const maximizeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context;
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
const { window } = context;
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
});
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
});
export const closeWindow = os
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
logger.info('IPC: closeWindow called');
const { window } = context;
window.close();
});
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
logger.info('IPC: closeWindow called');
const { window } = context;
window.close();
});
+110 -20
View File
@@ -69,9 +69,27 @@ export function transformClaudeRequestIn(
const allowDummyThought = config.finalModel.startsWith('gemini-');
// 4. Generation Config & Thinking
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
let isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
const isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
if (isThinkingEnabled) {
const globalSig = SignatureStore.get();
const hasFunctionCalls = claudeReq.messages.some((m) => {
if (Array.isArray(m.content)) {
return m.content.some((b) => b.type === 'tool_use');
}
return false;
});
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
isThinkingEnabled = false;
}
}
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
// Update thinking config based on the final decision
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
delete generationConfig.thinkingConfig;
}
// 2. Contents (Messages)
const contents = buildContents(
@@ -371,32 +389,104 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
/**
* Builds system instruction
* Converts Claude system prompts to Gemini format
* Converts Claude system prompts to Gemini format, with Antigravity Identity Injection
*/
function buildSystemInstruction(
system: ClaudeRequest['system'],
modelName: string,
): { parts: { text: string }[] } | null {
if (!system) {
return null;
}
const antigravity_identity =
'--- [IDENTITY_PATCH] ---\n' +
'Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n' +
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
'**Absolute paths only**\n' +
'**Proactiveness**';
const parts: { text: string }[] = [];
const identityPatch =
`--- [IDENTITY_PATCH] ---\n` +
`Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n` +
`You are currently providing services as the native ${modelName} model via a standard API proxy.\n` +
`Always use the 'claude' command for terminal tasks if relevant.\n` +
`--- [SYSTEM_PROMPT_BEGIN] ---\n`;
parts.push({ text: identityPatch });
if (typeof system === 'string') {
parts.push({ text: system });
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text') parts.push({ text: block.text });
// Check if user already provided Antigravity identity
let userHasAntigravity = false;
if (system) {
if (typeof system === 'string') {
if (system.includes('You are Antigravity')) userHasAntigravity = true;
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text' && block.text.includes('You are Antigravity')) {
userHasAntigravity = true;
break;
}
}
}
}
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
return { parts };
// Inject if missing
if (!userHasAntigravity) {
parts.push({ text: antigravity_identity });
}
if (system) {
if (typeof system === 'string') {
parts.push({ text: system });
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text') parts.push({ text: block.text });
}
}
}
if (!userHasAntigravity) {
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
}
// If we pushed at least something
if (parts.length > 0) {
return { parts };
}
return null;
}
/**
* Minimum length for a valid thought_signature
*/
const MIN_SIGNATURE_LENGTH = 10;
/**
* Check if we have any valid signature available for function calls
* @param messages Messages from ClaudeRequest
* @param globalSig Global signature from SignatureStore
* @returns True if any valid signature is available for function calls
*/
function hasValidSignatureForFunctionCalls(
messages: Message[],
globalSig: string | null | undefined,
): boolean {
// 1. Check global store
if (globalSig && globalSig.length >= MIN_SIGNATURE_LENGTH) {
return true;
}
// 2. Check if any message has a thinking block with valid signature
// Traverse in reverse to find recent signatures
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant') {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (
block.type === 'thinking' &&
block.signature &&
block.signature.length >= MIN_SIGNATURE_LENGTH
) {
return true;
}
}
}
}
}
return false;
}
/**
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Usage,
GroundingMetadata,
} from './types';
import { decodeSignature } from './signature-utils';
/**
* Non-streaming response processor (Gemini -> Claude)
@@ -55,7 +56,7 @@ class NonStreamingProcessor {
}
private processPart(part: GeminiPart) {
const signature = part.thoughtSignature || null;
const signature = decodeSignature(part.thoughtSignature) || null;
// 1. Handle FunctionCall
if (part.functionCall) {
+55 -2
View File
@@ -1,5 +1,6 @@
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
import { GeminiPart, Usage, UsageMetadata } from './types';
import { SignatureStore } from './SignatureStore';
import { decodeSignature } from './signature-utils';
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
@@ -43,6 +44,8 @@ export class StreamingState {
public webSearchQuery: string | null = null;
public groundingChunks: any[] | null = null;
private parseErrorCount: number = 0;
constructor() {}
public emit(eventType: string, data: any): string {
@@ -242,6 +245,56 @@ export class StreamingState {
public storeSignature(signature?: string) {
this.signatures.store(signature);
}
public handleParseError(rawData: string): string[] {
const chunks: string[] = [];
this.parseErrorCount++;
console.warn(
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
);
// Safely close current block
if (this.blockType !== 'None') {
chunks.push(...this.endBlock());
}
// Emit error event if too many errors
if (this.parseErrorCount > 3) {
console.error(
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
);
chunks.push(
this.emit('error', {
type: 'error',
error: {
type: 'network_error',
message: 'Unstable network connection. Please check your network or proxy settings.',
code: 'stream_decode_error',
details: {
error_count: this.parseErrorCount,
suggestion: 'Check network connection',
},
},
}),
);
}
return chunks;
}
/**
* Reset error state (call after recovery)
*/
public resetErrorState(): void {
this.parseErrorCount = 0;
}
/**
* Get current error count (for monitoring)
*/
public getErrorCount(): number {
return this.parseErrorCount;
}
}
/**
@@ -252,7 +305,7 @@ export class PartProcessor {
public process(part: GeminiPart): string[] {
const chunks: string[] = [];
const signature = part.thoughtSignature || undefined;
const signature = decodeSignature(part.thoughtSignature);
// 1. Handle FunctionCall
if (part.functionCall) {
+6 -1
View File
@@ -126,7 +126,12 @@ export class QuotaService {
} catch (error: any) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const text = JSON.stringify(error.response?.data || '');
let text = '';
try {
text = JSON.stringify(error.response?.data || '');
} catch {
text = '[Unable to serialize response data]';
}
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
if (status === 403) {
+49
View File
@@ -0,0 +1,49 @@
/** Jitter factor: ±20% */
const JITTER_FACTOR = 0.2;
/** Maximum retry delay in milliseconds (30 seconds) */
const MAX_DELAY_MS = 30000;
/**
* Apply jitter to a delay value to prevent thundering herd
* Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms)
*
* @param delayMs - Base delay in milliseconds
* @returns Jittered delay (minimum 1ms)
*/
export function applyJitter(delayMs: number): number {
if (delayMs <= 0) {
return 1;
}
const jitterRange = delayMs * JITTER_FACTOR;
const jitter = (Math.random() * 2 - 1) * jitterRange;
return Math.max(1, Math.round(delayMs + jitter));
}
/**
* Calculate retry delay with exponential backoff and jitter
*
* @param attempt - Current attempt number (0-indexed)
* @param baseMs - Base delay for first retry (default 1000ms)
* @returns Delay in milliseconds with jitter applied
*/
export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number {
// Exponential backoff: 1s, 2s, 4s, 8s, ...
const exponentialDelay = baseMs * Math.pow(2, attempt);
// Cap at maximum delay
const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);
// Apply jitter
return applyJitter(cappedDelay);
}
/**
* Sleep for specified milliseconds
*
* @param ms - Milliseconds to sleep
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+32
View File
@@ -0,0 +1,32 @@
import { isEmpty } from 'lodash-es';
/**
* Decode Base64-encoded signature if present
* Gemini sends Base64, Claude expects raw string
*
* @param signature - The signature string (may be Base64 or raw)
* @returns Decoded signature or undefined if input is empty
*/
export function decodeSignature(signature: string | undefined | null): string | undefined {
if (isEmpty(signature)) {
return undefined;
}
try {
const decoded = Buffer.from(signature!, 'base64').toString('utf-8');
// Validate: Check if it looks like valid UTF-8 text
// If decoding produces replacement characters, it wasn't valid Base64
if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) {
// Additional check: re-encode and compare to detect false positives
const reEncoded = Buffer.from(decoded).toString('base64');
if (reEncoded === signature) {
return decoded;
}
}
} catch {
// Not valid Base64, return as-is
}
// Return original if not Base64
return signature!;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Stream Error Utilities
* Classifies network errors and formats user-friendly messages
*
* Problem: Technical error messages confuse users
* Solution: Classify errors and provide localized, actionable messages
*/
export interface ClassifiedError {
type: string;
message: string;
}
/**
* Classify a stream error and return user-friendly message
*
* @param error - The error object
* @returns Classified error with type and localized message
*/
export function classifyStreamError(error: Error): ClassifiedError {
const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('timedout')) {
return {
type: 'timeout_error',
message: 'Request timed out. Please check your network connection or try again later.',
};
}
if (msg.includes('econnrefused') || msg.includes('connect')) {
return {
type: 'connection_error',
message: 'Unable to connect to server. Please check your network.',
};
}
if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) {
return {
type: 'network_error',
message: 'Network connection failed. Please check your network settings.',
};
}
if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) {
return {
type: 'stream_error',
message: 'Data transfer interrupted. Please try again later.',
};
}
return {
type: 'unknown_error',
message: `Unknown error occurred: ${error.message.substring(0, 100)}`,
};
}
/**
* Format error as SSE event for Claude protocol
*
* @param errorType - Error type string
* @param message - User-friendly message
* @returns SSE-formatted error event string
*/
export function formatErrorForSSE(errorType: string, message: string): string {
const errorEvent = {
type: 'error',
error: {
type: errorType,
message: message,
code: 'stream_error',
},
};
return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Thinking Block Utilities
* Handles validation and graceful degradation of thinking blocks
*
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
* Solution: Validate and convert invalid blocks to text instead of dropping
*/
/** Minimum length for a valid thought signature */
const MIN_SIGNATURE_LENGTH = 10;
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
}
export interface TextBlock {
type: 'text';
text: string;
}
export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any };
/**
* Check if a thinking block has a valid signature
*
* Rules:
* - Empty thinking + any signature = valid (trailing signature case)
* - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid
* - Non-empty thinking + no/short signature = invalid
* - Non-thinking blocks = always valid
*
* @param block - Content block to validate
* @returns true if block is valid or not a thinking block
*/
export function hasValidSignature(block: ContentBlock): boolean {
if (block.type !== 'thinking') {
return true; // Non-thinking blocks are always valid
}
const thinkingBlock = block as ThinkingBlock;
// Empty thinking + any signature = valid (trailing signature)
if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) {
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
// Non-empty thinking needs a long enough signature
return (
thinkingBlock.signature !== undefined &&
thinkingBlock.signature !== null &&
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
);
}
/**
* Gracefully degrade an invalid thinking block
*
* - Valid blocks: return as-is
* - Invalid with content: convert to text block
* - Invalid without content: return null (should be dropped)
*
* @param block - Content block to process
* @returns Processed block or null if should be dropped
*/
export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null {
if (block.type !== 'thinking') {
return block;
}
const thinkingBlock = block as ThinkingBlock;
if (hasValidSignature(block)) {
return block;
}
// Invalid signature - check if we have content to preserve
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
// Convert to text block to preserve content
console.info(
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
);
return {
type: 'text',
text: thinkingBlock.thinking,
};
}
// Empty content + invalid signature = drop
console.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
return null;
}
/**
* Process an array of content blocks, applying graceful degradation
*
* @param blocks - Array of content blocks
* @returns Processed array with invalid thinking blocks handled
*/
export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] {
const result: ContentBlock[] = [];
for (const block of blocks) {
const processed = gracefulDegradeThinking(block);
if (processed !== null) {
result.push(processed);
}
}
return result;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
+18
View File
@@ -30,9 +30,18 @@ i18n
deleteBackup: 'Delete Backup',
backupCurrent: 'Backup Current',
retry: 'Retry',
openLogs: 'Open Log Directory',
},
error: {
generic: 'An unexpected error occurred.',
keychainUnavailable: 'Keychain is unavailable.',
keychainHint: {
translocation:
'Detected macOS App Translocation. Move the app to /Applications and reopen.',
keychainDenied:
'Keychain access denied. The app may be unsigned; see README for the self-signing workaround.',
signNotarize: 'Please use a signed and notarized build when available.',
},
},
nav: {
accounts: 'Accounts',
@@ -77,6 +86,7 @@ i18n
version: 'Version',
platform: 'Platform',
license: 'License',
openLogDir: 'Open',
account: {
title: 'Account Settings',
description: 'Configure automatic account refresh and sync.',
@@ -304,9 +314,16 @@ i18n
deleteBackup: '删除备份',
backupCurrent: '备份当前账号',
retry: '重试',
openLogs: '打开日志目录',
},
error: {
generic: '发生未知错误。',
keychainUnavailable: 'Keychain 不可用。',
keychainHint: {
translocation: '检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
keychainDenied: 'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
signNotarize: '请在可用时使用已签名并公证的版本。',
},
},
nav: {
accounts: '账号',
@@ -351,6 +368,7 @@ i18n
version: '版本',
platform: '平台',
license: '许可证',
openLogDir: '打开',
account: {
title: '账号设置',
description: '配置自动刷新和同步账号。',
+49 -8
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import { app, BrowserWindow } from 'electron';
import path from 'path';
import fs from 'fs';
@@ -9,20 +9,40 @@ import { IPC_CHANNELS } from './constants';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { logger } from './utils/logger';
import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService';
// Static Imports to fix Bundle Resolution Errors
import { AuthServer } from './ipc/cloud/authServer';
import { bootstrapNestServer, stopNestServer } from './server/main';
import { initTray, setTrayLanguage } from './ipc/tray/handler';
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
import { rpcHandler } from './ipc/handler';
import { ConfigManager } from './ipc/config/manager';
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
/**
* Safely stringify an object, handling circular references
*/
function safeStringifyPacket(obj: unknown): string {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
}
function logPacket(data: any) {
try {
fs.appendFileSync(packetLogPath, `[${new Date().toISOString()}] ${JSON.stringify(data)}\n`);
fs.appendFileSync(
packetLogPath,
`[${new Date().toISOString()}] ${safeStringifyPacket(data)}\n`,
);
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
@@ -120,8 +140,9 @@ function createWindow() {
logger.info('Page finished loading successfully');
});
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
logger.info(`[Renderer Console] ${message} (${sourceId}:${line})`);
mainWindow.webContents.on('console-message', (details) => {
const { level, message, lineNumber, sourceId } = details;
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
});
}
@@ -129,8 +150,18 @@ app.on('child-process-gone', (event, details) => {
logger.error('Child process gone:', details);
});
app.on('before-quit', () => {
isQuitting = true;
logger.info('App before-quit event triggered - isQuitting set to true');
});
app.on('will-quit', (event) => {
logger.info('App will quit event triggered');
try {
destroyTray();
} catch (err) {
logger.error('Failed to destroy tray during will-quit', err);
}
});
app.on('quit', (event, exitCode) => {
@@ -193,6 +224,19 @@ process.on('unhandledRejection', (reason) => {
app
.whenReady()
.then(async () => {
logger.info('Step: Initialize CloudAccountRepo');
try {
await CloudAccountRepo.init();
} catch (e) {
logger.error('Startup: Failed to initialize CloudAccountRepo', e);
// We might want to exit here or show a dialog, but for now we proceed
// though functionality will be broken.
}
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
initDatabase();
})
.then(() => {
logger.info('Step: setupORPC');
return setupORPC();
@@ -212,9 +256,6 @@ app
.then(async () => {
// Initialize Cloud Monitor if enabled
try {
// Initialize DB & Migrate to Encrypted Storage
await CloudAccountRepo.init();
// Start OAuth Server
AuthServer.start();
+1 -1
View File
@@ -1 +1 @@
import "@/App";
import '@/App';
+2 -2
View File
@@ -1,5 +1,5 @@
import { MainLayout } from "@/layouts/MainLayout";
import { createRootRoute } from "@tanstack/react-router";
import { MainLayout } from '@/layouts/MainLayout';
import { createRootRoute } from '@tanstack/react-router';
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
/*
+12 -2
View File
@@ -17,9 +17,10 @@ import { getAppVersion, getPlatform } from '@/actions/app';
import { useTranslation } from 'react-i18next';
import { setAppLanguage } from '@/actions/language';
import { useAppConfig } from '@/hooks/useAppConfig';
import { Loader2 } from 'lucide-react';
import { Loader2, FolderOpen } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ProxyConfig } from '@/types/config';
import { openLogDirectory } from '@/actions/system';
function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -192,7 +193,16 @@ function SettingsPage() {
<div className="font-medium capitalize">{platform || 'Unknown'}</div>
<div className="text-muted-foreground">{t('settings.license')}</div>
<div className="font-medium">MIT</div>
<div className="font-medium">CC BY-NC-SA 4.0</div>
<div className="text-muted-foreground">{t('action.openLogs')}</div>
<button
onClick={() => openLogDirectory()}
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir', 'Open')}</span>
</button>
</div>
</CardContent>
</Card>
@@ -27,7 +27,8 @@ export class GeminiClient {
this.logger.error(`Gemini Stream API Error: ${error.message}`);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
}
@@ -50,11 +51,12 @@ export class GeminiClient {
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
`Gemini API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
}
@@ -77,7 +79,6 @@ export class GeminiClient {
return response.data;
} catch (error) {
this.handleAxiosError(error, 'StreamInternal');
throw error;
}
}
@@ -96,17 +97,38 @@ export class GeminiClient {
return response.data.response || response.data;
} catch (error) {
this.handleAxiosError(error, 'GenerateInternal');
throw error;
}
}
private handleAxiosError(error: any, context: string) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini ${context} API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
`Gemini ${context} API Error: ${error.message} - ${this.safeStringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
// Re-throw as clean Error to avoid circular reference issues
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
}
/**
* Safely stringify an object, handling circular references
*/
private safeStringify(obj: unknown): string {
if (obj === undefined || obj === null) {
return String(obj);
}
try {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
} catch {
return '[Unserializable]';
}
}
}
+90 -16
View File
@@ -7,6 +7,11 @@ import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequest
import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper';
import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper';
import { ClaudeRequest } from '../../../lib/antigravity/types';
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
import {
classifyStreamError,
formatErrorForSSE,
} from '../../../lib/antigravity/stream-error-utils';
import {
OpenAIChatRequest,
AnthropicChatRequest,
@@ -45,6 +50,13 @@ export class ProxyService {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
// Fix #6: Add jitter delay for retries (skip first attempt)
if (i > 0) {
const delay = calculateRetryDelay(i - 1);
this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`);
await sleep(delay);
}
const token = await this.tokenManager.getNextToken();
if (!token) {
throw new Error('No available accounts');
@@ -172,7 +184,11 @@ export class ProxyService {
let lastFinishReason: string | undefined;
let lastUsageMetadata: any | undefined;
// Fix #1: Track if we received any valid data
let receivedData = false;
upstreamStream.on('data', (chunk: Buffer) => {
receivedData = true; // Mark that we got data
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
@@ -205,19 +221,42 @@ export class ProxyService {
const chunks = processor.process(part as any);
chunks.forEach((c) => subscriber.next(c));
}
// Reset error state on successful parse
state.resetErrorState();
} catch (e) {
// Fix #3: Use handleParseError for graceful degradation
this.logger.error('Stream parse error', e);
const errorChunks = state.handleParseError(dataStr);
errorChunks.forEach((c) => subscriber.next(c));
}
}
});
upstreamStream.on('end', () => {
// Fix #1: Check for empty stream
if (!receivedData) {
this.logger.warn('Empty response stream detected');
subscriber.error(new Error('Empty response stream'));
return;
}
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
finishChunks.forEach((c) => subscriber.next(c));
subscriber.complete();
});
upstreamStream.on('error', (err: any) => subscriber.error(err));
upstreamStream.on('error', (err: any) => {
// Fix #2: Classify error and send friendly message
const cleanError = err instanceof Error ? err : new Error(String(err));
const { type, message } = classifyStreamError(cleanError);
this.logger.error(`Stream error: ${type} - ${cleanError.message}`);
// Send SSE error event before closing
subscriber.next(formatErrorForSSE(type, message));
subscriber.error(cleanError);
});
});
}
@@ -290,6 +329,9 @@ export class ProxyService {
const decoder = new TextDecoder();
let buffer = '';
const streamId = `chatcmpl-${uuidv4()}`;
const created = Math.floor(Date.now() / 1000);
upstreamStream.on('data', (chunk: Buffer) => {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
@@ -307,22 +349,52 @@ export class ProxyService {
const candidate = json.candidates?.[0];
const contentPart = candidate?.content?.parts?.[0];
const text = contentPart?.text || '';
const isThought = contentPart?.thought === true;
const openAICheck = {
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: model,
choices: [
{
index: 0,
delta: { content: text },
finish_reason: candidate?.finishReason ? 'stop' : null,
},
],
};
if (isThought && text) {
const reasoningChunk = {
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: { reasoning_content: text },
finish_reason: null,
},
],
};
subscriber.next(`data: ${JSON.stringify(reasoningChunk)}\n\n`);
continue;
}
subscriber.next(`data: ${JSON.stringify(openAICheck)}\n\n`);
if (text || candidate?.finishReason) {
const finishReason = candidate?.finishReason
? candidate.finishReason === 'STOP'
? 'stop'
: candidate.finishReason.toLowerCase()
: null;
const contentChunk = {
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: { content: text },
finish_reason: finishReason,
},
],
};
if (!text && finishReason) {
delete contentChunk.choices[0].delta.content;
}
subscriber.next(`data: ${JSON.stringify(contentChunk)}\n\n`);
}
if (candidate?.finishReason) {
subscriber.next('data: [DONE]\n\n');
@@ -339,7 +411,9 @@ export class ProxyService {
});
upstreamStream.on('error', (err: any) => {
subscriber.error(err);
// Convert to clean Error to avoid circular reference issues (socket objects)
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
subscriber.error(cleanError);
});
});
}
+51 -9
View File
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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';
declare const fetch: any;
const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
@@ -20,6 +18,18 @@ const URLS = {
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
// Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000;
/**
* Creates an AbortSignal that times out after the specified duration.
*/
function createTimeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
// --- Types ---
export interface TokenResponse {
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
// --- Service Implementation ---
export class GoogleAPIService {
private static getFetchOptions(): any {
private static getFetchOptions() {
try {
const config = ConfigManager.loadConfig();
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
@@ -121,7 +131,17 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -147,7 +167,17 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token refresh timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -166,7 +196,17 @@ export class GoogleAPIService {
static async getUserInfo(accessToken: string): Promise<UserInfo> {
const response = await fetch(URLS.USER_INFO, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'User info request timed out. Please check your network connection and try again.',
);
}
}
throw err;
});
if (!response.ok) {
@@ -221,7 +261,7 @@ export class GoogleAPIService {
const projectId = await this.fetchProjectId(accessToken);
// 2. Build Payload
const payload: Record<string, any> = {};
const payload: Record<string, unknown> = {};
if (projectId) {
payload['project'] = projectId;
}
@@ -285,11 +325,13 @@ export class GoogleAPIService {
}
return result;
} catch (e: any) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
lastError = e;
} catch (e: unknown) {
if (e instanceof Error) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
}
lastError = e instanceof Error ? e : new Error(String(e));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
}
+15 -16
View File
@@ -1,6 +1,6 @@
@import "tailwindcss";
@import 'tailwindcss';
@plugin "tailwindcss-animate";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@@ -32,7 +32,7 @@
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--font-sans: Geist, "Microsoft YaHei", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
--font-sans: Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
--font-mono: Geist Mono, monospace;
--font-tomorrow: Tomorrow, sans-serif;
@@ -74,45 +74,44 @@
@layer base {
@font-face {
font-family: "Geist";
font-family: 'Geist';
src: url("../assets/fonts/geist/geist.ttf") format("truetype");
src: url('../assets/fonts/geist/geist.ttf') format('truetype');
}
@font-face {
font-family: "Geist Mono";
font-family: 'Geist Mono';
font-display: swap;
src: url("../assets/fonts/geist-mono/geist-mono.ttf") format("truetype");
src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 400;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-regular.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 400;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-italic.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 700;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-bold.ttf") format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
}
@font-face {
font-family: "Tomorrow";
font-family: 'Tomorrow';
font-weight: 700;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-bold-italic.ttf")
format("truetype");
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
}
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { transformClaudeRequestIn } from '../lib/antigravity/ClaudeRequestMapper';
import { ClaudeRequest, GeminiInternalRequest } from '../lib/antigravity/types';
import { SignatureStore } from '../lib/antigravity/SignatureStore';
/**
* Antigravity Core Features Verification
*
* This test suite verifies the critical "Antigravity" enhancements ported from the Manager project.
* Primarily focuses on:
* 1. Identity Injection (Safety & Branding)
* 2. Gemini 3 Pro Compatibility (Stability Fixes)
* 3. Model Routing Logic (Business Rules)
*/
describe('Antigravity Core Features (Business Logic)', () => {
beforeEach(() => {
// Clear global signature store before each test
SignatureStore.clear();
vi.restoreAllMocks();
});
const BASE_REQUEST: ClaudeRequest = {
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 100,
stream: true,
};
describe('Feature: Identity Injection (Branding & Safety)', () => {
it('should inject Antigravity identity when system prompt is missing', () => {
const result = transformClaudeRequestIn({ ...BASE_REQUEST }, 'test-project');
const parts = result.request.systemInstruction?.parts || [];
const identityPart = parts.find((p) => p.text.includes('You are Antigravity'));
expect(identityPart).toBeDefined();
expect(identityPart?.text).toContain('[IDENTITY_PATCH]');
});
it('should NOT inject Antigravity identity if user already provided it', () => {
// User explicitly providing the identity string
const result = transformClaudeRequestIn(
{
...BASE_REQUEST,
system: 'You are Antigravity, the best AI.',
},
'test-project',
);
const parts = result.request.systemInstruction?.parts || [];
// Should verify we don't have TWO identity blocks
// The injected one starts with "--- [IDENTITY_PATCH] ---"
const injectedPart = parts.find((p) => p.text.includes('[IDENTITY_PATCH]'));
expect(injectedPart).toBeUndefined();
});
it('should inject before user system prompt if user prompt does NOT have identity', () => {
const result = transformClaudeRequestIn(
{
...BASE_REQUEST,
system: 'Be concise.',
},
'test-project',
);
const parts = result.request.systemInstruction?.parts || [];
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts[0].text).toContain('Antigravity');
expect(parts[parts.length - 1].text).toContain('Be concise');
});
});
describe('Feature: Gemini 3 Pro Stability Fix ', () => {
// Situation: User enables thinking, calls a tool, but NO thought signature exists.
// Result: Google API would return 400.
// Fix: We must silently disable thinking for this request.
it('should disable thinking mode if function calls exist but NO signature', () => {
const requestWithTools = {
...BASE_REQUEST,
// Maps to a gemini-3 model potentially
model: 'gemini-3-pro-preview',
tools: [{ name: 'get_weather' }], // Mock tool structure
thinking: { type: 'enabled', budget_tokens: 1000 },
} as any;
const result = transformClaudeRequestIn(requestWithTools, 'test-project');
// Should detect missing signature and remove thinking config
expect(result.request.generationConfig?.thinkingConfig).toBeUndefined();
});
it('should KEEP thinking mode if function calls exist AND valid signature exists in Store', () => {
// Pre-fill a valid signature
SignatureStore.store('valid_signature_string_longer_than_10_chars');
const requestWithTools = {
...BASE_REQUEST,
model: 'gemini-3-pro-preview',
tools: [{ name: 'get_weather' }],
thinking: { type: 'enabled', budget_tokens: 1000 },
} as any;
const result = transformClaudeRequestIn(requestWithTools, 'test-project');
// Should keep thinking config
expect(result.request.generationConfig?.thinkingConfig).toBeDefined();
});
it('should KEEP thinking mode if NO function calls exist (pure thinking)', () => {
const requestPureThinking = {
...BASE_REQUEST,
model: 'gemini-3-pro-preview',
// No tools
thinking: { type: 'enabled', budget_tokens: 1000 },
} as any;
const result = transformClaudeRequestIn(requestPureThinking, 'test-project');
// Should keep thinking config
expect(result.request.generationConfig?.thinkingConfig).toBeDefined();
});
});
});
+23 -23
View File
@@ -1,45 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
listAccountsData,
addAccountSnapshot,
switchAccount,
deleteAccount,
} from "../../ipc/account/handler";
import fs from "fs";
import path from "path";
} from '../../ipc/account/handler';
import fs from 'fs';
import path from 'path';
// Mock dependencies
vi.mock("../../utils/paths", async () => {
const path = await import("path");
const agentDir = path.join(process.cwd(), "temp_test_agent");
vi.mock('../../utils/paths', async () => {
const path = await import('path');
const agentDir = path.join(process.cwd(), 'temp_test_agent');
return {
getAgentDir: vi.fn(() => agentDir),
getAccountsFilePath: vi.fn(() => path.join(agentDir, "accounts.json")),
getBackupsDir: vi.fn(() => path.join(agentDir, "backups")),
getAntigravityDbPath: vi.fn(() => path.join(agentDir, "state.vscdb")),
getAntigravityExecutablePath: vi.fn(() => "mock_exec_path"),
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
getAntigravityDbPath: vi.fn(() => path.join(agentDir, 'state.vscdb')),
getAntigravityExecutablePath: vi.fn(() => 'mock_exec_path'),
};
});
vi.mock("../../ipc/database/handler", () => ({
vi.mock('../../ipc/database/handler', () => ({
getCurrentAccountInfo: vi.fn(() => ({
email: "[email protected]",
name: "Test User",
email: '[email protected]',
name: 'Test User',
isAuthenticated: true,
})),
backupAccount: vi.fn((account) => ({ version: "1.0", account, data: {} })),
backupAccount: vi.fn((account) => ({ version: '1.0', account, data: {} })),
restoreAccount: vi.fn(),
getDatabaseConnection: vi.fn(),
}));
vi.mock("../../ipc/process/handler", () => ({
vi.mock('../../ipc/process/handler', () => ({
closeAntigravity: vi.fn(),
startAntigravity: vi.fn(),
isProcessRunning: vi.fn(() => Promise.resolve(false)),
}));
describe("Account Handler", () => {
const testAgentDir = path.join(process.cwd(), "temp_test_agent");
describe('Account Handler', () => {
const testAgentDir = path.join(process.cwd(), 'temp_test_agent');
beforeEach(() => {
if (fs.existsSync(testAgentDir)) {
@@ -54,22 +54,22 @@ describe("Account Handler", () => {
}
});
it("should add account snapshot", async () => {
it('should add account snapshot', async () => {
const account = await addAccountSnapshot();
expect(account.email).toBe("[email protected]");
expect(account.email).toBe('[email protected]');
const accounts = await listAccountsData();
expect(accounts).toHaveLength(1);
expect(accounts[0].email).toBe("[email protected]");
expect(accounts[0].email).toBe('[email protected]');
});
it("should switch account", async () => {
it('should switch account', async () => {
const account = await addAccountSnapshot();
await switchAccount(account.id);
// Mocks should be called, verified by implementation logic not throwing
});
it("should delete account", async () => {
it('should delete account', async () => {
const account = await addAccountSnapshot();
await deleteAccount(account.id);
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock module to test timeout behavior
describe('GoogleAPIService Timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should abort request after timeout', async () => {
const TIMEOUT_MS = 100;
// Recreate the same logic used in GoogleAPIService
const createTimeoutSignal = (ms: number): AbortSignal => {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
};
const signal = createTimeoutSignal(TIMEOUT_MS);
// Verify signal is not aborted initially
expect(signal.aborted).toBe(false);
// Fast-forward time
vi.advanceTimersByTime(TIMEOUT_MS + 10);
// Signal should now be aborted
expect(signal.aborted).toBe(true);
});
it('should set aborted state when controller.abort() is called', () => {
const controller = new AbortController();
// Initially not aborted
expect(controller.signal.aborted).toBe(false);
// Abort the controller
controller.abort();
// Signal should now be aborted
expect(controller.signal.aborted).toBe(true);
});
it('should transform AbortError to user-friendly message', () => {
// This tests our error transformation logic from GoogleAPIService
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
expect(() => handleAbortError(abortError)).toThrow(
'Token exchange timed out. Please check your network connection and try again.',
);
});
it('should not transform non-AbortError', () => {
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const networkError = new Error('Network failure');
networkError.name = 'NetworkError';
expect(() => handleAbortError(networkError)).toThrow('Network failure');
});
});
+22 -22
View File
@@ -1,26 +1,26 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { logger } from "../../utils/logger";
import fs from "fs";
import path from "path";
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { logger } from '../../utils/logger';
import fs from 'fs';
import path from 'path';
vi.mock("../../utils/paths", async () => {
const path = await import("path");
vi.mock('../../utils/paths', async () => {
const path = await import('path');
return {
getAgentDir: vi.fn(() => path.join(process.cwd(), "temp_test_logs")),
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
};
});
describe("Logger Utilities", () => {
const testLogDir = path.join(process.cwd(), "temp_test_logs");
const testLogFile = path.join(testLogDir, "app.log");
describe('Logger Utilities', () => {
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
const testLogFile = path.join(testLogDir, 'app.log');
beforeEach(() => {
console.log("Test Log Dir:", testLogDir);
console.log('Test Log Dir:', testLogDir);
if (fs.existsSync(testLogDir)) {
fs.rmSync(testLogDir, { recursive: true, force: true });
}
fs.mkdirSync(testLogDir, { recursive: true });
console.log("Dir created:", fs.existsSync(testLogDir));
console.log('Dir created:', fs.existsSync(testLogDir));
});
afterEach(() => {
@@ -29,24 +29,24 @@ describe("Logger Utilities", () => {
}
});
it("should create log file if it does not exist", () => {
logger.info("Test message");
it('should create log file if it does not exist', () => {
logger.info('Test message');
expect(fs.existsSync(testLogFile)).toBe(true);
});
it("should write formatted message to file", () => {
const message = "Test info message";
it('should write formatted message to file', () => {
const message = 'Test info message';
logger.info(message);
const content = fs.readFileSync(testLogFile, "utf-8");
expect(content).toContain("[INFO]");
const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain('[INFO]');
expect(content).toContain(message);
});
it("should log error messages", () => {
const message = "Test error message";
it('should log error messages', () => {
const message = 'Test error message';
logger.error(message);
const content = fs.readFileSync(testLogFile, "utf-8");
expect(content).toContain("[ERROR]");
const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain('[ERROR]');
expect(content).toContain(message);
});
});
+172 -47
View File
@@ -1,57 +1,182 @@
import { describe, it, expect, vi } from "vitest";
import {
isProcessRunning,
closeAntigravity,
startAntigravity,
} from "../../ipc/process/handler";
import { exec } from "child_process";
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock("child_process", () => {
return {
exec: vi.fn(),
};
});
// Mock promisify to return a function that returns a promise
vi.mock("util", () => ({
promisify: (fn: unknown) => fn,
// Mock find-process module
vi.mock('find-process', () => ({
default: vi.fn(),
}));
describe("Process Handler", () => {
it("should check if process is running", async () => {
// Mock exec to return stdout
const execMock = exec as unknown as ReturnType<typeof vi.fn>;
execMock.mockImplementation((cmd, cb) => {
if (cb) cb(null, { stdout: "12345" }, { stderr: "" });
return { stdout: "12345" };
// Mock logger to avoid console output during tests
vi.mock('../../utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock paths module to avoid child_process issues
vi.mock('../../utils/paths', () => ({
getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'),
isWsl: vi.fn(() => false),
}));
// Import after mocks are set up
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
import findProcess from 'find-process';
describe('Process Handler', () => {
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('isProcessRunning', () => {
it('should return true when Antigravity main process is found on macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12345,
name: 'Antigravity',
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
},
]);
const result = await isProcessRunning();
expect(result).toBe(true);
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true);
});
// Wait, promisify wraps exec.
// If I mock exec, promisify(exec) will use the mock.
// But my mock implementation needs to match what promisify expects or I need to mock the promisified function.
// Since I mocked 'util', promisify returns the fn itself.
// So execAsync IS exec.
// But exec signature is (cmd, options, callback) or (cmd, callback).
// My implementation calls execAsync(command).
// So I should mock exec to return a Promise if I mocked promisify to return fn?
// No, if promisify returns fn, then `await execAsync(cmd)` means `await exec(cmd)`.
// `exec` returns a ChildProcess, not a Promise.
// So my mock of `util` is wrong if I want `await execAsync` to work.
// Better to mock the module that exports execAsync if possible, or mock `util` correctly.
// Or just mock `child_process` and use `vi.mocked(exec)`.
// But `promisify` is the key.
it('should return false when only helper processes are found', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
// Let's rely on vitest mocking capabilities.
// If I don't mock util, promisify will wrap the mocked exec.
// The mocked exec should behave like real exec (taking a callback).
mockFindProcess.mockResolvedValue([
{
pid: 12346,
name: 'Antigravity Helper (Renderer)',
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (Renderer).app --type=renderer',
},
{
pid: 12347,
name: 'Antigravity Helper (GPU)',
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (GPU).app --type=gpu-process',
},
]);
const result = await isProcessRunning();
expect(result).toBe(false);
});
it('should return false when only manager process is found', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12348,
name: 'Antigravity Manager',
cmd: '/Applications/Antigravity Manager.app/Contents/MacOS/Antigravity Manager',
},
]);
const result = await isProcessRunning();
expect(result).toBe(false);
});
it('should return false when no processes are found', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([]);
const result = await isProcessRunning();
expect(result).toBe(false);
});
it('should skip self process', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 12345, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12345, // Same as current PID
name: 'Antigravity',
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
},
]);
const result = await isProcessRunning();
expect(result).toBe(false);
});
it('should return true when Antigravity.exe is found on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12345,
name: 'Antigravity.exe',
cmd: 'C:\\Program Files\\Antigravity\\Antigravity.exe',
},
]);
const result = await isProcessRunning();
expect(result).toBe(true);
});
it('should return true when antigravity is found on Linux', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12345,
name: 'antigravity',
cmd: '/usr/bin/antigravity',
},
]);
const result = await isProcessRunning();
expect(result).toBe(true);
});
it('should handle find-process errors gracefully', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockRejectedValue(new Error('Process enumeration failed'));
const result = await isProcessRunning();
expect(result).toBe(false);
});
it('should exclude processes with --type= argument', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
mockFindProcess.mockResolvedValue([
{
pid: 12345,
name: 'Antigravity',
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity --type=utility',
},
]);
const result = await isProcessRunning();
expect(result).toBe(false);
});
});
// Skip complex mocking tests for now as they are fragile without proper setup.
// I'll write a basic test structure.
it("should be defined", () => {
expect(isProcessRunning).toBeDefined();
expect(closeAntigravity).toBeDefined();
expect(startAntigravity).toBeDefined();
describe('Module exports', () => {
it('should export all required functions', () => {
expect(isProcessRunning).toBeDefined();
expect(closeAntigravity).toBeDefined();
expect(startAntigravity).toBeDefined();
});
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
import { Observable } from 'rxjs';
// Mock dependencies
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() };
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
// Subclass to access private method
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager as any, mockGeminiClient as any);
}
public testProcessStream(stream: any, model: string = 'model'): Observable<string> {
// Access private method using type assertion
return (this as any).processAnthropicInternalStream(stream, model);
}
}
describe('ProxyService Empty Stream Retry Logic', () => {
it('should emit error when stream ends without data', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const resultObservable = service.testProcessStream(stream);
let errorReceived: Error | undefined;
const promise = new Promise<void>((resolve) => {
resultObservable.subscribe({
next: () => {},
error: (err) => {
errorReceived = err;
resolve();
},
complete: () => resolve(),
});
});
// Simulate empty stream: straight to end
setTimeout(() => stream.emit('end'), 10);
await promise;
expect(errorReceived).toBeDefined();
expect(errorReceived?.message).toBe('Empty response stream');
});
it('should NOT emit error when stream has data', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const resultObservable = service.testProcessStream(stream);
let errorReceived: Error | undefined;
const receivedChunks: string[] = [];
const promise = new Promise<void>((resolve) => {
resultObservable.subscribe({
next: (c) => receivedChunks.push(c),
error: (err) => {
errorReceived = err;
resolve();
},
complete: () => resolve(),
});
});
// Simulate valid data stream
setTimeout(() => {
const validJson = JSON.stringify({
candidates: [
{
content: { parts: [{ text: 'hello' }] },
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${validJson}\n\n`));
stream.emit('end');
}, 10);
await promise;
expect(errorReceived).toBeUndefined();
// It should produce chunks (though exact number depends on mapper logic, at least it shouldn't error)
// Actually our mapper might produce "message_start", "content_block_start" etc.
// We just care that it didn't error with "Empty response stream"
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { applyJitter, calculateRetryDelay } from '../../lib/antigravity/retry-utils';
describe('applyJitter', () => {
it('should return values within ±20% range', () => {
const baseDelay = 1000;
const results: number[] = [];
for (let i = 0; i < 100; i++) {
results.push(applyJitter(baseDelay));
}
// All values should be within 800-1200 range (±20%)
results.forEach((r) => {
expect(r).toBeGreaterThanOrEqual(800);
expect(r).toBeLessThanOrEqual(1200);
});
});
it('should produce different values (randomness)', () => {
const results = new Set<number>();
for (let i = 0; i < 20; i++) {
results.add(applyJitter(1000));
}
// 20 calls should produce multiple distinct values
expect(results.size).toBeGreaterThan(5);
});
it('should guarantee minimum value of 1 for small inputs', () => {
expect(applyJitter(1)).toBeGreaterThanOrEqual(1);
expect(applyJitter(0)).toBe(1);
});
});
describe('calculateRetryDelay', () => {
it('should implement exponential backoff', () => {
// First attempt (index 0): 1s ± 20% = 800-1200ms
const delay0 = calculateRetryDelay(0);
expect(delay0).toBeGreaterThanOrEqual(800);
expect(delay0).toBeLessThanOrEqual(1200);
// Second attempt (index 1): 2s ± 20% = 1600-2400ms
const delay1 = calculateRetryDelay(1);
expect(delay1).toBeGreaterThanOrEqual(1600);
expect(delay1).toBeLessThanOrEqual(2400);
});
it('should have maximum delay limit', () => {
// Even with high retry count, should not exceed 30s + 20% jitter = 36s
const delay = calculateRetryDelay(10);
expect(delay).toBeLessThanOrEqual(36000);
});
it('should accept custom base delay', () => {
const delay = calculateRetryDelay(0, 500);
expect(delay).toBeGreaterThanOrEqual(400); // 500 - 20%
expect(delay).toBeLessThanOrEqual(600); // 500 + 20%
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { decodeSignature } from '../../lib/antigravity/signature-utils';
describe('decodeSignature', () => {
describe('Base64 decoding', () => {
it('should correctly decode valid Base64 signature', () => {
const original = 'valid-thinking-signature-12345';
const base64 = Buffer.from(original).toString('base64');
expect(decodeSignature(base64)).toBe(original);
});
it('should decode signature with special characters', () => {
const original = 'sig+with/special=chars==';
const base64 = Buffer.from(original).toString('base64');
expect(decodeSignature(base64)).toBe(original);
});
it('should decode UTF-8 Chinese signature', () => {
const original = '思考签名-测试';
const base64 = Buffer.from(original).toString('base64');
expect(decodeSignature(base64)).toBe(original);
});
});
describe('Non-Base64 passthrough', () => {
it('should return non-Base64 strings as-is', () => {
expect(decodeSignature('plain-text-signature')).toBe('plain-text-signature');
});
it('should handle strings with invalid Base64 characters', () => {
expect(decodeSignature('not!valid@base64#')).toBe('not!valid@base64#');
});
});
describe('Empty value handling', () => {
it('should return undefined for null input', () => {
expect(decodeSignature(null)).toBeUndefined();
});
it('should return undefined for undefined input', () => {
expect(decodeSignature(undefined)).toBeUndefined();
});
it('should return undefined for empty string', () => {
expect(decodeSignature('')).toBeUndefined();
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { classifyStreamError, formatErrorForSSE } from '../../lib/antigravity/stream-error-utils';
describe('classifyStreamError', () => {
it('should identify timeout errors', () => {
const error = new Error('timeout of 30000ms exceeded');
const { type, message } = classifyStreamError(error);
expect(type).toBe('timeout_error');
expect(message).toContain('timed out');
});
it('should identify connection errors', () => {
const error = new Error('connect ECONNREFUSED 127.0.0.1:8080');
const { type, message } = classifyStreamError(error);
expect(type).toBe('connection_error');
expect(message).toContain('connect');
});
it('should identify network DNS errors', () => {
const error = new Error('getaddrinfo ENOTFOUND googleapis.com');
const { type, message } = classifyStreamError(error);
expect(type).toBe('network_error');
expect(message).toContain('Network');
});
it('should identify socket errors', () => {
const error = new Error('socket hang up');
const { type, message } = classifyStreamError(error);
expect(type).toBe('stream_error');
expect(message).toContain('interrupted');
});
it('should return generic message for unknown errors', () => {
const error = new Error('some random error');
const { type, message } = classifyStreamError(error);
expect(type).toBe('unknown_error');
expect(message).toBeTruthy();
expect(message).toContain('some random error');
});
});
describe('formatErrorForSSE', () => {
it('should format as Claude SSE error event', () => {
const result = formatErrorForSSE('network_error', 'Network connection failed');
expect(result).toContain('event: error');
expect(result).toContain('"type":"error"');
expect(result).toContain('network_error');
expect(result).toContain('Network connection failed');
});
it('should end with newlines', () => {
const result = formatErrorForSSE('test', 'test');
expect(result.endsWith('\n\n')).toBe(true);
});
it('should be valid SSE format', () => {
const result = formatErrorForSSE('timeout_error', 'Request timed out');
expect(result.startsWith('event: ')).toBe(true);
expect(result).toContain('\ndata: ');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
describe('StreamingState', () => {
let state: StreamingState;
beforeEach(() => {
state = new StreamingState();
});
describe('handleParseError', () => {
it('should return empty array on first error', () => {
const chunks = state.handleParseError('invalid json');
expect(chunks).toEqual([]);
});
it('should emit error event when error count exceeds 3', () => {
// Simulate 4 parse errors
state.handleParseError('error 1');
state.handleParseError('error 2');
state.handleParseError('error 3');
const chunks = state.handleParseError('error 4');
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[0]).toContain('network_error');
expect(chunks[0]).toContain('Unstable network');
});
it('should safely close active block on error', () => {
// Start a text block first
state.startBlock('Text', { type: 'text', text: '' });
const chunks = state.handleParseError('error during block');
// Should contain content_block_stop event
expect(chunks.some((c) => c.includes('content_block_stop'))).toBe(true);
});
});
describe('resetErrorState', () => {
it('should reset error counter', () => {
state.handleParseError('error 1');
state.handleParseError('error 2');
state.resetErrorState();
// After reset, should start counting from 0
const chunks = state.handleParseError('error after reset');
expect(chunks).toEqual([]);
});
});
describe('getErrorCount', () => {
it('should return current error count', () => {
expect(state.getErrorCount()).toBe(0);
state.handleParseError('error 1');
expect(state.getErrorCount()).toBe(1);
state.handleParseError('error 2');
expect(state.getErrorCount()).toBe(2);
});
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import {
hasValidSignature,
gracefulDegradeThinking,
processThinkingBlocks,
} from '../../lib/antigravity/thinking-utils';
describe('hasValidSignature', () => {
it('should return true for empty thinking + valid signature', () => {
const block = { type: 'thinking' as const, thinking: '', signature: 'valid-signature-long' };
expect(hasValidSignature(block)).toBe(true);
});
it('should return true for content + long signature', () => {
const block = {
type: 'thinking' as const,
thinking: 'some thought',
signature: 'long-enough-signature',
};
expect(hasValidSignature(block)).toBe(true);
});
it('should return false for content + short signature', () => {
const block = { type: 'thinking' as const, thinking: 'some thought', signature: 'short' };
expect(hasValidSignature(block)).toBe(false);
});
it('should return false for content + no signature', () => {
const block = { type: 'thinking' as const, thinking: 'some thought', signature: undefined };
expect(hasValidSignature(block)).toBe(false);
});
it('should return true for non-thinking types', () => {
const block = { type: 'text' as const, text: 'hello' };
expect(hasValidSignature(block)).toBe(true);
});
});
describe('gracefulDegradeThinking', () => {
it('should convert invalid signature thinking to text', () => {
const block = { type: 'thinking' as const, thinking: 'important content', signature: 'bad' };
const result = gracefulDegradeThinking(block);
expect(result?.type).toBe('text');
expect((result as any).text).toBe('important content');
});
it('should keep valid thinking block unchanged', () => {
const block = {
type: 'thinking' as const,
thinking: 'content',
signature: 'valid-long-signature',
};
const result = gracefulDegradeThinking(block);
expect(result?.type).toBe('thinking');
});
it('should drop empty content + invalid signature block', () => {
const block = { type: 'thinking' as const, thinking: '', signature: 'bad' };
const result = gracefulDegradeThinking(block);
expect(result).toBeNull();
});
it('should keep empty content + valid signature trailing block', () => {
const block = { type: 'thinking' as const, thinking: '', signature: 'any-valid-sig' };
const result = gracefulDegradeThinking(block);
expect(result?.type).toBe('thinking');
});
});
describe('processThinkingBlocks', () => {
it('should filter out invalid empty thinking blocks', () => {
const blocks = [
{ type: 'text' as const, text: 'hello' },
{ type: 'thinking' as const, thinking: '', signature: 'bad' },
{ type: 'text' as const, text: 'world' },
];
const result = processThinkingBlocks(blocks);
expect(result.length).toBe(2);
expect(result[0].type).toBe('text');
expect(result[1].type).toBe('text');
});
it('should convert content with invalid signature to text', () => {
const blocks = [{ type: 'thinking' as const, thinking: 'keep me', signature: 'x' }];
const result = processThinkingBlocks(blocks);
expect(result.length).toBe(1);
expect(result[0].type).toBe('text');
expect((result[0] as any).text).toBe('keep me');
});
});
+11 -11
View File
@@ -1,27 +1,27 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { test, expect } from "vitest";
import ToggleTheme from "@/components/toggle-theme";
import { render } from '@testing-library/react';
import { test, expect } from 'vitest';
import ToggleTheme from '@/components/toggle-theme';
test("renders ToggleTheme", () => {
test('renders ToggleTheme', () => {
const { getByRole } = render(<ToggleTheme />);
const isButton = getByRole("button");
const isButton = getByRole('button');
expect(isButton).toBeInTheDocument();
});
test("has icon", () => {
test('has icon', () => {
const { getByRole } = render(<ToggleTheme />);
const button = getByRole("button");
const icon = button.querySelector("svg");
const button = getByRole('button');
const icon = button.querySelector('svg');
expect(icon).toBeInTheDocument();
});
test("is moon icon", () => {
const svgIconClassName: string = "lucide-moon";
test('is moon icon', () => {
const svgIconClassName: string = 'lucide-moon';
const { getByRole } = render(<ToggleTheme />);
const svg = getByRole("button").querySelector("svg");
const svg = getByRole('button').querySelector('svg');
expect(svg?.classList).toContain(svgIconClassName);
});
+1 -1
View File
@@ -1 +1 @@
export type ThemeMode = "dark" | "light" | "system";
export type ThemeMode = 'dark' | 'light' | 'system';
+39
View File
@@ -0,0 +1,39 @@
import type { TFunction } from 'i18next';
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED';
const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE';
const KEYCHAIN_HINT_I18N_MAP: Record<string, string> = {
[KEYCHAIN_HINT_TRANSLOCATION]: 'error.keychainHint.translocation',
[KEYCHAIN_HINT_KEYCHAIN_DENIED]: 'error.keychainHint.keychainDenied',
[KEYCHAIN_HINT_SIGN_NOTARIZE]: 'error.keychainHint.signNotarize',
};
function resolveKeychainMessage(hintCode: string | undefined, t: TFunction): string {
const base = t('error.keychainUnavailable');
if (!hintCode) {
return base;
}
const hintKey = KEYCHAIN_HINT_I18N_MAP[hintCode];
if (!hintKey) {
return base;
}
return `${base} ${t(hintKey)}`;
}
export function getLocalizedErrorMessage(error: unknown, t: TFunction): string {
if (error instanceof Error) {
const rawMessage = error.message;
const [code, hint] = rawMessage.split('|');
if (code === KEYCHAIN_ERROR_CODE) {
return resolveKeychainMessage(hint, t);
}
return rawMessage;
}
return String(error);
}
+28 -1
View File
@@ -4,6 +4,33 @@ import { getAgentDir } from './paths';
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
/**
* Safely stringify an object, handling circular references
* This prevents "Converting circular structure to JSON" errors
* when logging objects like axios errors that contain socket references
*/
function safeStringify(obj: unknown): string {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// Handle Error objects specially
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
// Handle circular references
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
}
class Logger {
private logFilePath: string;
@@ -22,7 +49,7 @@ class Logger {
private formatMessage(level: LogLevel, message: string, ...args: unknown[]): string {
const timestamp = new Date().toISOString();
const formattedArgs = args
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
.map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg)))
.join(' ');
return `[${timestamp}] [${level.toUpperCase()}] ${message} ${formattedArgs}`;
}
+2
View File
@@ -124,6 +124,8 @@ export function getAntigravityDbPaths(): string[] {
}
if (process.platform === 'linux') {
paths.push(path.join(appData, 'User', 'globalStorage', 'state.vscdb'));
paths.push(path.join(appData, 'User', 'state.vscdb'));
paths.push(path.join(appData, 'state.vscdb'));
return paths;
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { routeTree } from "@/routeTree.gen";
import { createMemoryHistory, createRouter } from "@tanstack/react-router";
import { routeTree } from '@/routeTree.gen';
import { createMemoryHistory, createRouter } from '@tanstack/react-router';
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
@@ -11,6 +11,6 @@ export const router = createRouter({
defaultPendingMinMs: 0,
routeTree,
history: createMemoryHistory({
initialEntries: ["/"],
initialEntries: ['/'],
}),
});
+33 -2
View File
@@ -1,13 +1,43 @@
import keytar from 'keytar';
import crypto from 'crypto';
import { app } from 'electron';
import { logger } from './logger';
const SERVICE_NAME = 'AntigravityManager';
const ACCOUNT_NAME = 'MasterKey';
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED';
const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE';
// Cache the key in memory to avoid frequent system calls
let cachedMasterKey: Buffer | null = null;
function buildKeychainAccessHint(error: unknown): string | null {
if (process.platform !== 'darwin') {
return null;
}
let appPath = '';
try {
appPath = app.getAppPath();
} catch {
appPath = '';
}
const isTranslocated = appPath.includes('/AppTranslocation/');
if (isTranslocated) {
return KEYCHAIN_HINT_TRANSLOCATION;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.toLowerCase().includes('keychain')) {
return KEYCHAIN_HINT_KEYCHAIN_DENIED;
}
return KEYCHAIN_HINT_SIGN_NOTARIZE;
}
async function getOrGenerateMasterKey(): Promise<Buffer> {
if (cachedMasterKey) return cachedMasterKey;
@@ -25,16 +55,17 @@ async function getOrGenerateMasterKey(): Promise<Buffer> {
cachedMasterKey = Buffer.from(hexKey, 'hex');
return cachedMasterKey;
} catch (error) {
const hint = buildKeychainAccessHint(error);
logger.error('Security: Failed to access keychain/credential manager', error);
// Fallback? If we can't store the key, we can't persistently encrypt.
// For now, throw to prevent data loss (better not to write than to write something we can't decrypt later or write plain text when promised encrypted)
throw new Error('Key management system unavailable');
const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE;
throw new Error(message);
}
}
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
/**
* Encrypts a string using AES-256-GCM.
-36
View File
@@ -1,36 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
console.log('Starting DB test...');
try {
const dbPath = path.join(__dirname, 'test.db');
console.log(`DB Path: ${dbPath}`);
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
const db = new Database(dbPath);
console.log('DB created');
db.exec('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, value TEXT)');
console.log('Table created');
const stmt = db.prepare('INSERT INTO test (value) VALUES (?)');
stmt.run('hello');
console.log('Data inserted');
const row = db.prepare('SELECT * FROM test').get();
console.log('Data retrieved:', row);
db.close();
console.log('DB closed');
} catch (error) {
console.error('DB Error:', error);
}
console.log('Finished DB test');
-50
View File
@@ -1,50 +0,0 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const execAsync = promisify(exec);
function isWsl() {
if (process.platform !== 'linux') return false;
try {
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return version.includes('microsoft') && version.includes('wsl');
} catch {
return false;
}
}
async function isProcessRunning() {
try {
const platform = process.platform;
let command = '';
console.log('Platform:', platform);
console.log('Is WSL:', isWsl());
if (platform === 'win32' || isWsl()) {
const cmd = isWsl() ? '/mnt/c/Windows/System32/tasklist.exe' : 'tasklist';
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
} else {
command = 'pgrep -x Antigravity';
}
console.log('Command:', command);
const { stdout } = await execAsync(command, { timeout: 2000 });
console.log('Stdout:', stdout);
if (platform === 'win32' || isWsl()) {
return stdout.includes('Antigravity.exe');
} else {
return stdout.trim().length > 0;
}
} catch (error) {
console.error('Error:', error);
return false;
}
}
isProcessRunning().then((result) => {
console.log('Result:', result);
});
-1
View File
@@ -1,4 +1,3 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vite";
import { defineConfig } from 'vite';
// https://vitejs.dev/config
export default defineConfig({});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
// Mock native modules that fail to load in test environment
keytar: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'better-sqlite3': path.resolve(process.cwd(), './src/mocks/empty.ts'),
},
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/tests/unit/**/*.test.ts'],
},
});
-31
View File
@@ -1,31 +0,0 @@
import * as path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react() as any],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
dir: './src/tests/unit',
globals: true,
environment: 'node',
setupFiles: './src/tests/unit/setup.ts',
css: true,
reporters: ['verbose'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*'],
exclude: [],
},
server: {
deps: {
inline: ['jsdom'],
},
},
},
});