Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36848b46dc | ||
|
|
5bda4b19df | ||
|
|
476d297e4b | ||
|
|
ce7147025d | ||
|
|
080fb67974 | ||
|
|
f6f8069ce4 | ||
|
|
6328378da5 | ||
|
|
d25f2948c3 | ||
|
|
63956c9c2d | ||
|
|
a785640d7a | ||
|
|
1265d044f6 | ||
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db | ||
|
|
a988a33d21 | ||
|
|
e959ee346e | ||
|
|
5a2168030e | ||
|
|
00bf4dea0b | ||
|
|
02080588b7 | ||
|
|
d5ee08d5a0 | ||
|
|
e844aa9545 | ||
|
|
0d4e2ab21f | ||
|
|
f15bb48fdd | ||
|
|
515648d64d | ||
|
|
22265e153c | ||
|
|
ad39a735b5 | ||
|
|
6a07bc0a10 | ||
|
|
e91457eceb | ||
|
|
01e3f8f8fd | ||
|
|
bd3d41aed1 | ||
|
|
d2945a6e8a | ||
|
|
89f39710c7 | ||
|
|
1bce5d331d | ||
|
|
47be175e27 | ||
|
|
f0718db374 | ||
|
|
4b12f1220d | ||
|
|
47c14763de | ||
|
|
37a9750df2 | ||
|
|
f708de24c0 | ||
|
|
6e7f2252ad | ||
|
|
e96d3e0e8e | ||
|
|
12ab4e0ce7 | ||
|
|
013f3290f3 | ||
|
|
f224fea081 | ||
|
|
a438070268 | ||
|
|
e927ac9506 | ||
|
|
465eb5d0a4 | ||
|
|
37b0fdeeb0 | ||
|
|
a343e0c1ae | ||
|
|
695d787b01 | ||
|
|
b4ca037518 | ||
|
|
c43507c716 | ||
|
|
f5c44c549f | ||
|
|
f37127d385 | ||
|
|
f636dd61a5 | ||
|
|
42973561fb | ||
|
|
5772e2e90e | ||
|
|
b0ec96d4a6 | ||
|
|
5a004d9d16 | ||
|
|
ccc5c91969 | ||
|
|
dcfed908a5 | ||
|
|
1336de679f | ||
|
|
bc43cc101c | ||
|
|
01f6a5aaee | ||
|
|
f8e06efcca | ||
|
|
13f02c7ac2 | ||
|
|
b425dc3758 | ||
|
|
fb13826afd | ||
|
|
166a92ab94 | ||
|
|
2a961f8520 | ||
|
|
db675821b8 | ||
|
|
c182a514bf | ||
|
|
a079cf5ccc | ||
|
|
2b443251bb | ||
|
|
7768cf5309 | ||
|
|
64e27ab821 | ||
|
|
f5a85e3436 | ||
|
|
098cbe3eb9 | ||
|
|
bc775d4fd6 | ||
|
|
e7087bc4b1 | ||
|
|
c4a0fa773f |
No files matched your search
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: antigravity-manager
|
||||
description: Comprehensive guide to Antigravity Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
---
|
||||
|
||||
# Antigravity Manager Developer Guide
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
Antigravity Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[User Interface] -->|React/Vite| Renderer[Renderer Process]
|
||||
Renderer -->|ORPC Client| IPC[IPC Layer]
|
||||
IPC -->|ORPC Router| Main[Main Process]
|
||||
Main -->|Bootstraps| Server[NestJS Server]
|
||||
Main -->|Calls| Services[Service Layer]
|
||||
Services -->|Read/Write| DB[(SQLite Database)]
|
||||
Services -->|HTTP| Cloud[Cloud APIs (Google/Anthropic)]
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, TailwindCSS v4, TanStack Router, TanStack Query.
|
||||
- **Backend**: Electron (Main), NestJS (Core Logic), Better-SQLite3 (Data).
|
||||
- **Communication**: ORPC (Type-safe IPC wrapper around Electron IPC).
|
||||
- **Build**: Electron Forge + Vite.
|
||||
|
||||
## 📂 Directory Structure
|
||||
|
||||
- `src/main.ts`: Electron Main Process entry point.
|
||||
- `src/preload.ts`: Bridge between Main and Renderer.
|
||||
- `src/renderer.tsx`: React App entry point.
|
||||
- `src/components/`: Reusable React UI components (Radix UI based).
|
||||
- `src/ipc/`: IPC Routers and Handlers (Domain logic).
|
||||
- `router.ts`: Main ORPC router definition.
|
||||
- `account/`, `cloud/`, `database/`: Domain-specific handlers.
|
||||
- `src/server/`: NestJS application modules (proxies/gateways).
|
||||
- `src/services/`: Core business logic (framework agnostic).
|
||||
- `GoogleAPIService.ts`: Gemini/Cloud interactions.
|
||||
- `AutoSwitchService.ts`: Account rotation logic.
|
||||
- `src/routes/`: Frontend routing definitions (File-based).
|
||||
|
||||
## 🚀 Development Workflow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm (Project uses `package-lock.json`)
|
||||
|
||||
### Common Commands
|
||||
|
||||
- **Start Dev Server**: `npm start`
|
||||
- **Lint Code**: `npm run lint`
|
||||
- **Unit Test**: `npm run test:unit`
|
||||
- **E2E Test**: `npm run test:e2e`
|
||||
- **Build Production**: `npm run make`
|
||||
|
||||
## 🧠 Core Concepts
|
||||
|
||||
### IPC Communication (ORPC)
|
||||
|
||||
The project uses `orpc` for type-safe communication.
|
||||
|
||||
- **Define**: Create a router in `src/ipc/router.ts` with Zod schemas.
|
||||
- **Implement**: Add logic in handlers (e.g., `src/ipc/account/handler.ts`).
|
||||
- **Call**: Use the generated client in React components.
|
||||
|
||||
### Database Access
|
||||
|
||||
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
|
||||
|
||||
- Use `Better-SQLite3` for direct access.
|
||||
- Logic should be encapsulated in `src/services` or `src/ipc`.
|
||||
|
||||
### Account Management
|
||||
|
||||
- Accounts are added via OAuth (Google/Claude).
|
||||
- `GoogleAPIService` handles token exchange and refreshing.
|
||||
- `AutoSwitchService` monitors usage and switches active accounts automatically.
|
||||
|
||||
## ⚠️ Critical Rules
|
||||
|
||||
1. **Type Safety**: strict TypeScript usage; Zod for runtime validation.
|
||||
2. **Components**: Use `src/components/ui` (Radix primitives) for consistency.
|
||||
3. **Async**: Handle all IPC/DB calls asynchronously with try/catch.
|
||||
4. **Security**: Never commit secrets. API keys are user-provided or encrypted locally.
|
||||
@@ -1,14 +1,18 @@
|
||||
---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
|
||||
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||
@@ -16,5 +20,6 @@ Track these steps as TODOs and complete them one by one.
|
||||
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
@@ -19,6 +23,7 @@ description: Archive a deployed OpenSpec change and update specs.
|
||||
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec list` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -1,8 +1,11 @@
|
||||
---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
@@ -10,6 +13,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||
@@ -19,6 +23,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
|
||||
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report a bug to help us improve
|
||||
title: '[Bug] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Describe the Bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Steps To Reproduce
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What actually happened.
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: [e.g., Windows 11, macOS Sonoma, Ubuntu 22.04]
|
||||
- **App Version**: [e.g., 0.0.1]
|
||||
- **Node.js Version**: [e.g., 18.17.0]
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/Draculabo/AntigravityManager#readme
|
||||
about: Check our README for usage information
|
||||
- name: ❓ Questions
|
||||
url: https://github.com/Draculabo/AntigravityManager/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: 💡 Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: '[Feature] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem?
|
||||
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Are you willing to contribute?
|
||||
|
||||
- [ ] Yes, I'd like to implement this feature
|
||||
- [ ] Yes, I can help with testing
|
||||
- [ ] No, I'm just suggesting
|
||||
@@ -1,6 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
@@ -1,7 +1,7 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
categories:
|
||||
- title: '🚀 What''s new'
|
||||
- title: "🚀 What's new"
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'feat'
|
||||
|
||||
@@ -1,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, windows-11-arm, macos-latest, macos-14, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -32,7 +31,7 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm
|
||||
sudo apt-get install -y rpm libsecret-1-dev pkg-config
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -40,4 +39,8 @@ jobs:
|
||||
- name: Publish Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: npm run publish
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
@@ -102,3 +102,6 @@ out/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
@@ -3,4 +3,7 @@
|
||||
/coverage
|
||||
/.vite
|
||||
README.md
|
||||
/src/routeTree.gen.ts
|
||||
CHANGELOG.md
|
||||
/src/routeTree.gen.ts
|
||||
.agents/
|
||||
openspec/
|
||||
@@ -8,5 +8,4 @@
|
||||
"tabWidth": 2,
|
||||
"proseWrap": "preserve",
|
||||
"tailwindFunctions": ["clsx", "tw"]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||
}
|
||||
@@ -1,18 +1,506 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,184 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.6.0](https://github.com/Draculabo/AntigravityManager/compare/v0.5.0...v0.6.0) (2026-02-04)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* add cloud reset time UI ([#56](https://github.com/Draculabo/AntigravityManager/issues/56)) ([f6f8069](https://github.com/Draculabo/AntigravityManager/commit/f6f8069ce4673ae7027c6ba248daa18c8b602218))
|
||||
* windows install guidance ([#63](https://github.com/Draculabo/AntigravityManager/issues/63)) ([ce71470](https://github.com/Draculabo/AntigravityManager/commit/ce7147025d37648a82f6f6b300502ef4462bfde6))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* correct Windows install notice path ([5bda4b1](https://github.com/Draculabo/AntigravityManager/commit/5bda4b19dfc9ce64735cd39600d41a5deefc26ee))
|
||||
* **proxy:** route Claude Code CLI requests on /v1/chat/completions to Anthropic handler ([#61](https://github.com/Draculabo/AntigravityManager/issues/61)) ([476d297](https://github.com/Draculabo/AntigravityManager/commit/476d297e4b2dd3015f7ebcbb915b643730816dc6))
|
||||
|
||||
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* **i18n:** add Russian localization ([#48](https://github.com/Draculabo/AntigravityManager/issues/48)) ([63956c9](https://github.com/Draculabo/AntigravityManager/commit/63956c9c2d60f829a998237abe6ade675fdb01ed))
|
||||
* Implement collapsible sidebar and refined status bar UI ([#45](https://github.com/Draculabo/AntigravityManager/issues/45)) ([1265d04](https://github.com/Draculabo/AntigravityManager/commit/1265d044f69e52fba7da72fde6c47a0b85c58232))
|
||||
* sentry integration ([#51](https://github.com/Draculabo/AntigravityManager/issues/51)) ([a785640](https://github.com/Draculabo/AntigravityManager/commit/a785640d7a51b65a6852383401bd7c284b716975))
|
||||
|
||||
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
|
||||
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
|
||||
|
||||
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
|
||||
|
||||
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
|
||||
|
||||
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
|
||||
|
||||
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
|
||||
- clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
|
||||
|
||||
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
|
||||
|
||||
### 🔧 Continuous Integration
|
||||
|
||||
- setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
|
||||
- trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
|
||||
|
||||
## [0.3.1] - 2026-01-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
|
||||
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
|
||||
|
||||
## [0.3.0] - 2026-01-23
|
||||
|
||||
### New Features
|
||||
|
||||
- Verify Google OAuth code automatically after receipt
|
||||
- Add button to open logs folder
|
||||
- Add expiration warning for Google OAuth authentication
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
|
||||
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
|
||||
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Add VS Code settings for auto-formatting and ESLint
|
||||
|
||||
## [0.2.2] - 2026-01-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tray icon not appearing in production builds on Windows
|
||||
- Used `extraResource` config to properly copy assets outside of ASAR package
|
||||
- Added debug logging for tray icon path resolution
|
||||
|
||||
## [0.2.1] - 2026-01-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
|
||||
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Applied Prettier formatting to entire codebase (68 files)
|
||||
- Added node globals to ESLint configuration
|
||||
|
||||
## [0.2.0] - 2026-01-16
|
||||
|
||||
### New Features
|
||||
|
||||
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
|
||||
- Implemented stability fixes and enhanced error handling across the application.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
|
||||
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed "Converting circular structure to JSON" error.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added curly brace constraints for conditional statements.
|
||||
- Fixed incorrect reference documentation name.
|
||||
|
||||
## [0.1.1] - 2026-01-11
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
|
||||
|
||||
## [0.1.0] - 2026-01-10
|
||||
|
||||
### New Features
|
||||
|
||||
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
|
||||
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
|
||||
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
|
||||
|
||||
## [0.0.1] - 2025-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Antigravity Manager
|
||||
- Multi-account management for Google Gemini and Claude
|
||||
- Real-time quota monitoring
|
||||
- Intelligent auto-switching capabilities
|
||||
- Secure credential storage (AES-256-GCM)
|
||||
- IDE synchronization
|
||||
- Dark mode support
|
||||
- System tray integration
|
||||
@@ -0,0 +1,49 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [INSERT EMAIL ADDRESS]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
---
|
||||
|
||||
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Contributing to Antigravity Manager
|
||||
|
||||
First off, thank you for considering contributing to Antigravity Manager! 🎉
|
||||
|
||||
It's people like you that make Antigravity Manager such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Style Guidelines](#style-guidelines)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
|
||||
## 📜 Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
```
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Draculabo/AntigravityManager.git
|
||||
```
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js v18 or higher
|
||||
- npm (comes with Node.js)
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
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 |
|
||||
|
||||
## ✏️ Making Changes
|
||||
|
||||
1. **Create a new branch** from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
2. **Make your changes** and commit them:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add amazing new feature"
|
||||
```
|
||||
|
||||
3. **Keep your branch updated**:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### Commit Message Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/). Each commit message should be structured as follows:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation only changes
|
||||
- `style`: Changes that don't affect code meaning (formatting, etc.)
|
||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||
- `perf`: Performance improvement
|
||||
- `test`: Adding or correcting tests
|
||||
- `chore`: Changes to build process or auxiliary tools
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
feat(auth): add Google OAuth support
|
||||
fix(quota): resolve quota refresh timeout issue
|
||||
docs(readme): update installation instructions
|
||||
```
|
||||
|
||||
## 🔄 Pull Request Process
|
||||
|
||||
1. **Update documentation** if you're changing functionality
|
||||
2. **Add tests** for new features
|
||||
3. **Ensure all tests pass**: `npm run test:all`
|
||||
4. **Ensure code is formatted**: `npm run format:write`
|
||||
5. **Ensure no lint errors**: `npm run lint`
|
||||
6. **Push to your fork** and create a Pull Request
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
|
||||
## 🎨 Style Guidelines
|
||||
|
||||
### TypeScript/JavaScript
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Follow the existing code style (enforced by ESLint and Prettier)
|
||||
- Use meaningful variable and function names
|
||||
- Add JSDoc comments for public APIs
|
||||
|
||||
### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components small and focused
|
||||
- Use TypeScript interfaces for props
|
||||
- Follow the existing file structure
|
||||
|
||||
### CSS/Styling
|
||||
|
||||
- Use TailwindCSS utility classes
|
||||
- Follow the design system in `components.json`
|
||||
- Ensure responsive design
|
||||
|
||||
## 🐛 Reporting Bugs
|
||||
|
||||
Found a bug? Please [create an issue](https://github.com/Draculabo/AntigravityManager/issues/new?template=bug_report.md) with:
|
||||
|
||||
- **Clear title** describing the issue
|
||||
- **Steps to reproduce** the behavior
|
||||
- **Expected behavior** vs actual behavior
|
||||
- **Screenshots** if applicable
|
||||
- **Environment details** (OS, app version)
|
||||
|
||||
## 💡 Suggesting Features
|
||||
|
||||
Have an idea? Please [create an issue](https://github.com/Draculabo/AntigravityManager/issues/new?template=feature_request.md) with:
|
||||
|
||||
- **Clear description** of the feature
|
||||
- **Use case** - why is this feature needed?
|
||||
- **Possible implementation** ideas (optional)
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
Your contributions help make Antigravity Manager better for everyone. Thank you for taking the time to contribute!
|
||||
|
||||
---
|
||||
|
||||
If you have any questions, feel free to open an issue or reach out to the maintainers.
|
||||
@@ -1,121 +1,339 @@
|
||||
# Antigravity Manager
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
Antigravity Manager is a professional Electron-based application designed to manage multiple cloud AI accounts (Google Gemini / Claude) for the Antigravity application. It provides seamless account switching, real-time quota monitoring, intelligent auto-switching, and comprehensive backup management.
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
|
||||
## Features
|
||||
<p align="center">
|
||||
<strong>🚀 Professional multi-account manager for Google Gemini & Claude AI</strong>
|
||||
</p>
|
||||
|
||||
### Core Features
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
- **Cloud Account Pool Management**
|
||||
- Add unlimited Google Gemini / Claude accounts via OAuth authorization
|
||||
- Display account details: avatar, email, status, and last used time
|
||||
- Real-time account status monitoring (Active, Rate Limited, Expired)
|
||||
- Delete accounts from local database
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- **Real-time Quota Monitoring**
|
||||
- Multi-model support: Monitor quotas for `gemini-pro`, `claude-3-5-sonnet`, etc.
|
||||
- Visual display with progress bars and color indicators (green/yellow/red)
|
||||
- Auto & manual refresh capabilities
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
- **Intelligent Auto-Switching**
|
||||
- Unlimited pool mode: Automatically find and switch to the best backup account when current quota is low (<5%) or rate-limited
|
||||
- Background monitoring: Built-in `CloudMonitorService` polls all accounts every 5 minutes
|
||||
- Global toggle: Enable/disable via one-click in the UI
|
||||
---
|
||||
|
||||
- **One-Click Account Switching**
|
||||
- Inject tokens directly into IDE's `state.vscdb` database using Protobuf parsing
|
||||
- Graceful process restart to apply new credentials
|
||||
## 📖 Table of Contents
|
||||
|
||||
### Account Backup
|
||||
- [Why Antigravity Manager?](#-why-antigravity-manager)
|
||||
- [Features](#-features)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Development](#-development)
|
||||
- [FAQ](#-faq)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
|
||||
- **Backup**: Capture snapshots of your current account state
|
||||
- **Restore**: Fast and easy switching between multiple saved accounts
|
||||
- **Management**: View, organize, and delete saved account snapshots
|
||||
---
|
||||
|
||||
### Process Control
|
||||
## ✨ Why Antigravity Manager?
|
||||
|
||||
- Automatically detect if Antigravity is running
|
||||
- Launch Antigravity via URI protocol or executable path
|
||||
- Gracefully close or force kill the Antigravity process
|
||||
When using Antigravity IDE, have you ever encountered these problems?
|
||||
|
||||
### System Integration
|
||||
- 😫 Single account quota runs out quickly, requiring frequent manual switching
|
||||
- 🔄 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
|
||||
|
||||
- **System Tray**: Background mode with tray icon and right-click menu
|
||||
- **IDE Sync**: Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **Batch Operations**: Batch refresh and delete multiple accounts
|
||||
**Antigravity Manager** is here to solve these problems! It's a professional Electron desktop app that helps you:
|
||||
|
||||
### Security Hardening
|
||||
- ✅ **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
|
||||
|
||||
- **Key Management**: Uses OS native credential manager (Windows Credential Manager / macOS Keychain) via `keytar` to securely store Master Key (AES-256)
|
||||
- **Data Encryption**: All sensitive data (`token_json`, `quota_json`) encrypted with `AES-256-GCM` before storage
|
||||
- **Auto Migration**: Automatic detection and migration of legacy plaintext data on startup
|
||||
---
|
||||
|
||||
### Other Features
|
||||
## 🎯 Features
|
||||
|
||||
- **Internationalization**: Multi-language support (English / 中文)
|
||||
- **Modern UI**: Built with React, TailwindCSS, and Shadcn UI for a clean and responsive experience
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ Cloud Account Pool</h3>
|
||||
<ul>
|
||||
<li>Add unlimited Google Gemini / Claude accounts via OAuth</li>
|
||||
<li>Display avatar, email, status, and last used time</li>
|
||||
<li>Real-time status monitoring (Active, Rate Limited, Expired)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 Real-time Quota Monitoring</h3>
|
||||
<ul>
|
||||
<li>Multi-model support: gemini-pro, claude-3-5-sonnet, etc.</li>
|
||||
<li>Visual progress bars with color indicators</li>
|
||||
<li>Auto & manual refresh capabilities</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 Intelligent Auto-Switching</h3>
|
||||
<ul>
|
||||
<li>Unlimited pool mode with smart backup selection</li>
|
||||
<li>Auto-switch when quota < 5% or rate-limited</li>
|
||||
<li>Background monitoring every 5 minutes</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 Security First</h3>
|
||||
<ul>
|
||||
<li>AES-256-GCM encryption for sensitive data</li>
|
||||
<li>OS native credential manager integration</li>
|
||||
<li>Auto migration of legacy plaintext data</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 Account Backup</h3>
|
||||
<ul>
|
||||
<li>Capture snapshots of account state</li>
|
||||
<li>Fast switching between saved accounts</li>
|
||||
<li>View, organize, and delete snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ Process Control</h3>
|
||||
<ul>
|
||||
<li>Auto-detect if Antigravity is running</li>
|
||||
<li>Launch via URI protocol or executable</li>
|
||||
<li>Graceful close or force kill</li>
|
||||
</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>
|
||||
|
||||
## Tech Stack
|
||||
### Additional Features
|
||||
|
||||
- **Core**: [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/)
|
||||
- **Build Tool**: [Vite](https://vitejs.dev/)
|
||||
- **Styling**: [TailwindCSS](https://tailwindcss.com/)
|
||||
- **State Management**: [TanStack Query](https://tanstack.com/query/latest)
|
||||
- **Routing**: [TanStack Router](https://tanstack.com/router/latest)
|
||||
- **Database**: [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3)
|
||||
- **Testing**: [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/)
|
||||
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
||||
- **🌏 Internationalization** - Multi-language support (English / 中文)
|
||||
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
||||
|
||||
## Getting Started
|
||||
---
|
||||
|
||||
### Prerequisites
|
||||
## 📸 Screenshots
|
||||
|
||||
- Node.js (v18 or higher recommended)
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## � Quick Start
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release for your platform from the [Releases](https://github.com/Draculabo/AntigravityManager/releases) page.
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
|
||||
### Build from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Node.js v18 or higher
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd AntigravityManager
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
To start the application in development mode:
|
||||
#### Steps
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development
|
||||
npm start
|
||||
```
|
||||
|
||||
This will launch the Electron app with hot-reloading enabled.
|
||||
|
||||
### Building
|
||||
|
||||
To create a distributable package for your platform:
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
npm run make
|
||||
```
|
||||
|
||||
The output will be available in the `out` directory.
|
||||
---
|
||||
|
||||
### Testing
|
||||
## �🛠️ Tech Stack
|
||||
|
||||
- Run unit tests: `npm run test:unit`
|
||||
- Run E2E tests: `npm run test:e2e`
|
||||
- Run all tests: `npm run test:all`
|
||||
| Category | Technologies |
|
||||
|----------|-------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build Tool** | [Vite](https://vitejs.dev/) |
|
||||
| **Styling** | [TailwindCSS](https://tailwindcss.com/), [Shadcn UI](https://ui.shadcn.com/) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
## � Development
|
||||
|
||||
### 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 |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron main process
|
||||
│ ├── preload.ts # Preload script
|
||||
│ ├── renderer/ # React renderer process
|
||||
│ ├── ipc/ # IPC communication handlers
|
||||
│ └── server/ # Built-in server
|
||||
├── docs/ # Documentation and assets
|
||||
└── .github/ # GitHub configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Q: The app won't start?</b></summary>
|
||||
|
||||
Please check:
|
||||
1. Make sure all dependencies are installed: `npm install`
|
||||
2. Check if Node.js version is >= 18
|
||||
3. Try deleting `node_modules` and reinstalling
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: Account login failed?</b></summary>
|
||||
|
||||
1. Ensure network connection is working
|
||||
2. Try clearing app data and logging in again
|
||||
3. Check if the account is restricted by Google/Claude
|
||||
|
||||
</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>
|
||||
|
||||
Please submit issues or suggestions via [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
## Disclaimer
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
> [!WARNING]
|
||||
> **For Educational Purposes Only**
|
||||
@@ -123,3 +341,9 @@ The output will be available in the `out` directory.
|
||||
> This project is intended solely for educational and research purposes. It is provided "as-is" without any warranty. **Commercial use is strictly prohibited.**
|
||||
>
|
||||
> By using this software, you agree that you will not use it for any commercial purposes, and you are solely responsible for ensuring your use complies with all applicable laws and regulations. The authors and contributors are not responsible for any misuse or damages arising from the use of this software.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
If this project helps you, please give it a ⭐ Star!
|
||||
</p>
|
||||
@@ -0,0 +1,349 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 专业的 Google Gemini & Claude AI 多账号管理器</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 目录
|
||||
|
||||
- [为什么选择 Antigravity Manager?](#-为什么选择-antigravity-manager)
|
||||
- [功能特性](#-功能特性)
|
||||
- [截图预览](#-截图预览)
|
||||
- [快速开始](#-快速开始)
|
||||
- [技术栈](#️-技术栈)
|
||||
- [开发指南](#-开发指南)
|
||||
- [常见问题](#-常见问题)
|
||||
- [贡献指南](#-贡献指南)
|
||||
- [许可证](#-许可证)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 为什么选择 Antigravity Manager?
|
||||
|
||||
在使用 Antigravity IDE 时,你是否遇到过这些问题?
|
||||
|
||||
- 😫 单个账号额度很快用完,需要频繁手动切换
|
||||
- 🔄 管理多个 Google/Claude 账号非常麻烦
|
||||
- 📊 不知道当前账号还剩多少额度
|
||||
- ⏰ 担心错过额度重置时间
|
||||
- 🔌 需要可靠的本地 API 代理用于开发工具
|
||||
|
||||
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
|
||||
|
||||
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
|
||||
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
|
||||
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
|
||||
- ✅ **本地 API 代理** - 内置兼容 OpenAI/Anthropic 协议的代理服务器
|
||||
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ 云账号池管理</h3>
|
||||
<ul>
|
||||
<li>通过 OAuth 添加无限 Google Gemini / Claude 账号</li>
|
||||
<li>显示头像、邮箱、状态和最后使用时间</li>
|
||||
<li>实时状态监控(活跃、限速、过期)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 实时额度监控</h3>
|
||||
<ul>
|
||||
<li>多模型支持:gemini-pro、claude-3-5-sonnet 等</li>
|
||||
<li>可视化进度条配色指示器</li>
|
||||
<li>自动刷新与手动刷新</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 智能自动切换</h3>
|
||||
<ul>
|
||||
<li>无限池模式,智能选择备用账号</li>
|
||||
<li>额度低于 5% 或被限速时自动切换</li>
|
||||
<li>后台每 5 分钟自动监控</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 安全优先</h3>
|
||||
<ul>
|
||||
<li>AES-256-GCM 加密敏感数据</li>
|
||||
<li>集成操作系统原生凭证管理器</li>
|
||||
<li>自动迁移旧版明文数据</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 账号备份</h3>
|
||||
<ul>
|
||||
<li>捕获账号状态快照</li>
|
||||
<li>在已保存的账号间快速切换</li>
|
||||
<li>查看、整理和删除快照</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ 进程控制</h3>
|
||||
<ul>
|
||||
<li>自动检测 Antigravity 是否运行</li>
|
||||
<li>通过 URI 协议或可执行文件启动</li>
|
||||
<li>优雅关闭或强制终止</li>
|
||||
</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>
|
||||
|
||||
### 更多功能
|
||||
|
||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||
|
||||
---
|
||||
|
||||
## 📸 截图预览
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 下载安装
|
||||
|
||||
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
||||
|
||||
| 平台 | 下载链接 |
|
||||
| ------- | ------------------------------------------------------------------------------ |
|
||||
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
|
||||
### 从源码构建
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- Node.js v18 或更高版本
|
||||
- npm 或 yarn
|
||||
|
||||
#### 步骤
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式
|
||||
npm start
|
||||
|
||||
# 构建生产版本
|
||||
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/) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `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` | 构建生产包 |
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron 主进程
|
||||
│ ├── preload.ts # 预加载脚本
|
||||
│ ├── renderer/ # React 渲染进程
|
||||
│ ├── ipc/ # IPC 通信处理
|
||||
│ └── server/ # 内置服务器
|
||||
├── docs/ # 文档和资源
|
||||
└── .github/ # GitHub 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
||||
|
||||
请检查:
|
||||
|
||||
1. 确保已安装所有依赖:`npm install`
|
||||
2. 检查 Node.js 版本是否 >= 18
|
||||
3. 尝试删除 `node_modules` 后重新安装
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 账号登录失败?</b></summary>
|
||||
|
||||
1. 确保网络连接正常
|
||||
2. 尝试清除应用数据后重新登录
|
||||
3. 检查账号是否被 Google/Claude 限制
|
||||
|
||||
</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>
|
||||
|
||||
请通过 [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues) 提交问题或建议。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎贡献代码!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解详情。
|
||||
|
||||
我们遵循 [Contributor Covenant](CODE_OF_CONDUCT.md) 行为准则。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> [!WARNING]
|
||||
> **仅供学习研究使用**
|
||||
>
|
||||
> 本项目仅供教育和研究目的,按"原样"提供,不提供任何保证。**严禁商业使用。**
|
||||
>
|
||||
> 使用本软件即表示您同意不会将其用于任何商业目的,并自行负责确保您的使用符合所有适用法律法规。作者和贡献者对因使用本软件而产生的任何滥用或损害不承担责任。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
如果这个项目对你有帮助,请给一个 ⭐ Star 支持!
|
||||
</p>
|
||||
@@ -0,0 +1,93 @@
|
||||
# Security Policy
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
Antigravity Manager takes security seriously. Here are the security measures we've implemented:
|
||||
|
||||
### Encryption
|
||||
|
||||
- **AES-256-GCM** encryption for all sensitive data (`token_json`, `quota_json`)
|
||||
- Unique initialization vectors (IV) for each encryption operation
|
||||
- Authenticated encryption to prevent tampering
|
||||
|
||||
### Credential Storage
|
||||
|
||||
- **Windows**: Windows Credential Manager
|
||||
- **macOS**: macOS Keychain
|
||||
- **Linux**: Secret Service API / libsecret
|
||||
|
||||
Master keys are stored in the OS native credential manager via `keytar`, never in plain text files.
|
||||
|
||||
### Data Protection
|
||||
|
||||
- Sensitive tokens are encrypted before storage in SQLite database
|
||||
- Automatic migration of legacy plaintext data on application startup
|
||||
- No sensitive data is logged or transmitted to external services
|
||||
|
||||
## 📋 Supported Versions
|
||||
|
||||
| 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.
|
||||
|
||||
## 🚨 Reporting a Vulnerability
|
||||
|
||||
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Antigravity Manager.
|
||||
|
||||
### How to Report
|
||||
|
||||
**Please DO NOT report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via one of the following methods:
|
||||
|
||||
1. **GitHub Security Advisories**: Use our [private vulnerability reporting](https://github.com/Draculabo/AntigravityManager/security/advisories/new)
|
||||
2. **Email**: Send details to the project maintainer (see GitHub profile for contact)
|
||||
|
||||
### What to Include
|
||||
|
||||
Please include the following information in your report:
|
||||
|
||||
- Type of vulnerability (e.g., buffer overflow, SQL injection, XSS)
|
||||
- Full paths of source file(s) related to the vulnerability
|
||||
- Location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue and how an attacker might exploit it
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial Response**: Within 48 hours
|
||||
- **Status Update**: Within 5 business days
|
||||
- **Resolution Timeline**: Depends on complexity, typically within 30 days
|
||||
|
||||
### What to Expect
|
||||
|
||||
1. **Acknowledgment**: We'll confirm receipt of your report
|
||||
2. **Investigation**: We'll investigate and validate the vulnerability
|
||||
3. **Fix Development**: We'll develop and test a fix
|
||||
4. **Disclosure**: We'll coordinate disclosure timing with you
|
||||
5. **Credit**: With your permission, we'll credit you in the release notes
|
||||
|
||||
## 🛡️ Security Best Practices for Users
|
||||
|
||||
To ensure the security of your data:
|
||||
|
||||
1. **Keep Updated**: Always use the latest version of Antigravity Manager
|
||||
2. **System Security**: Keep your operating system updated
|
||||
3. **Secure Environment**: Use the application on trusted devices only
|
||||
4. **Backup**: Regularly backup your account data using the built-in backup feature
|
||||
5. **Review Permissions**: Only grant necessary OAuth permissions
|
||||
|
||||
## 📜 Disclosure Policy
|
||||
|
||||
When we receive a security bug report, we will:
|
||||
|
||||
1. Confirm the problem and determine affected versions
|
||||
2. Audit code to find any similar problems
|
||||
3. Prepare fixes for all supported versions
|
||||
4. Release new versions as soon as possible
|
||||
|
||||
Thank you for helping keep Antigravity Manager and its users safe!
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -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` 算法加密。
|
||||
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
||||
@@ -1,28 +1,38 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import eslintPluginPrettierRecommended from "eslint-config-prettier";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import path from "node:path";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from 'globals';
|
||||
import pluginJs from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import eslintPluginPrettierRecommended from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import path from 'node:path';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
|
||||
const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default defineConfig([
|
||||
includeIgnoreFile(prettierIgnorePath),
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
|
||||
pluginJs.configs.recommended,
|
||||
pluginReact.configs.flat["jsx-runtime"],
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'no-empty-pattern': 'off',
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,16 +1,114 @@
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import crypto from 'crypto';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type {
|
||||
HookFunction,
|
||||
HookFunctionErrorCallback,
|
||||
TargetArch,
|
||||
TargetPlatform,
|
||||
} from '@electron/packager';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
|
||||
import setLanguages from 'electron-packager-languages';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
|
||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||
const ResolvedMakerAppImage = MakerAppImage;
|
||||
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
||||
|
||||
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||
|
||||
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm)$/;
|
||||
const platformNamesMap: Record<string, string> = {
|
||||
darwin: 'macos',
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
};
|
||||
const ymlMapsMap: Record<string, string> = {
|
||||
darwin: 'latest-mac.yml',
|
||||
linux: 'latest-linux.yml',
|
||||
win32: 'latest.yml',
|
||||
};
|
||||
const ignorePatterns = [
|
||||
/^\/\.git/,
|
||||
/^\/\.github/,
|
||||
/^\/\.vscode/,
|
||||
/^\/\.idea/,
|
||||
/^\/openspec/,
|
||||
/^\/docs?/,
|
||||
/^\/scripts?/,
|
||||
/^\/tests?/,
|
||||
/^\/mocks?/,
|
||||
/^\/src/,
|
||||
/^\/node_modules\/\.cache/,
|
||||
];
|
||||
const setLanguagesHook = setLanguages([...keepLanguages.values()]);
|
||||
const packagerAfterCopy: HookFunction[] = [
|
||||
(
|
||||
buildPath: string,
|
||||
electronVersion: string,
|
||||
platform: TargetPlatform,
|
||||
arch: TargetArch,
|
||||
callback: HookFunctionErrorCallback,
|
||||
) => {
|
||||
if (platform !== 'win32') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
setLanguagesHook(buildPath, electronVersion, platform, arch, callback);
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeArtifactName(value?: string) {
|
||||
if (!value) {
|
||||
return 'app';
|
||||
}
|
||||
|
||||
return value.trim().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
function isSquirrelArtifact(artifactPath: string) {
|
||||
const fileName = path.basename(artifactPath);
|
||||
if (fileName === 'RELEASES') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return artifactPath.endsWith('.nupkg');
|
||||
}
|
||||
|
||||
const appImageMaker = new ResolvedMakerAppImage({
|
||||
config: {
|
||||
icons: [
|
||||
{
|
||||
file: 'images/32x32.png',
|
||||
size: 32,
|
||||
},
|
||||
{
|
||||
file: 'images/64x64.png',
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
file: 'images/128x128.png',
|
||||
size: 128,
|
||||
},
|
||||
{
|
||||
file: 'images/[email protected]',
|
||||
size: 256,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
@@ -20,6 +118,10 @@ 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
|
||||
afterCopy: packagerAfterCopy,
|
||||
ignore: ignorePatterns,
|
||||
prune: true,
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
@@ -45,9 +147,150 @@ const config: ForgeConfig = {
|
||||
for (const moduleName of nativeModules) {
|
||||
copyModuleRecursive(moduleName);
|
||||
}
|
||||
|
||||
// Copy assets to resources folder
|
||||
const assetsSrc = path.join(process.cwd(), 'src', 'assets');
|
||||
const assetsDest = path.join(buildPath, 'resources', 'assets');
|
||||
|
||||
if (fs.existsSync(assetsSrc)) {
|
||||
if (!fs.existsSync(assetsDest)) {
|
||||
fs.mkdirSync(assetsDest, { recursive: true });
|
||||
}
|
||||
fs.cpSync(assetsSrc, assetsDest, { recursive: true });
|
||||
console.log(`Copied assets from ${assetsSrc} to ${assetsDest}`);
|
||||
} else {
|
||||
console.warn(`Assets directory not found: ${assetsSrc}`);
|
||||
}
|
||||
},
|
||||
postMake: async (_config, makeResults) => {
|
||||
if (!makeResults?.length) {
|
||||
return makeResults;
|
||||
}
|
||||
|
||||
const ymlByPlatform = new Map<
|
||||
string,
|
||||
{
|
||||
basePath: string;
|
||||
yml: {
|
||||
version?: string;
|
||||
files: {
|
||||
url: string;
|
||||
sha512: string;
|
||||
size: number;
|
||||
}[];
|
||||
releaseDate?: string;
|
||||
};
|
||||
}
|
||||
>();
|
||||
|
||||
makeResults = makeResults.map((result) => {
|
||||
const productName = normalizeArtifactName(result.packageJSON.productName);
|
||||
const platformName = platformNamesMap[result.platform] || result.platform;
|
||||
const version = result.packageJSON.version;
|
||||
const platformKey = result.platform;
|
||||
|
||||
if (!ymlByPlatform.has(platformKey)) {
|
||||
ymlByPlatform.set(platformKey, {
|
||||
basePath: '',
|
||||
yml: {
|
||||
version,
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const platformState = ymlByPlatform.get(platformKey)!;
|
||||
|
||||
result.artifacts = result.artifacts
|
||||
.map((artifact) => {
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSquirrelArtifact(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
|
||||
if (!artifactRegex.test(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
|
||||
if (!platformState.basePath) {
|
||||
platformState.basePath = path.dirname(artifact);
|
||||
}
|
||||
|
||||
const newArtifact = `${path.dirname(artifact)}/${productName}-${version}-${platformName}-${result.arch}${path.extname(artifact)}`;
|
||||
if (newArtifact !== artifact) {
|
||||
fs.renameSync(artifact, newArtifact);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = fs.readFileSync(newArtifact);
|
||||
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
|
||||
const { size } = fs.statSync(newArtifact);
|
||||
|
||||
platformState.yml.files.push({
|
||||
url: path.basename(newArtifact),
|
||||
sha512: hash,
|
||||
size,
|
||||
});
|
||||
} catch {
|
||||
console.error(`Failed to hash ${newArtifact}`);
|
||||
}
|
||||
|
||||
return newArtifact;
|
||||
})
|
||||
.filter((artifact) => artifact !== null);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const releaseDate = new Date().toISOString();
|
||||
for (const [platform, platformState] of ymlByPlatform.entries()) {
|
||||
const ymlFileName = ymlMapsMap[platform];
|
||||
if (!ymlFileName || !platformState.basePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
platformState.yml.releaseDate = releaseDate;
|
||||
const ymlPath = path.join(platformState.basePath, ymlFileName);
|
||||
fs.writeFileSync(ymlPath, yamlStringify(platformState.yml));
|
||||
|
||||
const sampleResult = makeResults.find((result) => result.platform === platform);
|
||||
if (!sampleResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
makeResults.push({
|
||||
artifacts: [ymlPath],
|
||||
platform: sampleResult.platform,
|
||||
arch: sampleResult.arch,
|
||||
packageJSON: sampleResult.packageJSON,
|
||||
});
|
||||
}
|
||||
|
||||
return makeResults;
|
||||
},
|
||||
},
|
||||
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl:
|
||||
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
|
||||
}),
|
||||
new MakerDMG(
|
||||
{
|
||||
overwrite: true,
|
||||
icon: 'images/icon.icns',
|
||||
iconSize: 160,
|
||||
},
|
||||
['darwin'],
|
||||
),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
appImageMaker,
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
/*
|
||||
@@ -66,7 +309,6 @@ const config: ForgeConfig = {
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
new AutoUnpackNativesPlugin({}),
|
||||
new VitePlugin({
|
||||
build: [
|
||||
{
|
||||
@@ -87,16 +329,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,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 659 B |
@@ -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`: 认证状态和用户信息。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -18,14 +18,14 @@
|
||||
"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",
|
||||
"@electron-forge/maker-dmg": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
@@ -34,12 +34,17 @@
|
||||
"@electron-forge/plugin-vite": "^7.10.2",
|
||||
"@electron-forge/publisher-github": "^7.10.2",
|
||||
"@electron-forge/shared-types": "^7.8.1",
|
||||
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
|
||||
"@electron/fuses": "~1.8.0",
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"node-addon-api": "^8.3.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -47,33 +52,49 @@
|
||||
"@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",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^16.5.0",
|
||||
"icon-gen": "^5.0.0",
|
||||
"jsdom": "^27.1.0",
|
||||
"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",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nestjs/common": "^11.1.10",
|
||||
"@nestjs/core": "^11.1.10",
|
||||
"@nestjs/microservices": "^11.1.11",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@orpc/client": "^1.10.4",
|
||||
"@orpc/server": "^1.11.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -90,35 +111,41 @@
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.7.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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",
|
||||
"react-i18next": "^16.2.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"ts-node": {
|
||||
"transpileOnly": true,
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Semantic Release Configuration
|
||||
*
|
||||
* This configuration uses the specific release rules requested by the user,
|
||||
* but adapts the plugin configuration to work with standard Conventional Commits
|
||||
* instead of Gitmoji (since the project history doesn't use emojis).
|
||||
*/
|
||||
|
||||
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
|
||||
const GITHUB_BASE_URL = 'https://github.com';
|
||||
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
||||
|
||||
const releaseRules = [
|
||||
{
|
||||
release: 'minor',
|
||||
type: 'feat',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'fix',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'perf',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'style',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'refactor',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'build',
|
||||
},
|
||||
{ release: 'patch', scope: 'README', type: 'docs' },
|
||||
{ release: 'patch', scope: 'README.md', type: 'docs' },
|
||||
{ release: false, type: 'docs' },
|
||||
{
|
||||
release: false,
|
||||
type: 'test',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'ci',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'chore',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'wip',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
type: 'BREAKING CHANGE',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
scope: 'BREAKING CHANGE',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
subject: '*BREAKING CHANGE*',
|
||||
},
|
||||
{ release: 'patch', subject: '*force release*' },
|
||||
{ release: 'patch', subject: '*force patch*' },
|
||||
{ release: 'minor', subject: '*force minor*' },
|
||||
{ release: 'major', subject: '*force major*' },
|
||||
{ release: false, subject: '*skip release*' },
|
||||
];
|
||||
|
||||
const getGithubUsernameFromEmail = (email) => {
|
||||
if (!email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = email.match(/^(?:\d+\+)?(?<username>[a-z0-9-]+)@users\.noreply\.github\.com$/i);
|
||||
|
||||
return match?.groups?.username;
|
||||
};
|
||||
|
||||
const isBotIdentity = (value) => {
|
||||
return typeof value === 'string' && /bot/i.test(value);
|
||||
};
|
||||
|
||||
const buildReleaseFooter = (commits) => {
|
||||
const mergedPullRequests = new Map();
|
||||
const contributors = new Map();
|
||||
|
||||
(commits || []).forEach((commit) => {
|
||||
const message = [commit?.message, commit?.subject, commit?.header].filter(Boolean).join('\n');
|
||||
if (message) {
|
||||
const mergeMatches = message.match(/pull request #(?<number>\d+)/gi);
|
||||
if (mergeMatches) {
|
||||
mergeMatches.forEach((match) => {
|
||||
const number = match.replace(/\D/g, '');
|
||||
if (number) {
|
||||
mergedPullRequests.set(number, {
|
||||
number,
|
||||
url: `${REPO_URL}/pull/${number}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const squashMatches = message.match(/\(#(?<number>\d+)\)/g);
|
||||
if (squashMatches) {
|
||||
squashMatches.forEach((match) => {
|
||||
const number = match.replace(/\D/g, '');
|
||||
if (number) {
|
||||
mergedPullRequests.set(number, {
|
||||
number,
|
||||
url: `${REPO_URL}/pull/${number}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const author = commit?.author || commit?.committer;
|
||||
const username = getGithubUsernameFromEmail(author?.email);
|
||||
const displayName = username ? `@${username}` : author?.name;
|
||||
|
||||
if (!displayName || isBotIdentity(displayName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (username && isBotIdentity(username)) {
|
||||
return;
|
||||
}
|
||||
|
||||
contributors.set(username || displayName, {
|
||||
name: displayName,
|
||||
url: username ? `${GITHUB_BASE_URL}/${username}` : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const footerLines = [];
|
||||
const sortedPullRequests = Array.from(mergedPullRequests.values()).sort(
|
||||
(left, right) => Number(left.number) - Number(right.number),
|
||||
);
|
||||
if (sortedPullRequests.length > 0) {
|
||||
footerLines.push('### 🔀 Merged Pull Requests');
|
||||
sortedPullRequests.forEach((pullRequest) => {
|
||||
footerLines.push(`- [#${pullRequest.number}](${pullRequest.url})`);
|
||||
});
|
||||
footerLines.push('');
|
||||
}
|
||||
|
||||
const sortedContributors = Array.from(contributors.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
if (sortedContributors.length > 0) {
|
||||
footerLines.push('### 🙌 Contributors');
|
||||
sortedContributors.forEach((contributor) => {
|
||||
if (contributor.url) {
|
||||
footerLines.push(`- [${contributor.name}](${contributor.url})`);
|
||||
return;
|
||||
}
|
||||
|
||||
footerLines.push(`- ${contributor.name}`);
|
||||
});
|
||||
footerLines.push('');
|
||||
}
|
||||
|
||||
footerLines.push(`Full Changelog: ${FULL_CHANGELOG_URL}`);
|
||||
|
||||
return footerLines.join('\n');
|
||||
};
|
||||
|
||||
const appendReleaseNotes = (notes, commits) => {
|
||||
const trimmedNotes = (notes || '').trimEnd();
|
||||
const footer = buildReleaseFooter(commits);
|
||||
|
||||
if (!footer) {
|
||||
return trimmedNotes;
|
||||
}
|
||||
|
||||
return `${trimmedNotes}\n\n${footer}`;
|
||||
};
|
||||
|
||||
const appendReleaseNotesPlugin = {
|
||||
publish: async (pluginConfig, context) => {
|
||||
if (!context?.nextRelease?.notes) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.nextRelease.notes = appendReleaseNotes(context.nextRelease.notes, context.commits);
|
||||
},
|
||||
};
|
||||
|
||||
const releaseConfig = {
|
||||
branches: ['main', { name: 'beta', prerelease: true }],
|
||||
plugins: [
|
||||
[
|
||||
'@semantic-release/commit-analyzer',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
releaseRules: releaseRules,
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/release-notes-generator',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
presetConfig: {
|
||||
types: [
|
||||
{ type: 'feat', section: '✨ Features' },
|
||||
{ type: 'fix', section: '🐛 Bug Fixes' },
|
||||
{ type: 'perf', section: '⚡ Performance Improvements' },
|
||||
{ type: 'revert', section: '⏪ Reverts' },
|
||||
{ type: 'docs', section: '📝 Documentation' },
|
||||
{ type: 'style', section: '💄 Styles' },
|
||||
{ type: 'refactor', section: '♻️ Code Refactoring' },
|
||||
{ type: 'test', section: '✅ Tests' },
|
||||
{ type: 'build', section: '👷 Build System' },
|
||||
{ type: 'ci', section: '🔧 Continuous Integration' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/changelog',
|
||||
{
|
||||
changelogFile: 'CHANGELOG.md',
|
||||
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
|
||||
},
|
||||
],
|
||||
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
|
||||
appendReleaseNotesPlugin,
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
successComment: false,
|
||||
failComment: false,
|
||||
labels: false,
|
||||
releaseName: 'v${nextRelease.version}',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
releaseConfig.__internal = {
|
||||
appendReleaseNotes,
|
||||
buildReleaseFooter,
|
||||
};
|
||||
|
||||
module.exports = releaseConfig;
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Rocket } from 'lucide-react';
|
||||
import sharp from 'sharp';
|
||||
import icongen from 'icon-gen';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const IMAGES_DIR = path.join(process.cwd(), 'images');
|
||||
|
||||
// Colors
|
||||
const BACKGROUND_COLOR = '#0f172a'; // slate-900 / primary
|
||||
const ICON_COLOR = '#f8fafc'; // slate-50 / primary-foreground
|
||||
const ICON_SIZE = 1024;
|
||||
const PADDING = 0.25; // 25% padding
|
||||
|
||||
async function generate() {
|
||||
console.log('Generating assets...');
|
||||
|
||||
// 1. Generate SVG String
|
||||
// We want the icon to be centered and sized appropriately.
|
||||
// The canvas is 1024x1024.
|
||||
// We will create an SVG with the background rect and the icon centered.
|
||||
|
||||
// Lucide icons are 24x24 by default.
|
||||
// We will scale it up.
|
||||
const iconSize = ICON_SIZE * (1 - PADDING * 2);
|
||||
const padding = ICON_SIZE * PADDING;
|
||||
|
||||
const iconSvg = renderToStaticMarkup(
|
||||
React.createElement(Rocket, {
|
||||
size: iconSize,
|
||||
color: ICON_COLOR,
|
||||
strokeWidth: 1.5, // Thicker stroke for larger icon? Or keep 2? 1.5 might look elegant.
|
||||
}),
|
||||
);
|
||||
|
||||
// Construct the full SVG
|
||||
// We place the icon at (padding, padding)
|
||||
|
||||
const fullSvg = `
|
||||
<svg width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 ${ICON_SIZE} ${ICON_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${ICON_SIZE}" height="${ICON_SIZE}" fill="${BACKGROUND_COLOR}" />
|
||||
<g transform="translate(${padding}, ${padding})">
|
||||
${iconSvg}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// 2. Generate Base PNG
|
||||
const pngBuffer = await sharp(Buffer.from(fullSvg)).png().toBuffer();
|
||||
|
||||
const iconPngPath = path.join(IMAGES_DIR, 'icon.png');
|
||||
await sharp(pngBuffer).toFile(iconPngPath);
|
||||
console.log(`Generated ${iconPngPath}`);
|
||||
|
||||
// 3. Generate ICO and ICNS using icon-gen
|
||||
// icon-gen takes the png directory or file and outputs to destination
|
||||
|
||||
// Create a temporary directory for input if needed, but icon-gen works with a file path usually?
|
||||
// Checking icon-gen docs or usage. commonly: icongen(input, destination, options)
|
||||
// Input can be a path to a png image.
|
||||
|
||||
// Clean up old directories and files
|
||||
const itemsToClean = [
|
||||
'android',
|
||||
'ios',
|
||||
'icon.iconset',
|
||||
'icon_master_squircle.png',
|
||||
'storealogo.png',
|
||||
'tray-icon.png', // incorrectly named
|
||||
// Clean up "Square" logos as they appear unused
|
||||
'Square30x30Logo.png',
|
||||
'Square44x44Logo.png',
|
||||
'Square71x71Logo.png',
|
||||
'Square89x89Logo.png',
|
||||
'Square107x107Logo.png',
|
||||
'Square142x142Logo.png',
|
||||
'Square150x150Logo.png',
|
||||
'Square284x284Logo.png',
|
||||
'Square310x310Logo.png',
|
||||
'StoreLogo.png',
|
||||
]; // checking casing or existence
|
||||
for (const item of itemsToClean) {
|
||||
const itemPath = path.join(IMAGES_DIR, item);
|
||||
if (fs.existsSync(itemPath)) {
|
||||
fs.rmSync(itemPath, { recursive: true, force: true });
|
||||
console.log(`Removed old item: ${itemPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
report: true,
|
||||
ico: {
|
||||
name: 'icon',
|
||||
sizes: [16, 24, 32, 48, 64, 128, 256],
|
||||
},
|
||||
icns: {
|
||||
name: 'icon',
|
||||
sizes: [16, 32, 64, 128, 256, 512, 1024],
|
||||
},
|
||||
favicon: {
|
||||
name: 'favicon-',
|
||||
pngSizes: [32, 57, 72, 96, 120, 128, 144, 152, 195, 228],
|
||||
icoSizes: [16, 24, 32, 48, 64],
|
||||
},
|
||||
};
|
||||
|
||||
await icongen(iconPngPath, IMAGES_DIR, options);
|
||||
console.log('Generated ICO, ICNS, and Favicon files');
|
||||
|
||||
// Generate standard PNGs only
|
||||
const standardSizes = [
|
||||
{ name: '32x32.png', size: 32 },
|
||||
{ name: '64x64.png', size: 64 },
|
||||
{ name: '128x128.png', size: 128 },
|
||||
{ name: '[email protected]', size: 256 },
|
||||
];
|
||||
|
||||
for (const { name, size } of standardSizes) {
|
||||
await sharp(pngBuffer).resize(size, size).toFile(path.join(IMAGES_DIR, name));
|
||||
console.log(`Generated ${name}`);
|
||||
}
|
||||
|
||||
// Generate tray.png (Matched to code requirement)
|
||||
const TRAY_SIZE = 24;
|
||||
// Code uses 'tray.png'
|
||||
const trayIconSvg = renderToStaticMarkup(
|
||||
React.createElement(Rocket, {
|
||||
size: TRAY_SIZE,
|
||||
color: ICON_COLOR, // White
|
||||
strokeWidth: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const trayPngPath = path.join(IMAGES_DIR, 'tray.png');
|
||||
await sharp(Buffer.from(trayIconSvg)).png().toFile(trayPngPath);
|
||||
console.log('Generated tray.png');
|
||||
|
||||
// Sync to src/assets
|
||||
const SRC_ASSETS_DIR = path.join(process.cwd(), 'src', 'assets');
|
||||
if (fs.existsSync(SRC_ASSETS_DIR)) {
|
||||
fs.copyFileSync(iconPngPath, path.join(SRC_ASSETS_DIR, 'icon.png'));
|
||||
fs.copyFileSync(trayPngPath, path.join(SRC_ASSETS_DIR, 'tray.png'));
|
||||
console.log('Synced icon.png and tray.png to src/assets/');
|
||||
}
|
||||
}
|
||||
|
||||
generate().catch(console.error);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function listAccounts() {
|
||||
return ipc.client.account.listAccounts();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function getPlatform() {
|
||||
return ipc.client.app.currentPlatfom();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function openLogDirectory() {
|
||||
return ipc.client.system.openLogDirectory();
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import { ipc } from "../ipc/manager";
|
||||
import { ipc } from '../ipc/manager';
|
||||
|
||||
export async function minimizeWindow() {
|
||||
await ipc.client.window.minimizeWindow();
|
||||
|
||||
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 659 B |
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { enUS, zhCN, ru } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,7 +33,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
isSwitching,
|
||||
isDeleting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [transform, setTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||
@@ -136,6 +137,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
{t('account.lastUsed', {
|
||||
time: formatDistanceToNow(new Date(account.last_used), {
|
||||
addSuffix: true,
|
||||
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -50,11 +50,64 @@ export function CloudAccountCard({
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
if (percentage > 80) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (dateStr: string) => {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (isBefore(targetDate, now)) {
|
||||
return '0h 0m';
|
||||
}
|
||||
|
||||
// Calculate difference in hours and minutes
|
||||
const diffHrs = differenceInHours(targetDate, now);
|
||||
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
};
|
||||
|
||||
const getResetTimeLabel = (resetTime?: string) => {
|
||||
if (!resetTime) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||
};
|
||||
|
||||
const getResetTimeTitle = (resetTime?: string) => {
|
||||
if (!resetTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetDate = new Date(resetTime);
|
||||
if (Number.isNaN(resetDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const modelQuotas = Object.entries(account.quota?.models || {});
|
||||
|
||||
return (
|
||||
@@ -169,15 +222,30 @@ export function CloudAccountCard({
|
||||
<div className="space-y-2">
|
||||
{modelQuotas.length > 0 ? (
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div key={modelName} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
|
||||
<div
|
||||
key={modelName}
|
||||
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
|
||||
{info.percentage}%
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] leading-none"
|
||||
title={getResetTimeTitle(info.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('cloud.card.left')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -41,12 +41,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
|
||||
// ... (existing code: imports and comments)
|
||||
|
||||
export function CloudAccountList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts, isLoading, isError } = useCloudAccounts();
|
||||
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
|
||||
const refreshMutation = useRefreshQuota();
|
||||
const deleteMutation = useDeleteCloudAccount();
|
||||
const addMutation = useAddGoogleAccount();
|
||||
@@ -62,19 +63,49 @@ export function CloudAccountList() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
setAuthCode(code);
|
||||
// Auto submit if dialog is open
|
||||
if (isAddDialogOpen) {
|
||||
handleAddAccount(code);
|
||||
}
|
||||
// Note: Auto-submit will be triggered by the authCode change effect below
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isAddDialogOpen]);
|
||||
}, []);
|
||||
|
||||
// Auto-submit when authCode is set and dialog is open
|
||||
useEffect(() => {
|
||||
if (authCode && isAddDialogOpen && !addMutation.isPending) {
|
||||
console.log('[OAuth] Auto-submitting auth code');
|
||||
handleAddAccount(authCode);
|
||||
}
|
||||
}, [authCode, isAddDialogOpen]);
|
||||
|
||||
// Batch Operations State
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -103,7 +134,7 @@ export function CloudAccountList() {
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: t('cloud.toast.switchFailed'),
|
||||
description: err.message,
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
}),
|
||||
},
|
||||
@@ -145,8 +176,15 @@ export function CloudAccountList() {
|
||||
};
|
||||
|
||||
const handleForcePoll = () => {
|
||||
if (forcePollMutation.isPending) return;
|
||||
forcePollMutation.mutate(undefined, {
|
||||
onSuccess: () => toast({ title: t('cloud.polling') }),
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: t('cloud.toast.pollFailed'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,35 +207,13 @@ export function CloudAccountList() {
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.syncFailed.title'),
|
||||
description: err.message,
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) return;
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openAuthUrl = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
@@ -264,7 +280,13 @@ export function CloudAccountList() {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
|
||||
const errorMessage = getLocalizedErrorMessage(error, t);
|
||||
return (
|
||||
<div className="p-4 text-red-500">
|
||||
<div>{t('cloud.error.loadFailed')}</div>
|
||||
<div className="mt-1 text-sm text-red-400">{errorMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -372,7 +394,7 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isProcessRunning,
|
||||
startAntigravity,
|
||||
closeAntigravity,
|
||||
} from "@/actions/process";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play, Square, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export const StatusBar: React.FC = () => {
|
||||
interface StatusBarProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ["process", "status"],
|
||||
queryKey: ['process', 'status'],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
@@ -23,14 +24,14 @@ export const StatusBar: React.FC = () => {
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: closeAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,30 +45,78 @@ export const StatusBar: React.FC = () => {
|
||||
|
||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||
|
||||
// Collapsed View
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex justify-center py-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-lg transition-all duration-300',
|
||||
isRunning
|
||||
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')}
|
||||
/>
|
||||
<p>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors",
|
||||
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
|
||||
isRunning
|
||||
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100"
|
||||
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100",
|
||||
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50'
|
||||
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" />
|
||||
)}
|
||||
<span>
|
||||
{isLoading
|
||||
? t("status.checking")
|
||||
: isRunning
|
||||
? t("status.running")
|
||||
: t("status.stopped")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 leading-none">
|
||||
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
||||
<span className="text-sm leading-tight font-medium">
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -75,18 +124,22 @@ export const StatusBar: React.FC = () => {
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
"h-8 px-3 hover:bg-white/20",
|
||||
isRunning ? "hover:text-green-900" : "hover:text-red-900",
|
||||
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
|
||||
isRunning
|
||||
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50'
|
||||
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<Square className="mr-2 h-4 w-4 fill-current" />
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4 fill-current" />
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
{isRunning ? t("action.stop") : t("action.start")}
|
||||
<span className="ml-2 font-semibold">
|
||||
{isRunning ? t('action.stop') : t('action.start')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AppConfig } from '@/types/config';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
export function useAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -20,28 +25,105 @@ export function useAppConfig() {
|
||||
const updateConfig = useMutation({
|
||||
mutationFn: async (newConfig: AppConfig) => {
|
||||
await ipc.client.config.save(newConfig);
|
||||
return newConfig;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
});
|
||||
|
||||
const latestConfigRef = useRef<AppConfig | null>(null);
|
||||
const lastStableConfigRef = useRef<AppConfig | null>(null);
|
||||
const pendingResolversRef = useRef<
|
||||
Array<{ resolve: () => void; reject: (error: Error) => void }>
|
||||
>([]);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
lastStableConfigRef.current = config;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
const pendingBatch = pendingResolversRef.current.splice(0);
|
||||
const nextConfig = latestConfigRef.current;
|
||||
if (!nextConfig) {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedConfig = await updateConfig.mutateAsync(nextConfig);
|
||||
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||
lastStableConfigRef.current = savedConfig;
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your configuration has been updated.',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to save settings');
|
||||
const fallback = lastStableConfigRef.current;
|
||||
if (fallback) {
|
||||
queryClient.setQueryData(['appConfig'], fallback);
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
}
|
||||
toast({
|
||||
title: 'Error saving settings',
|
||||
description: err.message,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
for (const item of pendingBatch) {
|
||||
item.reject(error);
|
||||
}
|
||||
}
|
||||
}, [queryClient, updateConfig]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
(newConfig: AppConfig) => {
|
||||
latestConfigRef.current = newConfig;
|
||||
queryClient.setQueryData(['appConfig'], newConfig);
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingResolversRef.current.push({ resolve, reject });
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
flushPendingSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
},
|
||||
});
|
||||
[flushPendingSave, queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (!debounceTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
void flushPendingSave();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
handleBeforeUnload();
|
||||
};
|
||||
}, [flushPendingSave]);
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
saveConfig: updateConfig.mutateAsync,
|
||||
saveConfig,
|
||||
isSaving: updateConfig.isPending,
|
||||
};
|
||||
}
|
||||
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
|
||||
return useMutation({
|
||||
mutationFn: refreshAccountQuota,
|
||||
onSuccess: (updatedAccount: CloudAccount) => {
|
||||
// Optimistically update the list or just invalidate?
|
||||
// Invalidating is safer as sorting might change (last_used)
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
|
||||
if (!oldData) return [updatedAccount];
|
||||
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -97,8 +97,12 @@ export function useSetAutoSwitchEnabled() {
|
||||
}
|
||||
|
||||
export function useForcePollCloudMonitor() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: forcePollCloudMonitor,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { app } from 'electron';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getAppDataDir } from './utils/paths';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
function getQuickConfig() {
|
||||
try {
|
||||
const configPath = path.join(getAppDataDir(), 'gui_config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
// Default to false (privacy by default)
|
||||
return config.error_reporting_enabled;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to read config for Sentry init:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getQuickConfig()) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
release: `antigravity-manager@${app.getVersion()}`,
|
||||
beforeSend(event) {
|
||||
if (event.exception?.values?.[0]?.value) {
|
||||
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||
/Users\\\\[^\\\\]+/g,
|
||||
'Users\\\\***',
|
||||
);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
logger.setErrorReportingEnabled(true);
|
||||
logger.setSentryReporter((payload) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('log_level', payload.level);
|
||||
scope.setContext('recent_logs', {
|
||||
entries: payload.logs.map((entry) => ({
|
||||
timestamp: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
formatted: entry.formatted,
|
||||
})),
|
||||
});
|
||||
scope.setExtra('log_message', payload.message);
|
||||
if (payload.error) {
|
||||
Sentry.captureException(payload.error);
|
||||
return;
|
||||
}
|
||||
Sentry.captureMessage(payload.message, 'error');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.setErrorReportingEnabled(false);
|
||||
logger.setSentryReporter(null);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -123,6 +123,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
@@ -143,6 +145,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
@@ -189,7 +193,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 +223,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);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
ConfigManager.saveConfig(config);
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static cachedConfig: AppConfig | null = null;
|
||||
private static saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
@@ -20,6 +23,7 @@ export class ConfigManager {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ export class ConfigManager {
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged = {
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
@@ -45,22 +49,35 @@ export class ConfigManager {
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
return merged as AppConfig;
|
||||
this.cachedConfig = merged;
|
||||
return merged;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
static getCachedConfig(): AppConfig | null {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||
this.cachedConfig = config;
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@ export const configRouter = os.router({
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
saveConfig(input);
|
||||
await saveConfig(input);
|
||||
}),
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { encrypt, decrypt } from '../../utils/security';
|
||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Create accounts table
|
||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
||||
@@ -73,9 +74,70 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
function getDb(): Database.Database {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
return new Database(dbPath);
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
interface MigrationStats {
|
||||
totalFields: number;
|
||||
fallbackUsedFields: number;
|
||||
migratedFields: number;
|
||||
migratedBySource: Record<KeySource, number>;
|
||||
failedFields: number;
|
||||
}
|
||||
|
||||
function createMigrationStats(): MigrationStats {
|
||||
return {
|
||||
totalFields: 0,
|
||||
fallbackUsedFields: 0,
|
||||
migratedFields: 0,
|
||||
migratedBySource: {
|
||||
safeStorage: 0,
|
||||
keytar: 0,
|
||||
file: 0,
|
||||
},
|
||||
failedFields: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptAndMigrateField(
|
||||
db: Database.Database,
|
||||
accountId: string,
|
||||
field: 'token_json' | 'quota_json',
|
||||
value: string | null,
|
||||
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
|
||||
if (!value) {
|
||||
return { value: null, migrated: false };
|
||||
}
|
||||
|
||||
const result = await decryptWithMigration(value);
|
||||
if (result.reencrypted) {
|
||||
if (field === 'token_json') {
|
||||
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
|
||||
result.reencrypted,
|
||||
accountId,
|
||||
);
|
||||
} else {
|
||||
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
|
||||
result.reencrypted,
|
||||
accountId,
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
value: result.value,
|
||||
migrated: Boolean(result.reencrypted),
|
||||
usedFallback: result.usedFallback,
|
||||
};
|
||||
}
|
||||
|
||||
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
|
||||
|
||||
export class CloudAccountRepo {
|
||||
static async init(): Promise<void> {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
@@ -168,6 +230,7 @@ export class CloudAccountRepo {
|
||||
|
||||
static async getAccounts(): Promise<CloudAccount[]> {
|
||||
const db = getDb();
|
||||
const migrationStats = createMigrationStats();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
||||
@@ -180,24 +243,91 @@ export class CloudAccountRepo {
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
const accounts: CloudAccount[] = [];
|
||||
for (const row of rows) {
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${row.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${row.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${row.id}`);
|
||||
}
|
||||
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
is_active: Boolean(row.is_active),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return accounts;
|
||||
} finally {
|
||||
if (
|
||||
migrationStats.migratedFields > 0 ||
|
||||
migrationStats.fallbackUsedFields > 0 ||
|
||||
migrationStats.failedFields > 0
|
||||
) {
|
||||
const summary = {
|
||||
totalFields: migrationStats.totalFields,
|
||||
fallbackUsedFields: migrationStats.fallbackUsedFields,
|
||||
migratedFields: migrationStats.migratedFields,
|
||||
migratedBySource: migrationStats.migratedBySource,
|
||||
failedFields: migrationStats.failedFields,
|
||||
};
|
||||
if (migrationStats.failedFields > 0) {
|
||||
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
|
||||
} else {
|
||||
logger.info('CloudAccountRepo migration summary', summary);
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -211,14 +341,21 @@ export class CloudAccountRepo {
|
||||
|
||||
if (!row) return undefined;
|
||||
|
||||
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
||||
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${row.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
@@ -291,29 +428,51 @@ export class CloudAccountRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static injectCloudToken(token: CloudAccount['token']): void {
|
||||
static injectCloudToken(account: CloudAccount): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
let dbPath: string | null = null;
|
||||
|
||||
for (const p of dbPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
|
||||
|
||||
if (!row || !row.value) {
|
||||
throw new Error('jetskiStateSync.agentManagerInitState not found in database');
|
||||
logger.warn(
|
||||
'jetskiStateSync.agentManagerInitState not found. ' +
|
||||
'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.',
|
||||
);
|
||||
|
||||
// Inject minimal state: auth status and onboarding flag only
|
||||
const authStatus = {
|
||||
name: account.name || account.email,
|
||||
email: account.email,
|
||||
apiKey: account.token.access_token,
|
||||
};
|
||||
|
||||
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
|
||||
'antigravityAuthStatus',
|
||||
JSON.stringify(authStatus),
|
||||
);
|
||||
|
||||
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
|
||||
'antigravityOnboarding',
|
||||
'true',
|
||||
);
|
||||
|
||||
db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity');
|
||||
|
||||
logger.info(
|
||||
`Injected minimal auth state for ${account.email} (no protobuf state available)`,
|
||||
);
|
||||
|
||||
return; // Early return - skip protobuf manipulation
|
||||
}
|
||||
|
||||
// 1. Decode Base64
|
||||
@@ -325,9 +484,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 +503,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();
|
||||
}
|
||||
@@ -389,14 +568,11 @@ export class CloudAccountRepo {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||
|
||||
let dbPath: string | null = null;
|
||||
for (const p of dbPaths) {
|
||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const dbPath =
|
||||
dbPaths.find((p) => {
|
||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||
return fs.existsSync(p);
|
||||
}) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
@@ -406,6 +582,7 @@ export class CloudAccountRepo {
|
||||
|
||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||
const ideDb = new Database(dbPath, { readonly: true });
|
||||
ideDb.pragma('journal_mode = WAL');
|
||||
try {
|
||||
// 1. Read Raw Token Data
|
||||
const row = ideDb
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
backupAccount,
|
||||
restoreAccount,
|
||||
getCurrentAccountInfo,
|
||||
} from "./handler";
|
||||
import {
|
||||
AccountBackupDataSchema,
|
||||
AccountInfoSchema,
|
||||
AccountSchema,
|
||||
} from "../../types/account";
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
export enum ResponseType {
|
||||
None = 0,
|
||||
Thinking = 1,
|
||||
Text = 2,
|
||||
Tool = 3,
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class ClaudeStreamConverter {
|
||||
public responseIndex: number = 0;
|
||||
public currentType: ResponseType = ResponseType.None;
|
||||
public hasContent: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
processChunk(jsonChunk: any): StreamEvent[] {
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
// 0. Handle Prompt Feedback (Blocked requests)
|
||||
if (!jsonChunk.candidates && !jsonChunk.choices) {
|
||||
if (jsonChunk.promptFeedback) {
|
||||
console.warn(
|
||||
`(Anthropic) Received promptFeedback (likely blocked): ${JSON.stringify(jsonChunk.promptFeedback)}`,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const choices = jsonChunk.choices;
|
||||
if (!choices || choices.length === 0) return events;
|
||||
|
||||
const choice = choices[0];
|
||||
const delta = choice.delta;
|
||||
|
||||
const deltaContent = delta.content || delta.text || ''; // Support both content and text fields just in case
|
||||
const isThought = delta.thought === true || !!delta.thinking; // Check for thinking flag or content
|
||||
const thinkingContent = delta.thinking || '';
|
||||
const thoughtSignature = delta.thoughtSignature;
|
||||
|
||||
// If we have content of any kind
|
||||
if (deltaContent || isThought || thoughtSignature || thinkingContent) {
|
||||
this.hasContent = true;
|
||||
}
|
||||
|
||||
// 1. Handle Thinking
|
||||
// If it is explicitly marked as thought, or has thinking content
|
||||
if (isThought || thoughtSignature || thinkingContent) {
|
||||
// Close Text if open
|
||||
if (this.currentType === ResponseType.Text) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Thinking if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'thinking', thinking: '' }, // Anthropic Beta expects 'thinking' block
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Thinking;
|
||||
}
|
||||
|
||||
if (thinkingContent) {
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'thinking_delta', thinking: thinkingContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Handle Regular Text
|
||||
else if (deltaContent) {
|
||||
// Close Thinking if open
|
||||
if (this.currentType === ResponseType.Thinking) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Text if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Text;
|
||||
}
|
||||
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'text_delta', text: deltaContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Stop Reason
|
||||
if (choice.finish_reason) {
|
||||
// Close any open block
|
||||
if (this.currentType !== ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
let stopReason = 'end_turn';
|
||||
const r = choice.finish_reason;
|
||||
if (r === 'length' || r === 'MAX_TOKENS') stopReason = 'max_tokens';
|
||||
else if (r === 'tool_calls') stopReason = 'tool_use';
|
||||
// Safety mappings
|
||||
else if (r === 'SAFETY' || r === 'RECITATION') stopReason = 'end_turn'; // Fallback
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_stop', {
|
||||
type: 'message_stop',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
static createMessageStart(msgId: string, model: string): StreamEvent {
|
||||
return {
|
||||
event: 'message_start',
|
||||
data: JSON.stringify({
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: msgId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createEvent(name: string, data: any): StreamEvent {
|
||||
return {
|
||||
event: name,
|
||||
data: JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
OpenAIMessage,
|
||||
GeminiContent,
|
||||
GeminiPart,
|
||||
GeminiInlineData,
|
||||
AnthropicChatRequest,
|
||||
AnthropicMessage,
|
||||
AnthropicContent,
|
||||
ContentPart,
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Helper to extract text from content
|
||||
function getTextFromContent(content: string | ContentPart[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
return content
|
||||
.map((p) => {
|
||||
if (p.type === 'text') return p.text || '';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getPreview(content: string | ContentPart[]): string {
|
||||
const text = getTextFromContent(content);
|
||||
return text.length > 200 ? text.substring(0, 200) + '...' : text;
|
||||
}
|
||||
|
||||
export const Converter = {
|
||||
/**
|
||||
* Convert OpenAI messages to Gemini contents
|
||||
*/
|
||||
convertOpenAIToGeminiContents(messages: OpenAIMessage[]): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
const pendingImages: GeminiInlineData[] = [];
|
||||
|
||||
// Regex for markdown images: 
|
||||
// Group 1: mime, Group 2: data
|
||||
const reMarkdown =
|
||||
/!\[.*?\]\(data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)\)/g;
|
||||
// Regex for explicit data URLs
|
||||
const reDataUrl = /data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)/;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
// logger.info(`Msg[${i}][${msg.role}] content=${getPreview(msg.content)}`);
|
||||
|
||||
const role = msg.role === 'assistant' ? 'model' : msg.role === 'system' ? 'user' : msg.role;
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
// 1. Inject Pending Images from Assistant history to User
|
||||
if (role === 'user' && pendingImages.length > 0) {
|
||||
logger.info(`Injecting ${pendingImages.length} pending images to User message`);
|
||||
while (pendingImages.length > 0) {
|
||||
const img = pendingImages.shift();
|
||||
if (img) parts.push({ inlineData: img });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse Content
|
||||
if (typeof msg.content === 'string') {
|
||||
const text = msg.content;
|
||||
let lastEnd = 0;
|
||||
let match;
|
||||
|
||||
// Reset regex state
|
||||
reMarkdown.lastIndex = 0;
|
||||
|
||||
while ((match = reMarkdown.exec(text)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = reMarkdown.lastIndex;
|
||||
|
||||
if (matchStart > lastEnd) {
|
||||
const textPart = text.substring(lastEnd, matchStart);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const data = match[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
lastEnd = matchEnd;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
const textPart = text.substring(lastEnd);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Array format
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text') {
|
||||
if (part.text) parts.push({ text: part.text });
|
||||
} else if (part.type === 'image_url' && part.image_url) {
|
||||
const url = part.image_url.url;
|
||||
const validMatch = url.match(reDataUrl);
|
||||
if (validMatch) {
|
||||
const mimeType = validMatch[1];
|
||||
const data = validMatch[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Ignoring unsupported image URL format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallbacks
|
||||
if (role === 'model' && parts.length === 0 && pendingImages.length > 0) {
|
||||
// Was likely just an image in assistant history, we moved it to pending.
|
||||
// But model message cannot be empty?
|
||||
// Actually the loop above pushes for 'model' into pendingImages ONLY if it matches helper logic.
|
||||
// If the loops finishes and we have parts, good.
|
||||
// If empty and we put stuff in pendingImages, we need a placeholder?
|
||||
parts.push({ text: '[Image Generated]' });
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push({ text: '' });
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
// Merge User Messages
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
convertAnthropicToGeminiContents(
|
||||
request: AnthropicChatRequest,
|
||||
signatureMap?: Map<string, string>,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
|
||||
for (const msg of request.messages) {
|
||||
const role = msg.role === 'assistant' ? 'model' : 'user';
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
const msgContent = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: [{ type: 'text', text: msg.content } as AnthropicContent];
|
||||
|
||||
for (const content of msgContent) {
|
||||
if (content.type === 'text' && content.text) {
|
||||
parts.push({ text: content.text });
|
||||
} else if (content.type === 'image' && content.source && content.source.type === 'base64') {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: content.source.media_type,
|
||||
data: content.source.data,
|
||||
},
|
||||
});
|
||||
} else if (content.type === 'thinking') {
|
||||
// Ignore thinking input
|
||||
}
|
||||
}
|
||||
|
||||
// Thought Signature Injection (for Assistant)
|
||||
if (role === 'model' && signatureMap) {
|
||||
const latest = signatureMap.get('latest');
|
||||
if (latest) {
|
||||
parts.push({ thoughtSignature: latest });
|
||||
}
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
mergeConsecutiveUserMessages(contents: GeminiContent[]): GeminiContent[] {
|
||||
let i = 1;
|
||||
while (i < contents.length) {
|
||||
if (contents[i].role === 'user' && contents[i - 1].role === 'user') {
|
||||
const prev = contents[i - 1];
|
||||
const curr = contents[i];
|
||||
|
||||
// Add separator if needed
|
||||
const prevLast = prev.parts[prev.parts.length - 1];
|
||||
const currFirst = curr.parts[0];
|
||||
|
||||
if (prevLast && prevLast.text !== undefined && currFirst && currFirst.text !== undefined) {
|
||||
prev.parts.push({ text: '\n\n' });
|
||||
}
|
||||
|
||||
prev.parts.push(...curr.parts);
|
||||
contents.splice(i, 1);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
},
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OpenAIChatRequest, AnthropicChatRequest, GeminiContent } from './types';
|
||||
import { Converter } from './converter';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { UpstreamProxyConfig } from '../../types/config';
|
||||
|
||||
// Type definitions for fetch (since we are in Node)
|
||||
// Electron main process has 'fetch' globally available in newer versions (Node 18+),
|
||||
// but we might need to handle types.
|
||||
declare const fetch: any;
|
||||
|
||||
export class GeminiClient {
|
||||
private timeoutMs: number;
|
||||
private upstreamProxy?: UpstreamProxyConfig;
|
||||
|
||||
constructor(timeoutSeconds: number = 60, upstreamProxy?: UpstreamProxyConfig) {
|
||||
this.timeoutMs = timeoutSeconds * 1000;
|
||||
this.upstreamProxy = upstreamProxy;
|
||||
}
|
||||
|
||||
private getFetchOptions() {
|
||||
if (this.upstreamProxy?.enabled && this.upstreamProxy.url) {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(this.upstreamProxy.url),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamGenerateAnthropic(
|
||||
request: AnthropicChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
signatureMap?: Map<string, string>,
|
||||
modelMapping?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
// Returns a ReadableStream or similar
|
||||
const url =
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse';
|
||||
|
||||
const contents = Converter.convertAnthropicToGeminiContents(request, signatureMap);
|
||||
|
||||
// ... (System prompt handling remains same)
|
||||
const systemInstruction = request.system
|
||||
? {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof request.system === 'string'
|
||||
? request.system
|
||||
: JSON.stringify(request.system),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { role: 'user', parts: [{ text: '' }] };
|
||||
|
||||
const generationConfig: any = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 16384,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
if (request.model.includes('sonnet') || request.model.includes('thinking')) {
|
||||
generationConfig.thinkingConfig = {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 8191,
|
||||
};
|
||||
}
|
||||
|
||||
let upstreamModel = request.model;
|
||||
|
||||
// 1. User Custom Mapping (Fuzzy match)
|
||||
if (modelMapping) {
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (request.model.includes(key)) {
|
||||
upstreamModel = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Built-in Fallback Logic
|
||||
const lowerName = upstreamModel.toLowerCase();
|
||||
if (!upstreamModel.startsWith('gemini')) {
|
||||
// Only map if not already a gemini model (roughly)
|
||||
if (lowerName.includes('sonnet') || lowerName.includes('thinking')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('haiku')) {
|
||||
upstreamModel = 'gemini-2.0-flash-exp';
|
||||
} else if (lowerName.includes('opus')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('claude')) {
|
||||
upstreamModel = 'gemini-2.5-flash-thinking';
|
||||
} else if (lowerName === 'gemini-3-pro-high' || lowerName === 'gemini-3-pro-low') {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName === 'gemini-3-flash') {
|
||||
upstreamModel = 'gemini-3-flash-preview';
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: upstreamModel,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction,
|
||||
generationConfig,
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`(Gateway) Anthropic Request: ${request.model} -> ${upstreamModel} | Proj: ${projectId}`,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'claude-cli/1.0.83 (external, cli)',
|
||||
'X-App': 'cli',
|
||||
'Anthropic-Beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
'X-Stainless-Lang': 'js',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.body; // Node.js Readable Web Stream
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: OpenAIChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<any> {
|
||||
const url = 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent';
|
||||
|
||||
// Split System
|
||||
const systemMessages = request.messages.filter((m) => m.role === 'system');
|
||||
const chatMessages = request.messages.filter((m) => m.role !== 'system');
|
||||
|
||||
const systemText = systemMessages
|
||||
.map((m) => {
|
||||
if (typeof m.content === 'string') return m.content;
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const contents = Converter.convertOpenAIToGeminiContents(chatMessages);
|
||||
|
||||
const generationConfig = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 8096,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: request.model,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
role: 'user',
|
||||
parts: [{ text: systemText }],
|
||||
},
|
||||
generationConfig,
|
||||
toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } },
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'antigravity/1.11.3 windows/amd64',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service
|
||||
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
|
||||
*/
|
||||
import { gatewayInstance } from './server';
|
||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Start the gateway server
|
||||
* Start the gateway server (NestJS)
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
if (gatewayInstance.isRunning()) {
|
||||
await gatewayInstance.stop();
|
||||
}
|
||||
await gatewayInstance.start(port);
|
||||
return true;
|
||||
// Stop if already running
|
||||
await stopNestServer();
|
||||
|
||||
// Load full config and start NestJS server
|
||||
const config = ConfigManager.loadConfig();
|
||||
const proxyConfig = { ...config.proxy, port };
|
||||
|
||||
return await bootstrapNestServer(proxyConfig);
|
||||
} catch (e) {
|
||||
console.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
@@ -23,12 +26,11 @@ export const startGateway = async (port: number): Promise<boolean> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
* Stop the gateway server (NestJS)
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
await gatewayInstance.stop();
|
||||
return true;
|
||||
return await stopNestServer();
|
||||
} catch (e) {
|
||||
console.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
@@ -36,10 +38,10 @@ export const stopGateway = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status
|
||||
* Get gateway status (NestJS)
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return gatewayInstance.getStatus();
|
||||
return getNestServerStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -51,7 +53,7 @@ export const generateApiKey = async (): Promise<string> => {
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
ConfigManager.saveConfig(config);
|
||||
await ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
@@ -1,267 +0,0 @@
|
||||
import http from 'http';
|
||||
import { TokenManager } from './tokenManager';
|
||||
import { GeminiClient } from './geminiClient';
|
||||
import { ClaudeStreamConverter } from './claudeConverter';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const PORT = 3000; // Default, make configurable?
|
||||
|
||||
export class GatewayServer {
|
||||
private server: http.Server | null = null;
|
||||
private tokenManager: TokenManager;
|
||||
private client: GeminiClient;
|
||||
private signatureMap: Map<string, string> = new Map();
|
||||
private currentPort: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.tokenManager = new TokenManager();
|
||||
this.client = new GeminiClient(60);
|
||||
}
|
||||
|
||||
async start(port: number = PORT) {
|
||||
// Load config and accounts
|
||||
const config = ConfigManager.loadConfig();
|
||||
await this.tokenManager.loadAccounts();
|
||||
logger.info(`Gateway: Loaded ${await this.tokenManager.loadAccounts()} accounts`);
|
||||
|
||||
// Initialize Client with config
|
||||
// Default to 60s if not set, pass upstream proxy config
|
||||
this.client = new GeminiClient(
|
||||
config.proxy?.request_timeout || 60,
|
||||
config.proxy?.upstream_proxy,
|
||||
);
|
||||
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
|
||||
try {
|
||||
if (url.pathname === '/v1/messages' && req.method === 'POST') {
|
||||
await this.handleAnthropicMessages(req, res);
|
||||
} else if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
|
||||
// await this.handleChatCompletions(req, res);
|
||||
res.writeHead(501);
|
||||
res.end('Not Implemented Yet');
|
||||
} else if (url.pathname === '/healthz') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Gateway: Request Error', e);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: String(e) }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (e: any) => {
|
||||
if (e.code === 'EADDRINUSE') {
|
||||
logger.error(`Gateway: Port ${port} is already in use.`);
|
||||
} else {
|
||||
logger.error('Gateway: Server error', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
this.currentPort = port;
|
||||
logger.info(`Gateway: Server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAnthropicMessages(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const body = await this.readBody(req);
|
||||
const request = JSON.parse(body);
|
||||
|
||||
logger.info(`(Anthropic) Request ${request.model}`);
|
||||
|
||||
// Retry Loop
|
||||
const maxRetries = 3; // or tokenManager count
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
attempts++;
|
||||
const token = await this.tokenManager.getToken();
|
||||
|
||||
if (!token) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({ error: { type: 'overloaded_error', message: 'No accounts available' } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`(Anthropic) Using account ${token.email} (Attempt ${attempts})`);
|
||||
|
||||
try {
|
||||
const config = ConfigManager.loadConfig();
|
||||
const mapping = config.proxy?.anthropic_mapping || {};
|
||||
|
||||
const stream = await this.client.streamGenerateAnthropic(
|
||||
request,
|
||||
token.access_token,
|
||||
token.project_id || 'unknown-project',
|
||||
token.session_id,
|
||||
this.signatureMap,
|
||||
mapping,
|
||||
);
|
||||
|
||||
// Handle SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const msgId = `msg_${uuidv4()}`;
|
||||
const startEvent = ClaudeStreamConverter.createMessageStart(msgId, request.model);
|
||||
res.write(`event: ${startEvent.event}\ndata: ${startEvent.data}\n\n`);
|
||||
|
||||
const converter = new ClaudeStreamConverter();
|
||||
|
||||
// Read the stream
|
||||
// Node fetch response.body is a ReadableStream (web stream) or IncomingMessage (Node stream) depending on fetch polyfill
|
||||
// Assuming it's an async iterable or we can get a reader.
|
||||
// In Node 18+ native fetch, body is a Web stream.
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// chunk is Buffer or Uint8Array
|
||||
const text = Buffer.from(chunk).toString('utf-8');
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
// Logic from server.rs: process_chunk -> get events
|
||||
const events = converter.processChunk(json);
|
||||
for (const event of events) {
|
||||
res.write(`event: ${event.event}\ndata: ${event.data}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Gateway: Failed to parse Gemini chunk', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.end();
|
||||
return; // Success
|
||||
} catch (e: any) {
|
||||
logger.error(`(Anthropic) Request failed: ${e.message}`);
|
||||
// Check retry
|
||||
if (this.shouldRetry(e.message) && attempts < maxRetries) {
|
||||
logger.warn(`(Anthropic) Retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Final error
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { type: 'api_error', message: e.message } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetry(errorMsg: string): boolean {
|
||||
// Simple heuristic
|
||||
if (
|
||||
errorMsg.includes('429') ||
|
||||
errorMsg.includes('503') ||
|
||||
errorMsg.includes('500') ||
|
||||
errorMsg.includes('403')
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => resolve(body));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Lifecycle & Status Methods =====
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
*/
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close((err) => {
|
||||
if (err) {
|
||||
logger.error('Gateway: Failed to stop server', err);
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info('Gateway: Server stopped');
|
||||
this.server = null;
|
||||
this.currentPort = 0;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.server !== null && this.server.listening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current gateway status
|
||||
*/
|
||||
async getStatus(): Promise<{
|
||||
running: boolean;
|
||||
port: number;
|
||||
base_url: string;
|
||||
active_accounts: number;
|
||||
}> {
|
||||
const accountCount = await this.getAccountCount();
|
||||
return {
|
||||
running: this.isRunning(),
|
||||
port: this.currentPort,
|
||||
base_url: this.isRunning() ? `http://localhost:${this.currentPort}` : '',
|
||||
active_accounts: accountCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of loaded accounts
|
||||
*/
|
||||
async getAccountCount(): Promise<number> {
|
||||
return this.tokenManager.loadAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global access
|
||||
export const gatewayInstance = new GatewayServer();
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ProxyToken } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
|
||||
export class TokenManager {
|
||||
private tokens: Map<string, ProxyToken> = new Map();
|
||||
private currentIndex: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async loadAccounts(): Promise<number> {
|
||||
// Load from SQLite DB
|
||||
// Fix: Use getAccounts() instead of list()
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
let count = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const token = this.convertAccountToToken(account);
|
||||
if (token) {
|
||||
this.tokens.set(token.account_id, token);
|
||||
count++;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Gateway: Failed to load account ${account.email}`, e);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private convertAccountToToken(account: CloudAccount): ProxyToken | null {
|
||||
// Fix: account.token instead of account.token_data
|
||||
if (!account.token) return null;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
email: account.email,
|
||||
access_token: account.token.access_token,
|
||||
refresh_token: account.token.refresh_token,
|
||||
expires_in: account.token.expires_in,
|
||||
timestamp: account.token.expiry_timestamp,
|
||||
account_path: '', // Not used with DB
|
||||
project_id: account.token.project_id || undefined,
|
||||
session_id: account.token.session_id || this.generateSessionId(),
|
||||
};
|
||||
}
|
||||
|
||||
generateSessionId(): string {
|
||||
const min = 1_000_000_000_000_000_000n;
|
||||
const max = 9_000_000_000_000_000_000n;
|
||||
const range = max - min;
|
||||
const rand = BigInt(Math.floor(Math.random() * Number(range)));
|
||||
return (-(min + rand)).toString();
|
||||
}
|
||||
|
||||
async getToken(): Promise<ProxyToken | null> {
|
||||
if (this.tokens.size === 0) {
|
||||
await this.loadAccounts();
|
||||
}
|
||||
if (this.tokens.size === 0) return null;
|
||||
|
||||
const keys = Array.from(this.tokens.keys());
|
||||
this.currentIndex = (this.currentIndex + 1) % keys.length;
|
||||
const key = keys[this.currentIndex];
|
||||
const token = this.tokens.get(key);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now >= token.timestamp - 300) {
|
||||
logger.info(`Gateway: Token for ${token.email} expiring, refreshing...`);
|
||||
try {
|
||||
const newTokens = await GoogleAPIService.refreshAccessToken(token.refresh_token);
|
||||
|
||||
// Update Token Object
|
||||
token.access_token = newTokens.access_token;
|
||||
token.expires_in = newTokens.expires_in;
|
||||
token.timestamp = Math.floor(Date.now() / 1000) + newTokens.expires_in;
|
||||
|
||||
// Update DB
|
||||
await this.saveRefreshedToken(token.account_id, token);
|
||||
|
||||
this.tokens.set(token.account_id, token);
|
||||
logger.info(`Gateway: Token refreshed for ${token.email}`);
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to refresh token for ${token.email}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token.project_id) {
|
||||
try {
|
||||
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
|
||||
token.project_id = mockId;
|
||||
await this.saveProjectId(token.account_id, mockId);
|
||||
} catch (e) {
|
||||
logger.warn('Gateway: Failed to resolve project ID', e);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async saveRefreshedToken(accountId: string, token: ProxyToken) {
|
||||
try {
|
||||
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||
if (acc && acc.token) {
|
||||
// Update token fields
|
||||
const newToken = {
|
||||
...acc.token,
|
||||
access_token: token.access_token,
|
||||
expires_in: token.expires_in,
|
||||
expiry_timestamp: token.timestamp,
|
||||
};
|
||||
|
||||
// Use updateToken which expects the whole token object (or partial?)
|
||||
// CloudHandler.updateToken expects JSON object that it stringifies.
|
||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to save refreshed token to DB`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async saveProjectId(accountId: string, projectId: string) {
|
||||
try {
|
||||
// CloudAccountRepo.getAccount is async
|
||||
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||
if (acc && acc.token) {
|
||||
const newToken = {
|
||||
...acc.token,
|
||||
project_id: projectId,
|
||||
};
|
||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to save project ID to DB`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||