Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- OPENSPEC:START -->
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
**Guardrails**
|
**Guardrails**
|
||||||
|
|
||||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
- Keep changes tightly scoped to the requested outcome.
|
- 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.
|
- 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**
|
**Steps**
|
||||||
Track these steps as TODOs and complete them one by one.
|
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.
|
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.
|
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.
|
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.
|
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||||
|
|
||||||
**Reference**
|
**Reference**
|
||||||
|
|
||||||
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||||
<!-- OPENSPEC:END -->
|
<!-- OPENSPEC:END -->
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
description: Archive a deployed OpenSpec change and update specs.
|
description: Archive a deployed OpenSpec change and update specs.
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- OPENSPEC:START -->
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
**Guardrails**
|
**Guardrails**
|
||||||
|
|
||||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
- Keep changes tightly scoped to the requested outcome.
|
- 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.
|
- 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**
|
**Steps**
|
||||||
|
|
||||||
1. Determine the change ID to archive:
|
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 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.
|
- 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.
|
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||||
|
|
||||||
**Reference**
|
**Reference**
|
||||||
|
|
||||||
- Use `openspec list` to confirm change IDs before archiving.
|
- Use `openspec list` to confirm change IDs before archiving.
|
||||||
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||||
<!-- OPENSPEC:END -->
|
<!-- OPENSPEC:END -->
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: Scaffold a new OpenSpec change and validate strictly.
|
description: Scaffold a new OpenSpec change and validate strictly.
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- OPENSPEC:START -->
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
**Guardrails**
|
**Guardrails**
|
||||||
|
|
||||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
- Keep changes tightly scoped to the requested outcome.
|
- 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.
|
- 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.
|
- 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**
|
**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.
|
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>/`.
|
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.
|
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.
|
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||||
|
|
||||||
**Reference**
|
**Reference**
|
||||||
|
|
||||||
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
- 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.
|
- 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.
|
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: 'npm'
|
||||||
directory: "/"
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: 'weekly'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name-template: 'v$RESOLVED_VERSION'
|
name-template: 'v$RESOLVED_VERSION'
|
||||||
tag-template: 'v$RESOLVED_VERSION'
|
tag-template: 'v$RESOLVED_VERSION'
|
||||||
categories:
|
categories:
|
||||||
- title: '🚀 What''s new'
|
- title: "🚀 What's new"
|
||||||
labels:
|
labels:
|
||||||
- 'feature'
|
- 'feature'
|
||||||
- 'feat'
|
- 'feat'
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
name: Publish Release
|
name: Publish Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags:
|
types: [published]
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -11,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
@@ -40,4 +39,8 @@ jobs:
|
|||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
run: npm run publish
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- beta
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Semantic Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for semantic-release to analyze commits
|
||||||
|
persist-credentials: false # We use a token in the next steps
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
|
||||||
|
run: npx semantic-release
|
||||||
@@ -102,3 +102,6 @@ out/
|
|||||||
|
|
||||||
# Tanstack
|
# Tanstack
|
||||||
.tanstack/
|
.tanstack/
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
+3
-1
@@ -3,4 +3,6 @@
|
|||||||
/coverage
|
/coverage
|
||||||
/.vite
|
/.vite
|
||||||
README.md
|
README.md
|
||||||
/src/routeTree.gen.ts
|
/src/routeTree.gen.ts
|
||||||
|
.agents/
|
||||||
|
openspec/
|
||||||
@@ -8,5 +8,4 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"proseWrap": "preserve",
|
"proseWrap": "preserve",
|
||||||
"tailwindFunctions": ["clsx", "tw"]
|
"tailwindFunctions": ["clsx", "tw"]
|
||||||
|
|
||||||
}
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
<!-- OPENSPEC:START -->
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
# OpenSpec Instructions
|
# OpenSpec Instructions
|
||||||
@@ -107,9 +106,6 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
|||||||
- `npm run test:e2e` - 运行 Playwright 端到端测试
|
- `npm run test:e2e` - 运行 Playwright 端到端测试
|
||||||
- `npm run test:all` - 运行所有测试
|
- `npm run test:all` - 运行所有测试
|
||||||
|
|
||||||
- **数据库 (DB)**:
|
|
||||||
- `npm run test-db` - 运行数据库测试脚本
|
|
||||||
|
|
||||||
### 运行单个测试 (Running Single Tests)
|
### 运行单个测试 (Running Single Tests)
|
||||||
|
|
||||||
- 单元测试: `npm run test:unit path/to/test.test.ts`
|
- 单元测试: `npm run test:unit path/to/test.test.ts`
|
||||||
@@ -117,6 +113,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
|||||||
- 类型检查: `npm run type-check`
|
- 类型检查: `npm run type-check`
|
||||||
|
|
||||||
## 🧪 开发注意事项(Development Notes)
|
## 🧪 开发注意事项(Development Notes)
|
||||||
|
|
||||||
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
|
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
|
||||||
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
|
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
|
||||||
- **React**: React Strict Mode 关闭。
|
- **React**: React Strict Mode 关闭。
|
||||||
@@ -141,6 +138,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
|||||||
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
|
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
|
||||||
|
|
||||||
### 命名约定 (Naming Specifics)
|
### 命名约定 (Naming Specifics)
|
||||||
|
|
||||||
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
|
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
|
||||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
|
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
|
||||||
- **Files**:
|
- **Files**:
|
||||||
@@ -221,6 +219,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
|||||||
- 标题后保留一行空行,便于渲染与阅读。
|
- 标题后保留一行空行,便于渲染与阅读。
|
||||||
|
|
||||||
## 换行规范
|
## 换行规范
|
||||||
|
|
||||||
return 等语句不要与其他语句在同一行,而是单独保持一行
|
return 等语句不要与其他语句在同一行,而是单独保持一行
|
||||||
|
|
||||||
## 💭 注释规范
|
## 💭 注释规范
|
||||||
@@ -278,7 +277,9 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用正确的错误类型
|
// 使用正确的错误类型
|
||||||
if (error instanceof Error) { /* 处理 Error 实例 */ }
|
if (error instanceof Error) {
|
||||||
|
/* 处理 Error 实例 */
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 新功能实现
|
### 新功能实现
|
||||||
@@ -301,6 +302,7 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
|
|||||||
- \[ ] 形成 TODO 列表
|
- \[ ] 形成 TODO 列表
|
||||||
|
|
||||||
实现/重构/修复:
|
实现/重构/修复:
|
||||||
|
|
||||||
- [ ] 阅读相关模板与周边代码,沿用既有模式
|
- [ ] 阅读相关模板与周边代码,沿用既有模式
|
||||||
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
|
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
|
||||||
- [ ] 在约束内尽力提升前端交互与体验
|
- [ ] 在约束内尽力提升前端交互与体验
|
||||||
@@ -335,9 +337,11 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
|
|||||||
## 🔧 工具偏好与命令
|
## 🔧 工具偏好与命令
|
||||||
|
|
||||||
包与脚本:
|
包与脚本:
|
||||||
|
|
||||||
- `npm install` (或简写 `npm i`)
|
- `npm install` (或简写 `npm i`)
|
||||||
|
|
||||||
Shell:
|
Shell:
|
||||||
|
|
||||||
- 在工作区根目录执行命令。
|
- 在工作区根目录执行命令。
|
||||||
- 路径建议使用引号包裹。
|
- 路径建议使用引号包裹。
|
||||||
|
|
||||||
@@ -350,6 +354,7 @@ Web 搜索:
|
|||||||
- `context7` 获取依赖的最新用法
|
- `context7` 获取依赖的最新用法
|
||||||
|
|
||||||
VSCode MCP (如可用):
|
VSCode MCP (如可用):
|
||||||
|
|
||||||
- `mcp__vscode-mcp__get_references` 辅助重构范围判定
|
- `mcp__vscode-mcp__get_references` 辅助重构范围判定
|
||||||
- `mcp__vscode-mcp__rename_symbol` 安全重命名
|
- `mcp__vscode-mcp__rename_symbol` 安全重命名
|
||||||
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
|
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
|
||||||
@@ -384,15 +389,17 @@ change description 示例:
|
|||||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
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:
|
How to use skills:
|
||||||
|
|
||||||
- Invoke: Bash("openskills read <skill-name>")
|
- Invoke: Bash("openskills read <skill-name>")
|
||||||
- The skill content will load with detailed instructions on how to complete the task
|
- 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/)
|
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
|
||||||
|
|
||||||
Usage notes:
|
Usage notes:
|
||||||
|
|
||||||
- Only use skills listed in <available_skills> below
|
- Only use skills listed in <available_skills> below
|
||||||
- Do not invoke a skill that is already loaded in your context
|
- Do not invoke a skill that is already loaded in your context
|
||||||
- Each skill invocation is stateless
|
- Each skill invocation is stateless
|
||||||
</usage>
|
</usage>
|
||||||
|
|
||||||
<available_skills>
|
<available_skills>
|
||||||
|
|
||||||
@@ -493,7 +500,7 @@ Usage notes:
|
|||||||
</skill>
|
</skill>
|
||||||
|
|
||||||
</available_skills>
|
</available_skills>
|
||||||
|
|
||||||
<!-- SKILLS_TABLE_END -->
|
<!-- SKILLS_TABLE_END -->
|
||||||
|
|
||||||
</skills_system>
|
</skills_system>
|
||||||
|
|
||||||
+125
-4
@@ -1,46 +1,167 @@
|
|||||||
|
<a name="readme-top"></a>
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
### ✨ Features
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
* **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
|
## [0.2.0] - 2026-01-16
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
|
- 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.
|
- Implemented stability fixes and enhanced error handling across the application.
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
|
- 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.
|
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed "Converting circular structure to JSON" error.
|
- Fixed "Converting circular structure to JSON" error.
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- Added curly brace constraints for conditional statements.
|
- Added curly brace constraints for conditional statements.
|
||||||
- Fixed incorrect reference documentation name.
|
- Fixed incorrect reference documentation name.
|
||||||
|
|
||||||
## [0.1.1] - 2026-01-11
|
## [0.1.1] - 2026-01-11
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
|
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-10
|
## [0.1.0] - 2026-01-10
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
|
- 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.
|
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
|
||||||
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
|
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
|
|
||||||
|
|
||||||
|
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
|
||||||
|
|
||||||
## [0.0.1] - 2025-12-22
|
## [0.0.1] - 2025-12-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial release of Antigravity Manager
|
- Initial release of Antigravity Manager
|
||||||
- Multi-account management for Google Gemini and Claude
|
- Multi-account management for Google Gemini and Claude
|
||||||
- Real-time quota monitoring
|
- Real-time quota monitoring
|
||||||
|
|||||||
+11
-10
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
|
|||||||
|
|
||||||
Examples of behavior that contributes to creating a positive environment include:
|
Examples of behavior that contributes to creating a positive environment include:
|
||||||
|
|
||||||
* Using welcoming and inclusive language
|
- Using welcoming and inclusive language
|
||||||
* Being respectful of differing viewpoints and experiences
|
- Being respectful of differing viewpoints and experiences
|
||||||
* Gracefully accepting constructive criticism
|
- Gracefully accepting constructive criticism
|
||||||
* Focusing on what is best for the community
|
- Focusing on what is best for the community
|
||||||
* Showing empathy towards other community members
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include:
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
- Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
- 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
|
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
## Our Responsibilities
|
## Our Responsibilities
|
||||||
|
|
||||||
@@ -45,4 +45,5 @@ This Code of Conduct is adapted from the [Contributor Covenant][homepage], versi
|
|||||||
[homepage]: https://www.contributor-covenant.org
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
|
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
|
||||||
+14
-10
@@ -52,20 +52,21 @@ npm start
|
|||||||
|
|
||||||
### Available Scripts
|
### Available Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ---------------------- | ----------------------------------- |
|
||||||
| `npm start` | Start the app in development mode |
|
| `npm start` | Start the app in development mode |
|
||||||
| `npm run lint` | Run ESLint to check for code issues |
|
| `npm run lint` | Run ESLint to check for code issues |
|
||||||
| `npm run format:write` | Format code with Prettier |
|
| `npm run format:write` | Format code with Prettier |
|
||||||
| `npm run test:unit` | Run unit tests with Vitest |
|
| `npm run test:unit` | Run unit tests with Vitest |
|
||||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||||
| `npm run test:all` | Run all tests |
|
| `npm run test:all` | Run all tests |
|
||||||
| `npm run type-check` | Run TypeScript type checking |
|
| `npm run type-check` | Run TypeScript type checking |
|
||||||
| `npm run make` | Build production packages |
|
| `npm run make` | Build production packages |
|
||||||
|
|
||||||
## ✏️ Making Changes
|
## ✏️ Making Changes
|
||||||
|
|
||||||
1. **Create a new branch** from `main`:
|
1. **Create a new branch** from `main`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b feature/your-feature-name
|
git checkout -b feature/your-feature-name
|
||||||
# or
|
# or
|
||||||
@@ -73,6 +74,7 @@ npm start
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Make your changes** and commit them:
|
2. **Make your changes** and commit them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "feat: add amazing new feature"
|
git commit -m "feat: add amazing new feature"
|
||||||
@@ -97,6 +99,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Types:**
|
**Types:**
|
||||||
|
|
||||||
- `feat`: A new feature
|
- `feat`: A new feature
|
||||||
- `fix`: A bug fix
|
- `fix`: A bug fix
|
||||||
- `docs`: Documentation only changes
|
- `docs`: Documentation only changes
|
||||||
@@ -107,6 +110,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
|
|||||||
- `chore`: Changes to build process or auxiliary tools
|
- `chore`: Changes to build process or auxiliary tools
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(auth): add Google OAuth support
|
feat(auth): add Google OAuth support
|
||||||
fix(quota): resolve quota refresh timeout issue
|
fix(quota): resolve quota refresh timeout issue
|
||||||
|
|||||||
@@ -280,6 +280,24 @@ Please check:
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><b>Q: How to report issues or suggestions?</b></summary>
|
<summary><b>Q: How to report issues or suggestions?</b></summary>
|
||||||
|
|
||||||
|
|||||||
+40
-22
@@ -174,7 +174,6 @@
|
|||||||
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
|
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
@@ -183,11 +182,11 @@
|
|||||||
|
|
||||||
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
||||||
|
|
||||||
| 平台 | 下载链接 |
|
| 平台 | 下载链接 |
|
||||||
|------|----------|
|
| ------- | ------------------------------------------------------------------------------ |
|
||||||
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
|
|
||||||
### 从源码构建
|
### 从源码构建
|
||||||
|
|
||||||
@@ -217,14 +216,14 @@ npm run make
|
|||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
| 类别 | 技术 |
|
| 类别 | 技术 |
|
||||||
|------|------|
|
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
|
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
|
||||||
| **构建工具** | [Vite](https://vitejs.dev/) |
|
| **构建工具** | [Vite](https://vitejs.dev/) |
|
||||||
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
|
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
|
||||||
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
|
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
|
||||||
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||||
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
|
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,16 +231,16 @@ npm run make
|
|||||||
|
|
||||||
### 可用脚本
|
### 可用脚本
|
||||||
|
|
||||||
| 命令 | 描述 |
|
| 命令 | 描述 |
|
||||||
|------|------|
|
| ---------------------- | ------------------------ |
|
||||||
| `npm start` | 启动开发模式 |
|
| `npm start` | 启动开发模式 |
|
||||||
| `npm run lint` | 运行 ESLint 检查 |
|
| `npm run lint` | 运行 ESLint 检查 |
|
||||||
| `npm run format:write` | 使用 Prettier 格式化代码 |
|
| `npm run format:write` | 使用 Prettier 格式化代码 |
|
||||||
| `npm run test:unit` | 运行单元测试 |
|
| `npm run test:unit` | 运行单元测试 |
|
||||||
| `npm run test:e2e` | 运行 E2E 测试 |
|
| `npm run test:e2e` | 运行 E2E 测试 |
|
||||||
| `npm run test:all` | 运行所有测试 |
|
| `npm run test:all` | 运行所有测试 |
|
||||||
| `npm run type-check` | TypeScript 类型检查 |
|
| `npm run type-check` | TypeScript 类型检查 |
|
||||||
| `npm run make` | 构建生产包 |
|
| `npm run make` | 构建生产包 |
|
||||||
|
|
||||||
### 项目结构
|
### 项目结构
|
||||||
|
|
||||||
@@ -265,6 +264,7 @@ AntigravityManager/
|
|||||||
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
||||||
|
|
||||||
请检查:
|
请检查:
|
||||||
|
|
||||||
1. 确保已安装所有依赖:`npm install`
|
1. 确保已安装所有依赖:`npm install`
|
||||||
2. 检查 Node.js 版本是否 >= 18
|
2. 检查 Node.js 版本是否 >= 18
|
||||||
3. 尝试删除 `node_modules` 后重新安装
|
3. 尝试删除 `node_modules` 后重新安装
|
||||||
@@ -280,6 +280,24 @@ AntigravityManager/
|
|||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><b>Q: 如何反馈问题或建议?</b></summary>
|
<summary><b>Q: 如何反馈问题或建议?</b></summary>
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -26,10 +26,10 @@ Master keys are stored in the OS native credential manager via `keytar`, never i
|
|||||||
|
|
||||||
## 📋 Supported Versions
|
## 📋 Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| -------- | ------------------ |
|
||||||
| Latest | :white_check_mark: |
|
| Latest | :white_check_mark: |
|
||||||
| < Latest | :x: |
|
| < Latest | :x: |
|
||||||
|
|
||||||
We only provide security updates for the latest version. Please ensure you're running the most recent release.
|
We only provide security updates for the latest version. Please ensure you're running the most recent release.
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
## 1. 核心功能
|
## 1. 核心功能
|
||||||
|
|
||||||
### 1.1 账号池管理
|
### 1.1 账号池管理
|
||||||
|
|
||||||
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
|
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
|
||||||
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
|
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
|
||||||
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
|
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
|
||||||
- **删除账号**:支持从本地数据库中移除账号。
|
- **删除账号**:支持从本地数据库中移除账号。
|
||||||
|
|
||||||
### 1.2 配额实时监控
|
### 1.2 配额实时监控
|
||||||
|
|
||||||
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
|
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
|
||||||
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
|
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
|
||||||
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
|
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
|
||||||
|
|
||||||
### 1.4 智能自动切换 (Intelligent Auto-Switching)
|
### 1.4 智能自动切换 (Intelligent Auto-Switching)
|
||||||
|
|
||||||
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
|
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
|
||||||
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
|
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
|
||||||
- **全局开关**:用户可在界面上一键开启或关闭此功能。
|
- **全局开关**:用户可在界面上一键开启或关闭此功能。
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
## 2. 技术实现
|
## 2. 技术实现
|
||||||
|
|
||||||
### 2.1 数据库设计 (`cloud_accounts.db`)
|
### 2.1 数据库设计 (`cloud_accounts.db`)
|
||||||
|
|
||||||
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
|
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -48,6 +52,7 @@ CREATE TABLE settings (
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 IPC 接口设计
|
### 2.2 IPC 接口设计
|
||||||
|
|
||||||
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
|
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
|
||||||
|
|
||||||
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
|
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
|
||||||
@@ -59,15 +64,18 @@ CREATE TABLE settings (
|
|||||||
- `forcePollCloudMonitor()`: 手动触发后台轮询。
|
- `forcePollCloudMonitor()`: 手动触发后台轮询。
|
||||||
|
|
||||||
### 2.3 Token 注入原理 (`ProtobufUtils`)
|
### 2.3 Token 注入原理 (`ProtobufUtils`)
|
||||||
|
|
||||||
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
|
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
|
||||||
value 是一个 Base64 编码的 Protobuf 二进制数据。
|
value 是一个 Base64 编码的 Protobuf 二进制数据。
|
||||||
|
|
||||||
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
|
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
|
||||||
|
|
||||||
1. **解码**:解析 Varint 和 Length-Delimited 字段。
|
1. **解码**:解析 Varint 和 Length-Delimited 字段。
|
||||||
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
|
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
|
||||||
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
|
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
|
||||||
|
|
||||||
### 2.4 切换流程
|
### 2.4 切换流程
|
||||||
|
|
||||||
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
|
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
|
||||||
2. **停止进程**:优雅关闭 Antigravity 进程。
|
2. **停止进程**:优雅关闭 Antigravity 进程。
|
||||||
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
|
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
|
||||||
@@ -75,12 +83,14 @@ value 是一个 Base64 编码的 Protobuf 二进制数据。
|
|||||||
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
|
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
|
||||||
|
|
||||||
### 2.5 自动切换逻辑 (`AutoSwitchService`)
|
### 2.5 自动切换逻辑 (`AutoSwitchService`)
|
||||||
|
|
||||||
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
|
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
|
||||||
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
|
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
|
||||||
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
|
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
|
||||||
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
|
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
|
||||||
|
|
||||||
### 2.6 安全加固 (Security Hardening)
|
### 2.6 安全加固 (Security Hardening)
|
||||||
|
|
||||||
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
|
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
|
||||||
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
|
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
|
||||||
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
||||||
+24
-14
@@ -1,28 +1,38 @@
|
|||||||
import globals from "globals";
|
import globals from 'globals';
|
||||||
import pluginJs from "@eslint/js";
|
import pluginJs from '@eslint/js';
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from 'typescript-eslint';
|
||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from 'eslint-plugin-react';
|
||||||
import eslintPluginPrettierRecommended from "eslint-config-prettier";
|
import eslintPluginPrettierRecommended from 'eslint-config-prettier';
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import path from "node:path";
|
import path from 'node:path';
|
||||||
import { includeIgnoreFile } from "@eslint/compat";
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from "eslint/config";
|
import { defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
|
const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
includeIgnoreFile(prettierIgnorePath),
|
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,
|
pluginJs.configs.recommended,
|
||||||
pluginReact.configs.flat["jsx-runtime"],
|
pluginReact.configs.flat['jsx-runtime'],
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
...tseslint.configs.recommended,
|
...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',
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
@@ -22,6 +22,7 @@ const config: ForgeConfig = {
|
|||||||
name: 'Antigravity Manager',
|
name: 'Antigravity Manager',
|
||||||
executableName: 'antigravity-manager',
|
executableName: 'antigravity-manager',
|
||||||
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
||||||
|
extraResource: ['src/assets'], // Copy assets folder to resources/assets
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ src/
|
|||||||
**数据库位置:**
|
**数据库位置:**
|
||||||
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
||||||
* Windows: `%APPDATA%/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`: 认证状态和用户信息。
|
* `antigravityAuthStatus`: 认证状态和用户信息。
|
||||||
|
|||||||
Generated
+7621
-248
File diff suppressed because it is too large.
Load diff
+13
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"description": "Antigravity Manager - Electron App",
|
"description": "Antigravity Manager - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:all": "vitest run && playwright test",
|
"test:all": "vitest run && playwright test",
|
||||||
"test-db": "electron test-db.js",
|
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"author": "Draculabo",
|
"author": "Draculabo",
|
||||||
@@ -39,6 +38,10 @@
|
|||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
"@playwright/test": "^1.56.1",
|
"@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",
|
"@swc/core": "^1.15.8",
|
||||||
"@tanstack/router-plugin": "^1.135.0",
|
"@tanstack/router-plugin": "^1.135.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
"code-inspector-plugin": "^1.3.3",
|
||||||
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||||
"electron": "^37.3.1",
|
"electron": "^37.3.1",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-playwright-helpers": "^1.8.2",
|
"electron-playwright-helpers": "^1.8.2",
|
||||||
@@ -68,6 +72,8 @@
|
|||||||
"node-addon-api": "^8.3.1",
|
"node-addon-api": "^8.3.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
|
"semantic-release": "^25.0.2",
|
||||||
|
"semantic-release-config-gitmoji": "^1.5.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@@ -86,7 +92,6 @@
|
|||||||
"@nestjs/microservices": "^11.1.11",
|
"@nestjs/microservices": "^11.1.11",
|
||||||
"@nestjs/platform-express": "^11.1.11",
|
"@nestjs/platform-express": "^11.1.11",
|
||||||
"@nestjs/platform-fastify": "^11.1.10",
|
"@nestjs/platform-fastify": "^11.1.10",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
|
||||||
"@orpc/client": "^1.10.4",
|
"@orpc/client": "^1.10.4",
|
||||||
"@orpc/server": "^1.11.3",
|
"@orpc/server": "^1.11.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -103,6 +108,9 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-toggle": "^1.1.9",
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@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",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"@tanstack/react-router": "^1.139.3",
|
"@tanstack/react-router": "^1.139.3",
|
||||||
@@ -117,6 +125,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
|
"find-process": "^2.0.0",
|
||||||
"happy-dom": "^20.0.10",
|
"happy-dom": "^20.0.10",
|
||||||
"i18next": "^25.6.3",
|
"i18next": "^25.6.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
@@ -133,6 +142,7 @@
|
|||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
"update-electron-app": "^3.1.1",
|
"update-electron-app": "^3.1.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./src/tests/e2e",
|
testDir: './src/tests/e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
trace: "on-first-retry",
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: 'chromium',
|
||||||
use: { ...devices["Desktop Chrome"] },
|
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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ipc } from "@/ipc/manager";
|
import { ipc } from '@/ipc/manager';
|
||||||
|
|
||||||
export function listAccounts() {
|
export function listAccounts() {
|
||||||
return ipc.client.account.listAccounts();
|
return ipc.client.account.listAccounts();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ipc } from "@/ipc/manager";
|
import { ipc } from '@/ipc/manager';
|
||||||
|
|
||||||
export function getPlatform() {
|
export function getPlatform() {
|
||||||
return ipc.client.app.currentPlatfom();
|
return ipc.client.app.currentPlatfom();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ipc } from "@/ipc/manager";
|
import { ipc } from '@/ipc/manager';
|
||||||
import { AccountBackupData, Account } from "@/types/account";
|
import { AccountBackupData, Account } from '@/types/account';
|
||||||
|
|
||||||
export function backupAccount(account: Account) {
|
export function backupAccount(account: Account) {
|
||||||
return ipc.client.database.backupAccount(account);
|
return ipc.client.database.backupAccount(account);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ipc } from '@/ipc/manager';
|
||||||
|
|
||||||
|
export function openLogDirectory() {
|
||||||
|
return ipc.client.system.openLogDirectory();
|
||||||
|
}
|
||||||
+9
-11
@@ -1,6 +1,6 @@
|
|||||||
import { ThemeMode } from "@/types/theme-mode";
|
import { ThemeMode } from '@/types/theme-mode';
|
||||||
import { ipc } from "@/ipc/manager";
|
import { ipc } from '@/ipc/manager';
|
||||||
import { LOCAL_STORAGE_KEYS } from "@/constants";
|
import { LOCAL_STORAGE_KEYS } from '@/constants';
|
||||||
|
|
||||||
export interface ThemePreferences {
|
export interface ThemePreferences {
|
||||||
system: ThemeMode;
|
system: ThemeMode;
|
||||||
@@ -9,9 +9,7 @@ export interface ThemePreferences {
|
|||||||
|
|
||||||
export async function getCurrentTheme(): Promise<ThemePreferences> {
|
export async function getCurrentTheme(): Promise<ThemePreferences> {
|
||||||
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
|
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
|
||||||
const localTheme = localStorage.getItem(
|
const localTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as ThemeMode | null;
|
||||||
LOCAL_STORAGE_KEYS.THEME,
|
|
||||||
) as ThemeMode | null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
system: currentTheme,
|
system: currentTheme,
|
||||||
@@ -20,7 +18,7 @@ export async function getCurrentTheme(): Promise<ThemePreferences> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setTheme(newTheme: ThemeMode) {
|
export async function setTheme(newTheme: ThemeMode) {
|
||||||
const isDarkMode = newTheme === "dark";
|
const isDarkMode = newTheme === 'dark';
|
||||||
await ipc.client.theme.setThemeMode(newTheme);
|
await ipc.client.theme.setThemeMode(newTheme);
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||||
updateDocumentTheme(isDarkMode);
|
updateDocumentTheme(isDarkMode);
|
||||||
@@ -28,7 +26,7 @@ export async function setTheme(newTheme: ThemeMode) {
|
|||||||
|
|
||||||
export async function toggleTheme() {
|
export async function toggleTheme() {
|
||||||
const isDarkMode = await ipc.client.theme.toggleThemeMode();
|
const isDarkMode = await ipc.client.theme.toggleThemeMode();
|
||||||
const newTheme = isDarkMode ? "dark" : "light";
|
const newTheme = isDarkMode ? 'dark' : 'light';
|
||||||
|
|
||||||
updateDocumentTheme(isDarkMode);
|
updateDocumentTheme(isDarkMode);
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||||
@@ -37,7 +35,7 @@ export async function toggleTheme() {
|
|||||||
export async function syncWithLocalTheme() {
|
export async function syncWithLocalTheme() {
|
||||||
const { local } = await getCurrentTheme();
|
const { local } = await getCurrentTheme();
|
||||||
if (!local) {
|
if (!local) {
|
||||||
setTheme("system");
|
setTheme('system');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +44,8 @@ export async function syncWithLocalTheme() {
|
|||||||
|
|
||||||
function updateDocumentTheme(isDarkMode: boolean) {
|
function updateDocumentTheme(isDarkMode: boolean) {
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} 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() {
|
export async function minimizeWindow() {
|
||||||
await ipc.client.window.minimizeWindow();
|
await ipc.client.window.minimizeWindow();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { enUS, zhCN, ru } from 'date-fns/locale';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -32,18 +33,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
isSwitching,
|
isSwitching,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const [transform, setTransform] = useState('');
|
const [transform, setTransform] = useState('');
|
||||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||||
|
|
||||||
const initials = account.name
|
const initials = account.name
|
||||||
? account.name
|
? account.name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
: account.email[0].toUpperCase();
|
: account.email[0].toUpperCase();
|
||||||
|
|
||||||
// 处理鼠标移动,计算 3D 旋转角度
|
// 处理鼠标移动,计算 3D 旋转角度
|
||||||
@@ -136,6 +137,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
{t('account.lastUsed', {
|
{t('account.lastUsed', {
|
||||||
time: formatDistanceToNow(new Date(account.last_used), {
|
time: formatDistanceToNow(new Date(account.last_used), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
|
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
|
||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -41,12 +41,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||||
|
|
||||||
// ... (existing code: imports and comments)
|
// ... (existing code: imports and comments)
|
||||||
|
|
||||||
export function CloudAccountList() {
|
export function CloudAccountList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: accounts, isLoading, isError } = useCloudAccounts();
|
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
|
||||||
const refreshMutation = useRefreshQuota();
|
const refreshMutation = useRefreshQuota();
|
||||||
const deleteMutation = useDeleteCloudAccount();
|
const deleteMutation = useDeleteCloudAccount();
|
||||||
const addMutation = useAddGoogleAccount();
|
const addMutation = useAddGoogleAccount();
|
||||||
@@ -62,19 +63,49 @@ export function CloudAccountList() {
|
|||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [authCode, setAuthCode] = useState('');
|
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
|
// Listen for Google Auth Code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.electron?.onGoogleAuthCode) {
|
if (window.electron?.onGoogleAuthCode) {
|
||||||
|
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
|
||||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||||
|
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
|
||||||
setAuthCode(code);
|
setAuthCode(code);
|
||||||
// Auto submit if dialog is open
|
// Note: Auto-submit will be triggered by the authCode change effect below
|
||||||
if (isAddDialogOpen) {
|
|
||||||
handleAddAccount(code);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return cleanup;
|
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
|
// Batch Operations State
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -103,7 +134,7 @@ export function CloudAccountList() {
|
|||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.switchFailed'),
|
title: t('cloud.toast.switchFailed'),
|
||||||
description: err.message,
|
description: getLocalizedErrorMessage(err, t),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -145,8 +176,15 @@ export function CloudAccountList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleForcePoll = () => {
|
const handleForcePoll = () => {
|
||||||
|
if (forcePollMutation.isPending) return;
|
||||||
forcePollMutation.mutate(undefined, {
|
forcePollMutation.mutate(undefined, {
|
||||||
onSuccess: () => toast({ title: t('cloud.polling') }),
|
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) => {
|
onError: (err) => {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.syncFailed.title'),
|
title: t('cloud.toast.syncFailed.title'),
|
||||||
description: err.message,
|
description: getLocalizedErrorMessage(err, t),
|
||||||
variant: 'destructive',
|
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 () => {
|
const openAuthUrl = async () => {
|
||||||
try {
|
try {
|
||||||
await startAuthFlow();
|
await startAuthFlow();
|
||||||
@@ -264,7 +280,13 @@ export function CloudAccountList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||||
isProcessRunning,
|
import { Button } from '@/components/ui/button';
|
||||||
startAntigravity,
|
import { cn } from '@/lib/utils';
|
||||||
closeAntigravity,
|
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||||
} from "@/actions/process";
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Play, Square, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const StatusBar: React.FC = () => {
|
interface StatusBarProps {
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: isRunning, isLoading } = useQuery({
|
const { data: isRunning, isLoading } = useQuery({
|
||||||
queryKey: ["process", "status"],
|
queryKey: ['process', 'status'],
|
||||||
queryFn: isProcessRunning,
|
queryFn: isProcessRunning,
|
||||||
refetchInterval: 2000,
|
refetchInterval: 2000,
|
||||||
});
|
});
|
||||||
@@ -23,14 +24,14 @@ export const StatusBar: React.FC = () => {
|
|||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: startAntigravity,
|
mutationFn: startAntigravity,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopMutation = useMutation({
|
const stopMutation = useMutation({
|
||||||
mutationFn: closeAntigravity,
|
mutationFn: closeAntigravity,
|
||||||
onSuccess: () => {
|
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;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isRunning
|
||||||
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-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 dark:bg-red-900/30 dark:text-red-100",
|
: '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 ? (
|
{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 ? (
|
) : 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>
|
<div className="flex flex-col gap-1 leading-none">
|
||||||
{isLoading
|
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
||||||
? t("status.checking")
|
<span className="text-sm leading-tight font-medium">
|
||||||
: isRunning
|
{isLoading
|
||||||
? t("status.running")
|
? t('status.checking')
|
||||||
: t("status.stopped")}
|
: isRunning
|
||||||
</span>
|
? t('status.running')
|
||||||
|
: t('status.stopped')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -75,18 +124,22 @@ export const StatusBar: React.FC = () => {
|
|||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isLoading || isPending}
|
disabled={isLoading || isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 hover:bg-white/20",
|
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
|
||||||
isRunning ? "hover:text-green-900" : "hover:text-red-900",
|
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 ? (
|
{isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : isRunning ? (
|
) : 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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from '@tanstack/react-router';
|
||||||
import {
|
import {
|
||||||
NavigationMenu as NavigationMenuBase,
|
NavigationMenu as NavigationMenuBase,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
NavigationMenuLink,
|
NavigationMenuLink,
|
||||||
NavigationMenuList,
|
NavigationMenuList,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from "@/components/ui/navigation-menu";
|
} from '@/components/ui/navigation-menu';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function NavigationMenu() {
|
export default function NavigationMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -16,7 +16,7 @@ export default function NavigationMenu() {
|
|||||||
<NavigationMenuList>
|
<NavigationMenuList>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link to="/">{t("titleHomePage")}</Link>
|
<Link to="/">{t('titleHomePage')}</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Moon } from "lucide-react";
|
import { Moon } from 'lucide-react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { toggleTheme } from "@/actions/theme";
|
import { toggleTheme } from '@/actions/theme';
|
||||||
|
|
||||||
export default function ToggleTheme() {
|
export default function ToggleTheme() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
@@ -8,10 +8,7 @@ const Avatar = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -23,7 +20,7 @@ const AvatarImage = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
className={cn('aspect-square h-full w-full', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -36,7 +33,7 @@ const AvatarFallback = React.forwardRef<
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+11
-15
@@ -1,35 +1,31 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
outline: "text-foreground",
|
outline: 'text-foreground',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
+43
-70
@@ -1,82 +1,55 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
ref={ref}
|
||||||
<div
|
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn(
|
/>
|
||||||
"bg-card text-card-foreground rounded-xl border shadow",
|
),
|
||||||
className,
|
);
|
||||||
)}
|
Card.displayName = 'Card';
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Card.displayName = "Card";
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
),
|
||||||
<div
|
);
|
||||||
ref={ref}
|
CardHeader.displayName = 'CardHeader';
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardHeader.displayName = "CardHeader";
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
HTMLParagraphElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
<h3
|
||||||
>(({ className, ...props }, ref) => (
|
ref={ref}
|
||||||
<h3
|
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
/>
|
||||||
{...props}
|
),
|
||||||
/>
|
);
|
||||||
));
|
CardTitle.displayName = 'CardTitle';
|
||||||
CardTitle.displayName = "CardTitle";
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CardDescription.displayName = "CardDescription";
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
),
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
);
|
||||||
));
|
CardContent.displayName = 'CardContent';
|
||||||
CardContent.displayName = "CardContent";
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
),
|
||||||
<div
|
);
|
||||||
ref={ref}
|
CardFooter.displayName = 'CardFooter';
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardFooter.displayName = "CardFooter";
|
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
@@ -19,8 +19,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
'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",
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -29,8 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
@@ -39,14 +38,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
DropdownMenuPrimitive.SubContent.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
@@ -57,8 +55,8 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
'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",
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -76,8 +74,8 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
'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",
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -92,7 +90,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -106,8 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
));
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
@@ -116,7 +113,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -139,11 +136,7 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -155,24 +148,18 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const labelVariants = cva(
|
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<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
Label.displayName = LabelPrimitive.Root.displayName;
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
));
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
SelectPrimitive.ScrollDownButton.displayName;
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
'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" &&
|
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",
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
'p-1',
|
||||||
position === "popper" &&
|
position === 'popper' &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
className={cn(
|
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>
|
</SwitchPrimitive.Root>
|
||||||
|
|||||||
+15
-16
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { X } from "lucide-react";
|
import { X } from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider;
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -22,25 +22,24 @@ const ToastViewport = React.forwardRef<
|
|||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border bg-background text-foreground",
|
default: 'border bg-background text-foreground',
|
||||||
destructive:
|
destructive:
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
VariantProps<typeof toastVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => {
|
>(({ className, variant, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<ToastPrimitives.Root
|
<ToastPrimitives.Root
|
||||||
@@ -59,7 +58,7 @@ const ToastAction = React.forwardRef<
|
|||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -74,7 +73,7 @@ const ToastClose = React.forwardRef<
|
|||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
@@ -91,7 +90,7 @@ const ToastTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Title
|
<ToastPrimitives.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -103,7 +102,7 @@ const ToastDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm opacity-90", className)}
|
className={cn('text-sm opacity-90', className)}
|
||||||
{...props}
|
{...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 * as React from 'react';
|
||||||
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast";
|
import { Toast, ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||||
|
|
||||||
const TOAST_LIMIT = 1;
|
const TOAST_LIMIT = 1;
|
||||||
const TOAST_REMOVE_DELAY = 1000000;
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
@@ -12,10 +12,10 @@ type ToasterToast = ToastProps & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
ADD_TOAST: "ADD_TOAST",
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -29,20 +29,20 @@ type ActionType = typeof actionTypes;
|
|||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"];
|
type: ActionType['ADD_TOAST'];
|
||||||
toast: ToasterToast;
|
toast: ToasterToast;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"];
|
type: ActionType['UPDATE_TOAST'];
|
||||||
toast: Partial<ToasterToast>;
|
toast: Partial<ToasterToast>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"];
|
type: ActionType['DISMISS_TOAST'];
|
||||||
toastId?: ToasterToast["id"];
|
toastId?: ToasterToast['id'];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"];
|
type: ActionType['REMOVE_TOAST'];
|
||||||
toastId?: ToasterToast["id"];
|
toastId?: ToasterToast['id'];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -59,7 +59,7 @@ const addToRemoveQueue = (toastId: string) => {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId);
|
toastTimeouts.delete(toastId);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: 'REMOVE_TOAST',
|
||||||
toastId,
|
toastId,
|
||||||
});
|
});
|
||||||
}, TOAST_REMOVE_DELAY);
|
}, TOAST_REMOVE_DELAY);
|
||||||
@@ -69,21 +69,19 @@ const addToRemoveQueue = (toastId: string) => {
|
|||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ADD_TOAST":
|
case 'ADD_TOAST':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
};
|
};
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case 'UPDATE_TOAST':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case 'DISMISS_TOAST': {
|
||||||
const { toastId } = action;
|
const { toastId } = action;
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() 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) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -133,20 +131,20 @@ function dispatch(action: Action) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">;
|
type Toast = Omit<ToasterToast, 'id'>;
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ ...props }: Toast) {
|
||||||
const id = genId();
|
const id = genId();
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: 'UPDATE_TOAST',
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
});
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: 'ADD_TOAST',
|
||||||
toast: {
|
toast: {
|
||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
@@ -180,7 +178,7 @@ function useToast() {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
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 { ipc } from '@/ipc/manager';
|
||||||
import { AppConfig } from '@/types/config';
|
import { AppConfig } from '@/types/config';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
export function useAppConfig() {
|
export function useAppConfig() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -20,28 +25,105 @@ export function useAppConfig() {
|
|||||||
const updateConfig = useMutation({
|
const updateConfig = useMutation({
|
||||||
mutationFn: async (newConfig: AppConfig) => {
|
mutationFn: async (newConfig: AppConfig) => {
|
||||||
await ipc.client.config.save(newConfig);
|
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({
|
toast({
|
||||||
title: 'Settings saved',
|
title: 'Settings saved',
|
||||||
description: 'Your configuration has been updated.',
|
description: 'Your configuration has been updated.',
|
||||||
});
|
});
|
||||||
},
|
for (const item of pendingBatch) {
|
||||||
onError: (err) => {
|
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({
|
toast({
|
||||||
title: 'Error saving settings',
|
title: 'Error saving settings',
|
||||||
description: err.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
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 {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
saveConfig: updateConfig.mutateAsync,
|
saveConfig,
|
||||||
isSaving: updateConfig.isPending,
|
isSaving: updateConfig.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: refreshAccountQuota,
|
mutationFn: refreshAccountQuota,
|
||||||
onSuccess: (updatedAccount: CloudAccount) => {
|
onSuccess: (updatedAccount: CloudAccount) => {
|
||||||
// Optimistically update the list or just invalidate?
|
// Optimistically update
|
||||||
// Invalidating is safer as sorting might change (last_used)
|
|
||||||
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
|
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
|
||||||
if (!oldData) return [updatedAccount];
|
if (!oldData) return [updatedAccount];
|
||||||
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
|
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() {
|
export function useForcePollCloudMonitor() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: forcePollCloudMonitor,
|
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 { z } from 'zod';
|
||||||
import { os } from "@orpc/server";
|
import { os } from '@orpc/server';
|
||||||
import {
|
import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
|
||||||
listAccountsData,
|
import { AccountSchema } from '../../types/account';
|
||||||
addAccountSnapshot,
|
|
||||||
switchAccount,
|
|
||||||
deleteAccount,
|
|
||||||
} from "./handler";
|
|
||||||
import { AccountSchema } from "../../types/account";
|
|
||||||
|
|
||||||
export const accountRouter = os.router({
|
export const accountRouter = os.router({
|
||||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { os } from "@orpc/server";
|
import { os } from '@orpc/server';
|
||||||
import { app } from "electron";
|
import { app } from 'electron';
|
||||||
|
|
||||||
export const currentPlatfom = os.handler(() => {
|
export const currentPlatfom = os.handler(() => {
|
||||||
return process.platform;
|
return process.platform;
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export class AuthServer {
|
|||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
logger.info('AuthServer: Received authorization code');
|
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
||||||
|
|
||||||
// Send code to renderer
|
// Send code to renderer
|
||||||
if (ipcContext.mainWindow) {
|
if (ipcContext.mainWindow) {
|
||||||
|
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||||
|
logger.info('AuthServer: Code sent successfully');
|
||||||
} else {
|
} else {
|
||||||
logger.error('AuthServer: Main window not found, cannot send code');
|
logger.error('AuthServer: Main window not found, cannot send code');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||||
account.quota = quota;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, 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);
|
notifyTrayUpdate(account);
|
||||||
return account;
|
return account;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -143,6 +145,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||||
account.quota = quota;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, 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;
|
return account;
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { AppConfig } from '../../types/config';
|
import { AppConfig } from '../../types/config';
|
||||||
import { ConfigManager } from './manager';
|
import { ConfigManager } from './manager';
|
||||||
|
import { syncAutoStart } from '../../utils/autoStart';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
return ConfigManager.loadConfig();
|
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 notify proxy server if configuration changes (hot update)
|
||||||
// Logic to update Tray if language changes
|
// Logic to update Tray if language changes
|
||||||
// For now just save
|
// For now just save
|
||||||
ConfigManager.saveConfig(config);
|
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||||
|
await ConfigManager.saveConfig(config);
|
||||||
|
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||||
|
if (previous.auto_startup !== config.auto_startup) {
|
||||||
|
syncAutoStart(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+29
-12
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
|
|||||||
const CONFIG_FILENAME = 'gui_config.json';
|
const CONFIG_FILENAME = 'gui_config.json';
|
||||||
|
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
|
private static cachedConfig: AppConfig | null = null;
|
||||||
|
private static saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
private static getConfigPath(): string {
|
private static getConfigPath(): string {
|
||||||
const appDataDir = getAppDataDir();
|
const appDataDir = getAppDataDir();
|
||||||
if (!fs.existsSync(appDataDir)) {
|
if (!fs.existsSync(appDataDir)) {
|
||||||
@@ -20,6 +23,7 @@ export class ConfigManager {
|
|||||||
const configPath = this.getConfigPath();
|
const configPath = this.getConfigPath();
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||||
|
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||||
return DEFAULT_APP_CONFIG;
|
return DEFAULT_APP_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ export class ConfigManager {
|
|||||||
|
|
||||||
// Merge with default to ensure new fields are present
|
// Merge with default to ensure new fields are present
|
||||||
// Zod parse helps validate
|
// Zod parse helps validate
|
||||||
const merged = {
|
const merged: AppConfig = {
|
||||||
...DEFAULT_APP_CONFIG,
|
...DEFAULT_APP_CONFIG,
|
||||||
...raw,
|
...raw,
|
||||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||||
@@ -45,22 +49,35 @@ export class ConfigManager {
|
|||||||
// Handle Anthropic Mapping Map vs Object
|
// Handle Anthropic Mapping Map vs Object
|
||||||
// In JSON it's object
|
// In JSON it's object
|
||||||
|
|
||||||
return merged as AppConfig;
|
this.cachedConfig = merged;
|
||||||
|
return merged;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Config: Failed to load config', e);
|
logger.error('Config: Failed to load config', e);
|
||||||
|
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||||
return DEFAULT_APP_CONFIG;
|
return DEFAULT_APP_CONFIG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static saveConfig(config: AppConfig): void {
|
static getCachedConfig(): AppConfig | null {
|
||||||
try {
|
return this.cachedConfig;
|
||||||
const configPath = this.getConfigPath();
|
}
|
||||||
const content = JSON.stringify(config, null, 2);
|
|
||||||
fs.writeFileSync(configPath, content, 'utf-8');
|
static async saveConfig(config: AppConfig): Promise<void> {
|
||||||
logger.info(`Config: Saved to ${configPath}`);
|
const configPath = this.getConfigPath();
|
||||||
} catch (e) {
|
const content = JSON.stringify(config, null, 2);
|
||||||
logger.error('Config: Failed to save config', e);
|
|
||||||
throw e;
|
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)
|
.input(AppConfigSchema)
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
.handler(async ({ input }) => {
|
.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 { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
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 { ProtobufUtils } from '../../utils/protobuf';
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
let db: Database.Database | null = null;
|
let db: Database.Database | null = null;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Create accounts table
|
// Create accounts table
|
||||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
// Storing complex objects (token, quota) as JSON strings for simplicity
|
||||||
@@ -73,9 +74,70 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
function getDb(): Database.Database {
|
function getDb(): Database.Database {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
ensureDatabaseInitialized(dbPath);
|
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 {
|
export class CloudAccountRepo {
|
||||||
static async init(): Promise<void> {
|
static async init(): Promise<void> {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
@@ -168,6 +230,7 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
static async getAccounts(): Promise<CloudAccount[]> {
|
static async getAccounts(): Promise<CloudAccount[]> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const migrationStats = createMigrationStats();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
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})`));
|
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts: CloudAccount[] = [];
|
||||||
rows.map(async (row) => ({
|
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,
|
id: row.id,
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
token: JSON.parse(await decrypt(row.token_json)),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
last_used: row.last_used,
|
last_used: row.last_used,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
is_active: Boolean(row.is_active),
|
is_active: Boolean(row.is_active),
|
||||||
})),
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
return accounts;
|
return accounts;
|
||||||
} finally {
|
} 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();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,14 +341,21 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
if (!row) return undefined;
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
token: JSON.parse(await decrypt(row.token_json)),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
last_used: row.last_used,
|
last_used: row.last_used,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -293,20 +430,14 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
static injectCloudToken(account: CloudAccount): void {
|
static injectCloudToken(account: CloudAccount): void {
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
let dbPath: string | null = null;
|
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
||||||
|
|
||||||
for (const p of dbPaths) {
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
dbPath = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
try {
|
try {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||||
@@ -437,14 +568,11 @@ export class CloudAccountRepo {
|
|||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||||
|
|
||||||
let dbPath: string | null = null;
|
const dbPath =
|
||||||
for (const p of dbPaths) {
|
dbPaths.find((p) => {
|
||||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||||
if (fs.existsSync(p)) {
|
return fs.existsSync(p);
|
||||||
dbPath = p;
|
}) ?? null;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||||
@@ -454,6 +582,7 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||||
const ideDb = new Database(dbPath, { readonly: true });
|
const ideDb = new Database(dbPath, { readonly: true });
|
||||||
|
ideDb.pragma('journal_mode = WAL');
|
||||||
try {
|
try {
|
||||||
// 1. Read Raw Token Data
|
// 1. Read Raw Token Data
|
||||||
const row = ideDb
|
const row = ideDb
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ import path from 'path';
|
|||||||
|
|
||||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
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.
|
* Ensures that the database file exists.
|
||||||
* @param dbPath {string} The path to the database file.
|
* @param dbPath {string} The path to the database file.
|
||||||
@@ -59,7 +84,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
ensureDatabaseExists(targetPath);
|
ensureDatabaseExists(targetPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { code?: string };
|
const err = error as { code?: string };
|
||||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
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.
|
* Gets the current account info.
|
||||||
* @returns {AccountInfo} The current account info.
|
* @returns {AccountInfo} The current account info.
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
import { os } from "@orpc/server";
|
import { os } from '@orpc/server';
|
||||||
import {
|
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||||
backupAccount,
|
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||||
restoreAccount,
|
|
||||||
getCurrentAccountInfo,
|
|
||||||
} from "./handler";
|
|
||||||
import {
|
|
||||||
AccountBackupDataSchema,
|
|
||||||
AccountInfoSchema,
|
|
||||||
AccountSchema,
|
|
||||||
} from "../../types/account";
|
|
||||||
|
|
||||||
export const databaseRouter = os.router({
|
export const databaseRouter = os.router({
|
||||||
backupAccount: os
|
backupAccount: os
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const generateApiKey = async (): Promise<string> => {
|
|||||||
// Save to config
|
// Save to config
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
config.proxy.api_key = newKey;
|
config.proxy.api_key = newKey;
|
||||||
ConfigManager.saveConfig(config);
|
await ConfigManager.saveConfig(config);
|
||||||
|
|
||||||
return newKey;
|
return newKey;
|
||||||
};
|
};
|
||||||
+1
-1
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
|
|||||||
pendingRequests.delete(id);
|
pendingRequests.delete(id);
|
||||||
reject(new Error(`Request /${methodPath} timed out`));
|
reject(new Error(`Request /${methodPath} timed out`));
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+147
-40
@@ -1,63 +1,166 @@
|
|||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import findProcess, { ProcessInfo } from 'find-process';
|
||||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper process name patterns to exclude (Electron helper processes)
|
||||||
|
*/
|
||||||
|
const HELPER_PATTERNS = [
|
||||||
|
'helper',
|
||||||
|
'plugin',
|
||||||
|
'renderer',
|
||||||
|
'gpu',
|
||||||
|
'crashpad',
|
||||||
|
'utility',
|
||||||
|
'audio',
|
||||||
|
'sandbox',
|
||||||
|
'language_server',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a process is a helper/auxiliary process that should be excluded.
|
||||||
|
* @param name Process name (lowercase)
|
||||||
|
* @param cmd Process command line (lowercase)
|
||||||
|
* @returns True if the process is a helper process
|
||||||
|
*/
|
||||||
|
function isHelperProcess(name: string, cmd: string): boolean {
|
||||||
|
const nameLower = name.toLowerCase();
|
||||||
|
const cmdLower = cmd.toLowerCase();
|
||||||
|
|
||||||
|
// Check for --type= argument (Electron helper process indicator)
|
||||||
|
if (cmdLower.includes('--type=')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for helper patterns in process name
|
||||||
|
for (const pattern of HELPER_PATTERNS) {
|
||||||
|
if (nameLower.includes(pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for crashpad in path
|
||||||
|
if (cmdLower.includes('crashpad')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPgrepNoMatchError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
|
||||||
|
const code = (error as { code?: number }).code;
|
||||||
|
return hasPgrep && code === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the Antigravity process is running.
|
* Checks if the Antigravity process is running.
|
||||||
|
* Uses find-process package for robust cross-platform process detection.
|
||||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||||
*/
|
*/
|
||||||
export async function isProcessRunning(): Promise<boolean> {
|
export async function isProcessRunning(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
let command = '';
|
const currentPid = process.pid;
|
||||||
|
|
||||||
if (platform === 'win32' || isWsl()) {
|
// Use find-process to search for Antigravity processes
|
||||||
// Use absolute path for tasklist to be safe on Windows too
|
// 'name' search type matches process name
|
||||||
const cmd = isWsl()
|
const processMap = new Map<number, ProcessInfo>();
|
||||||
? '/mnt/c/Windows/System32/tasklist.exe'
|
const searchNames = ['Antigravity', 'antigravity'];
|
||||||
: process.env.SystemRoot // Usually C:\Windows
|
let sawNoMatch = false;
|
||||||
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
|
|
||||||
: 'tasklist';
|
|
||||||
|
|
||||||
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
|
for (const searchName of searchNames) {
|
||||||
} else {
|
try {
|
||||||
// macOS and Linux (native)
|
const matches = await findProcess('name', searchName, true);
|
||||||
command = 'pgrep -x Antigravity';
|
for (const proc of matches) {
|
||||||
|
if (typeof proc.pid === 'number') {
|
||||||
|
processMap.set(proc.pid, proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isPgrepNoMatchError(error)) {
|
||||||
|
sawNoMatch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Checking process with command: ${command}`);
|
const processes = Array.from(processMap.values());
|
||||||
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
|
if (processes.length === 0 && sawNoMatch) {
|
||||||
try {
|
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||||
const { stdout } = await execAsync(command, { timeout: 4000 });
|
}
|
||||||
logger.debug(`Process check stdout: ${stdout.trim()}`);
|
|
||||||
|
|
||||||
if (platform === 'win32' || isWsl()) {
|
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
|
||||||
return stdout.includes('Antigravity.exe');
|
|
||||||
} else {
|
for (const proc of processes) {
|
||||||
// pgrep returns PIDs if found, empty if not
|
// Skip self
|
||||||
return stdout.trim().length > 0;
|
if (proc.pid === currentPid) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch (cmdError) {
|
|
||||||
if (platform === 'win32' && !isWsl()) {
|
const name = proc.name?.toLowerCase() || '';
|
||||||
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
|
const cmd = proc.cmd?.toLowerCase() || '';
|
||||||
// Fallback to wmic (filtered)
|
|
||||||
try {
|
// Skip manager process
|
||||||
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
|
if (
|
||||||
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
|
name.includes('manager') ||
|
||||||
return stdout.includes('ProcessId');
|
cmd.includes('manager') ||
|
||||||
} catch (wmicError) {
|
cmd.includes('antigravity-manager')
|
||||||
logger.error('wmic fallback also failed', wmicError);
|
) {
|
||||||
return false;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip helper processes
|
||||||
|
if (isHelperProcess(name, cmd)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// macOS: Check for Antigravity.app in path
|
||||||
|
if (cmd.includes('antigravity.app')) {
|
||||||
|
logger.debug(
|
||||||
|
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check if the process name is exactly 'Antigravity' (main process)
|
||||||
|
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
|
||||||
|
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
// Windows: Check for Antigravity.exe
|
||||||
|
if (name === 'antigravity.exe' || name === 'antigravity') {
|
||||||
|
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linux: Check for antigravity in name or path (but not tools)
|
||||||
|
if (
|
||||||
|
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||||
|
!name.includes('tools')
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw cmdError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking process status:', error);
|
logger.error('Error checking process status with find-process:', error);
|
||||||
// pgrep returns exit code 1 if no process found
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,18 +292,22 @@ export async function closeAntigravity(): Promise<void> {
|
|||||||
if (p.pid === currentPid) {
|
if (p.pid === currentPid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Exclude this electron app (if named Antigravity Manager)
|
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
|
||||||
if (p.cmd.includes('Antigravity Manager')) {
|
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Match Antigravity
|
// Match Antigravity (but not manager)
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
return (
|
return (
|
||||||
p.cmd.includes('Antigravity.exe') ||
|
p.cmd.includes('Antigravity.exe') ||
|
||||||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity');
|
// Explicit !manager check for Linux/macOS to be defensive
|
||||||
|
return (
|
||||||
|
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
|
||||||
|
!p.cmd.includes('manager')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { os } from '@orpc/server';
|
import { os } from '@orpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { networkInterfaces } from 'os';
|
import { networkInterfaces } from 'os';
|
||||||
|
import { shell } from 'electron';
|
||||||
|
import { getAgentDir } from '../../utils/paths';
|
||||||
|
|
||||||
// Schema for IP info
|
// Schema for IP info
|
||||||
const IpInfoSchema = z.object({
|
const IpInfoSchema = z.object({
|
||||||
@@ -61,4 +63,10 @@ export const systemHandler = os.router({
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Open log directory in file explorer
|
||||||
|
openLogDirectory: os.output(z.void()).handler(async () => {
|
||||||
|
const logDir = getAgentDir();
|
||||||
|
await shell.openPath(logDir);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
+19
-21
@@ -1,6 +1,6 @@
|
|||||||
import { os } from "@orpc/server";
|
import { os } from '@orpc/server';
|
||||||
import { nativeTheme } from "electron";
|
import { nativeTheme } from 'electron';
|
||||||
import { setThemeModeInputSchema } from "./schemas";
|
import { setThemeModeInputSchema } from './schemas';
|
||||||
|
|
||||||
export const getCurrentThemeMode = os.handler(() => {
|
export const getCurrentThemeMode = os.handler(() => {
|
||||||
return nativeTheme.themeSource;
|
return nativeTheme.themeSource;
|
||||||
@@ -8,28 +8,26 @@ export const getCurrentThemeMode = os.handler(() => {
|
|||||||
|
|
||||||
export const toggleThemeMode = os.handler(() => {
|
export const toggleThemeMode = os.handler(() => {
|
||||||
if (nativeTheme.shouldUseDarkColors) {
|
if (nativeTheme.shouldUseDarkColors) {
|
||||||
nativeTheme.themeSource = "light";
|
nativeTheme.themeSource = 'light';
|
||||||
} else {
|
} else {
|
||||||
nativeTheme.themeSource = "dark";
|
nativeTheme.themeSource = 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
return nativeTheme.shouldUseDarkColors;
|
return nativeTheme.shouldUseDarkColors;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setThemeMode = os
|
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
|
||||||
.input(setThemeModeInputSchema)
|
switch (mode) {
|
||||||
.handler(({ input: mode }) => {
|
case 'light':
|
||||||
switch (mode) {
|
nativeTheme.themeSource = 'light';
|
||||||
case "light":
|
break;
|
||||||
nativeTheme.themeSource = "light";
|
case 'dark':
|
||||||
break;
|
nativeTheme.themeSource = 'dark';
|
||||||
case "dark":
|
break;
|
||||||
nativeTheme.themeSource = "dark";
|
case 'system':
|
||||||
break;
|
nativeTheme.themeSource = 'system';
|
||||||
case "system":
|
break;
|
||||||
nativeTheme.themeSource = "system";
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nativeTheme.themeSource;
|
return nativeTheme.themeSource;
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]);
|
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
|
||||||
+37
-1
@@ -38,12 +38,36 @@ function getQuotaText(account: CloudAccount | null, texts: any): string[] {
|
|||||||
|
|
||||||
export function initTray(mainWindow: BrowserWindow) {
|
export function initTray(mainWindow: BrowserWindow) {
|
||||||
globalMainWindow = mainWindow;
|
globalMainWindow = mainWindow;
|
||||||
|
|
||||||
|
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
|
||||||
|
if (tray) {
|
||||||
|
try {
|
||||||
|
tray.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to destroy existing tray', e);
|
||||||
|
}
|
||||||
|
tray = null;
|
||||||
|
logger.info('Destroyed existing tray before creating new one');
|
||||||
|
}
|
||||||
|
|
||||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
|
||||||
const iconPath = inDevelopment
|
const iconPath = inDevelopment
|
||||||
? path.join(process.cwd(), 'src/assets/tray.png')
|
? path.join(process.cwd(), 'src/assets/tray.png')
|
||||||
: path.join(process.resourcesPath, 'assets/tray.png');
|
: path.join(process.resourcesPath, 'assets', 'tray.png');
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
|
||||||
|
);
|
||||||
|
|
||||||
const icon = nativeImage.createFromPath(iconPath);
|
const icon = nativeImage.createFromPath(iconPath);
|
||||||
|
|
||||||
|
// Verify icon is valid before creating tray
|
||||||
|
if (icon.isEmpty()) {
|
||||||
|
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tray = new Tray(icon);
|
tray = new Tray(icon);
|
||||||
tray.setToolTip('Antigravity Manager');
|
tray.setToolTip('Antigravity Manager');
|
||||||
|
|
||||||
@@ -158,3 +182,15 @@ export function updateTrayMenu(account: CloudAccount | null, language?: string)
|
|||||||
export function setTrayLanguage(lang: string) {
|
export function setTrayLanguage(lang: string) {
|
||||||
updateTrayMenu(lastAccount, lang);
|
updateTrayMenu(lastAccount, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function destroyTray() {
|
||||||
|
if (tray) {
|
||||||
|
try {
|
||||||
|
tray.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to destroy tray', e);
|
||||||
|
}
|
||||||
|
tray = null;
|
||||||
|
logger.info('Tray destroyed');
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-27
@@ -1,33 +1,26 @@
|
|||||||
import { os } from "@orpc/server";
|
import { os } from '@orpc/server';
|
||||||
import { ipcContext } from "../context";
|
import { ipcContext } from '../context';
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export const minimizeWindow = os
|
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||||
.use(ipcContext.mainWindowContext)
|
const { window } = context;
|
||||||
.handler(({ context }) => {
|
|
||||||
const { window } = context;
|
|
||||||
|
|
||||||
window.minimize();
|
window.minimize();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const maximizeWindow = os
|
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||||
.use(ipcContext.mainWindowContext)
|
const { window } = context;
|
||||||
.handler(({ context }) => {
|
|
||||||
const { window } = context;
|
|
||||||
|
|
||||||
if (window.isMaximized()) {
|
if (window.isMaximized()) {
|
||||||
window.unmaximize();
|
window.unmaximize();
|
||||||
} else {
|
} else {
|
||||||
window.maximize();
|
window.maximize();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const closeWindow = os
|
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||||
.use(ipcContext.mainWindowContext)
|
logger.info('IPC: closeWindow called');
|
||||||
.handler(({ context }) => {
|
const { window } = context;
|
||||||
logger.info('IPC: closeWindow called');
|
|
||||||
const { window } = context;
|
|
||||||
|
|
||||||
window.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
+107
-27
@@ -1,14 +1,34 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { StatusBar } from '@/components/StatusBar';
|
import { StatusBar } from '@/components/StatusBar';
|
||||||
import { LayoutDashboard, Settings, Network, Rocket } from 'lucide-react';
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings,
|
||||||
|
Network,
|
||||||
|
Rocket,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
export const MainLayout: React.FC = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Initialize state from localStorage if available, default to false (expanded)
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('sidebar-collapsed');
|
||||||
|
return saved ? JSON.parse(saved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist state changes
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
to: '/',
|
to: '/',
|
||||||
@@ -31,42 +51,102 @@ export const MainLayout: React.FC = () => {
|
|||||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="bg-muted/10 flex w-64 flex-col border-r">
|
<aside
|
||||||
<div className="p-6">
|
className={cn(
|
||||||
<h1 className="flex items-center gap-2 text-xl font-bold tracking-tight">
|
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out',
|
||||||
<div className="bg-primary text-primary-foreground flex h-6 w-6 items-center justify-center rounded">
|
isCollapsed ? 'w-[70px]' : 'w-64',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
<div className="bg-primary text-primary-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded">
|
||||||
<Rocket className="h-4 w-4" />
|
<Rocket className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
Antigravity
|
<div
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">Manager</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 space-y-2 px-4">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'overflow-hidden transition-all duration-300',
|
||||||
location.pathname === item.to
|
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<h1 className="text-xl font-bold tracking-tight">Antigravity</h1>
|
||||||
{item.label}
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground mt-1 overflow-hidden text-xs whitespace-nowrap transition-all duration-300',
|
||||||
|
isCollapsed ? 'h-0 opacity-0' : 'h-auto opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Manager
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-2 px-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.to;
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.to} delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span className="sr-only">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TooltipProvider>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-2">
|
||||||
<StatusBar />
|
<StatusBar isCollapsed={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto transition-all duration-300">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function transformClaudeRequestIn(
|
|||||||
const toolIdToName = new Map<string, string>();
|
const toolIdToName = new Map<string, string>();
|
||||||
|
|
||||||
// 1. System Instruction
|
// 1. System Instruction
|
||||||
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
const systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
||||||
|
|
||||||
// Map model name
|
// Map model name
|
||||||
const mappedModel = hasWebSearchTool
|
const mappedModel = hasWebSearchTool
|
||||||
@@ -100,7 +100,7 @@ export function transformClaudeRequestIn(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 3. Tools
|
// 3. Tools
|
||||||
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||||
|
|
||||||
// 5. Safety Settings
|
// 5. Safety Settings
|
||||||
const safetySettings = [
|
const safetySettings = [
|
||||||
@@ -152,7 +152,7 @@ export function transformClaudeRequestIn(
|
|||||||
delete innerRequest.systemInstruction;
|
delete innerRequest.systemInstruction;
|
||||||
|
|
||||||
// 3. Clean generationConfig
|
// 3. Clean generationConfig
|
||||||
let genConfig = innerRequest.generationConfig || {};
|
const genConfig = innerRequest.generationConfig || {};
|
||||||
delete genConfig.thinkingConfig;
|
delete genConfig.thinkingConfig;
|
||||||
delete genConfig.responseMimeType;
|
delete genConfig.responseMimeType;
|
||||||
delete genConfig.responseModalities;
|
delete genConfig.responseModalities;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
|
|||||||
|
|
||||||
// Recursively process all children
|
// Recursively process all children
|
||||||
for (const k in map) {
|
for (const k in map) {
|
||||||
if (map.hasOwnProperty(k)) {
|
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
||||||
const v = map[k];
|
const v = map[k];
|
||||||
if (typeof v === 'object' && v !== null) {
|
if (typeof v === 'object' && v !== null) {
|
||||||
flattenRefs(v, defs);
|
flattenRefs(v, defs);
|
||||||
@@ -91,7 +91,7 @@ function cleanJsonSchemaRecursive(value: any) {
|
|||||||
|
|
||||||
// 1. Recursively process all children first to ensure nested structures are cleaned
|
// 1. Recursively process all children first to ensure nested structures are cleaned
|
||||||
for (const k in map) {
|
for (const k in map) {
|
||||||
if (map.hasOwnProperty(k)) {
|
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
||||||
cleanJsonSchemaRecursive(map[k]);
|
cleanJsonSchemaRecursive(map[k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
+391
-1
@@ -12,7 +12,7 @@ i18n
|
|||||||
caches: ['localStorage'],
|
caches: ['localStorage'],
|
||||||
lookupLocalStorage: 'lang',
|
lookupLocalStorage: 'lang',
|
||||||
},
|
},
|
||||||
supportedLngs: ['en', 'zh-CN'],
|
supportedLngs: ['en', 'zh-CN', 'ru'],
|
||||||
load: 'currentOnly', // Only load the exact language code, not variants
|
load: 'currentOnly', // Only load the exact language code, not variants
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
@@ -30,9 +30,23 @@ i18n
|
|||||||
deleteBackup: 'Delete Backup',
|
deleteBackup: 'Delete Backup',
|
||||||
backupCurrent: 'Backup Current',
|
backupCurrent: 'Backup Current',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
openLogs: 'Open Log Directory',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
generic: 'An unexpected error occurred.',
|
generic: 'An unexpected error occurred.',
|
||||||
|
keychainUnavailable: 'Keychain is unavailable.',
|
||||||
|
keychainHint: {
|
||||||
|
translocation:
|
||||||
|
'Detected macOS App Translocation. Move the app to /Applications and reopen.',
|
||||||
|
keychainDenied:
|
||||||
|
'Keychain access denied. The app may be unsigned; see README for the self-signing workaround.',
|
||||||
|
signNotarize: 'Please use a signed and notarized build when available.',
|
||||||
|
},
|
||||||
|
dataMigrationFailed: 'Unable to decrypt legacy account data.',
|
||||||
|
dataMigrationHint: {
|
||||||
|
relogin: 'Please re-login or re-add your accounts.',
|
||||||
|
clearData: 'If the issue persists, clear local account data and sign in again.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
accounts: 'Accounts',
|
accounts: 'Accounts',
|
||||||
@@ -69,6 +83,7 @@ i18n
|
|||||||
description: 'Select your preferred language.',
|
description: 'Select your preferred language.',
|
||||||
english: 'English',
|
english: 'English',
|
||||||
chinese: 'Chinese (Simplified)',
|
chinese: 'Chinese (Simplified)',
|
||||||
|
russian: 'Russian',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: 'About',
|
title: 'About',
|
||||||
@@ -77,6 +92,7 @@ i18n
|
|||||||
version: 'Version',
|
version: 'Version',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
license: 'License',
|
license: 'License',
|
||||||
|
openLogDir: 'Open',
|
||||||
account: {
|
account: {
|
||||||
title: 'Account Settings',
|
title: 'Account Settings',
|
||||||
description: 'Configure automatic account refresh and sync.',
|
description: 'Configure automatic account refresh and sync.',
|
||||||
@@ -85,6 +101,23 @@ i18n
|
|||||||
auto_sync: 'Auto Sync Current Account',
|
auto_sync: 'Auto Sync Current Account',
|
||||||
auto_sync_desc: 'Periodically sync active account information',
|
auto_sync_desc: 'Periodically sync active account information',
|
||||||
},
|
},
|
||||||
|
startup: {
|
||||||
|
title: 'Startup',
|
||||||
|
description: 'Control application launch behavior at system startup.',
|
||||||
|
auto_startup: 'Start with system',
|
||||||
|
auto_startup_desc: 'Launch at sign-in and keep the app in the system tray',
|
||||||
|
macos_hint:
|
||||||
|
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Privacy',
|
||||||
|
description: 'Control how your data is used to improve the application.',
|
||||||
|
error_reporting: 'Error Reporting',
|
||||||
|
error_reporting_desc:
|
||||||
|
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||||
|
restart_note:
|
||||||
|
'Changes to error reporting will take effect after restarting the application.',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Upstream Proxy',
|
title: 'Upstream Proxy',
|
||||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||||
@@ -212,6 +245,9 @@ i18n
|
|||||||
list: {
|
list: {
|
||||||
noAccounts: 'No cloud accounts added yet.',
|
noAccounts: 'No cloud accounts added yet.',
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: 'Failed to load cloud accounts.',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
syncSuccess: {
|
syncSuccess: {
|
||||||
title: 'Sync Successful',
|
title: 'Sync Successful',
|
||||||
@@ -227,6 +263,7 @@ i18n
|
|||||||
},
|
},
|
||||||
quotaRefreshed: 'Quota refreshed',
|
quotaRefreshed: 'Quota refreshed',
|
||||||
refreshFailed: 'Failed to refresh quota',
|
refreshFailed: 'Failed to refresh quota',
|
||||||
|
pollFailed: 'Failed to poll quota for all accounts',
|
||||||
switched: {
|
switched: {
|
||||||
title: 'Account switched!',
|
title: 'Account switched!',
|
||||||
description: 'Restarting Antigravity...',
|
description: 'Restarting Antigravity...',
|
||||||
@@ -275,6 +312,10 @@ i18n
|
|||||||
api_key: 'API Key',
|
api_key: 'API Key',
|
||||||
auto_start: 'Auto Start with App',
|
auto_start: 'Auto Start with App',
|
||||||
auto_start_desc: 'Start proxy service when application launches',
|
auto_start_desc: 'Start proxy service when application launches',
|
||||||
|
local_access: 'Local network access:',
|
||||||
|
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
|
||||||
|
show_key: 'Show',
|
||||||
|
hide_key: 'Hide',
|
||||||
},
|
},
|
||||||
mapping: {
|
mapping: {
|
||||||
title: 'Model Mapping',
|
title: 'Model Mapping',
|
||||||
@@ -304,9 +345,23 @@ i18n
|
|||||||
deleteBackup: '删除备份',
|
deleteBackup: '删除备份',
|
||||||
backupCurrent: '备份当前账号',
|
backupCurrent: '备份当前账号',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
|
openLogs: '打开日志目录',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
generic: '发生未知错误。',
|
generic: '发生未知错误。',
|
||||||
|
keychainUnavailable: 'Keychain 不可用。',
|
||||||
|
keychainHint: {
|
||||||
|
translocation:
|
||||||
|
'检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
|
||||||
|
keychainDenied:
|
||||||
|
'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
|
||||||
|
signNotarize: '请在可用时使用已签名并公证的版本。',
|
||||||
|
},
|
||||||
|
dataMigrationFailed: '旧版账号数据无法解密。',
|
||||||
|
dataMigrationHint: {
|
||||||
|
relogin: '请重新登录或重新添加账号。',
|
||||||
|
clearData: '如仍失败,请清理本地账号数据后再登录。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
accounts: '账号',
|
accounts: '账号',
|
||||||
@@ -343,6 +398,7 @@ i18n
|
|||||||
description: '选择您的首选语言。',
|
description: '选择您的首选语言。',
|
||||||
english: '英语',
|
english: '英语',
|
||||||
chinese: '中文 (简体)',
|
chinese: '中文 (简体)',
|
||||||
|
russian: '俄语',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -351,6 +407,7 @@ i18n
|
|||||||
version: '版本',
|
version: '版本',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
license: '许可证',
|
license: '许可证',
|
||||||
|
openLogDir: '打开',
|
||||||
account: {
|
account: {
|
||||||
title: '账号设置',
|
title: '账号设置',
|
||||||
description: '配置自动刷新和同步账号。',
|
description: '配置自动刷新和同步账号。',
|
||||||
@@ -359,6 +416,22 @@ i18n
|
|||||||
auto_sync: '自动获取当前账号',
|
auto_sync: '自动获取当前账号',
|
||||||
auto_sync_desc: '定期自动同步当前活跃账号信息',
|
auto_sync_desc: '定期自动同步当前活跃账号信息',
|
||||||
},
|
},
|
||||||
|
startup: {
|
||||||
|
title: '启动',
|
||||||
|
description: '控制系统启动时的应用行为。',
|
||||||
|
auto_startup: '随系统启动',
|
||||||
|
auto_startup_desc: '登录时自动启动并保持在托盘里',
|
||||||
|
macos_hint:
|
||||||
|
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: '隐私',
|
||||||
|
description: '控制你的数据如何用于改进应用。',
|
||||||
|
error_reporting: '错误上报',
|
||||||
|
error_reporting_desc:
|
||||||
|
'发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||||
|
restart_note: '错误上报设置会在重启应用后生效。',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: '上游代理',
|
title: '上游代理',
|
||||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||||
@@ -484,6 +557,9 @@ i18n
|
|||||||
list: {
|
list: {
|
||||||
noAccounts: '暂无云账号。',
|
noAccounts: '暂无云账号。',
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: '加载云账号失败。',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
syncSuccess: {
|
syncSuccess: {
|
||||||
title: '同步成功',
|
title: '同步成功',
|
||||||
@@ -499,6 +575,7 @@ i18n
|
|||||||
},
|
},
|
||||||
quotaRefreshed: '配额已刷新',
|
quotaRefreshed: '配额已刷新',
|
||||||
refreshFailed: '刷新配额失败',
|
refreshFailed: '刷新配额失败',
|
||||||
|
pollFailed: '轮询所有账号配额失败',
|
||||||
switched: {
|
switched: {
|
||||||
title: '账号已切换!',
|
title: '账号已切换!',
|
||||||
description: '正在重启 Antigravity...',
|
description: '正在重启 Antigravity...',
|
||||||
@@ -564,5 +641,318 @@ i18n
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ru: {
|
||||||
|
translation: {
|
||||||
|
appName: 'Antigravity Manager',
|
||||||
|
status: {
|
||||||
|
checking: 'Проверка статуса...',
|
||||||
|
running: 'Antigravity работает в фоне',
|
||||||
|
stopped: 'Служба Antigravity остановлена',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
stop: 'Стоп',
|
||||||
|
start: 'Старт',
|
||||||
|
switch: 'Переключить',
|
||||||
|
deleteBackup: 'Удалить резервную копию',
|
||||||
|
backupCurrent: 'Создать бэкап текущего',
|
||||||
|
retry: 'Повторить',
|
||||||
|
openLogs: 'Открыть папку логов',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
generic: 'Произошла непредвиденная ошибка.',
|
||||||
|
keychainUnavailable: 'Keychain недоступен.',
|
||||||
|
keychainHint: {
|
||||||
|
translocation:
|
||||||
|
'Обнаружено App Translocation (macOS). Переместите приложение в /Applications и откройте заново.',
|
||||||
|
keychainDenied:
|
||||||
|
'Доступ к Keychain запрещен. Возможно, приложение не подписано. См. README для инструкции.',
|
||||||
|
signNotarize: 'Пожалуйста, используйте подписанную версию приложения.',
|
||||||
|
},
|
||||||
|
dataMigrationFailed: 'Не удалось расшифровать данные старого аккаунта.',
|
||||||
|
dataMigrationHint: {
|
||||||
|
relogin: 'Пожалуйста, перезайдите в аккаунт или добавьте его заново.',
|
||||||
|
clearData: 'Если ошибка сохраняется, очистите локальные данные и войдите снова.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
accounts: 'Аккаунты',
|
||||||
|
proxy: 'API Прокси',
|
||||||
|
settings: 'Настройки',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
current: 'Текущий',
|
||||||
|
lastUsed: 'Последнее использование {{time}}',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
title: 'Аккаунты',
|
||||||
|
description: 'Управление вашими аккаунтами Antigravity Google Gemini.',
|
||||||
|
noBackups: {
|
||||||
|
title: 'Бэкапы не найдены',
|
||||||
|
description: 'Создайте резервную копию вашего текущего аккаунта Antigravity, чтобы начать.',
|
||||||
|
action: 'Создать бэкап текущего аккаунта',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Настройки',
|
||||||
|
description: 'Управление настройками приложения.',
|
||||||
|
general: 'Общие',
|
||||||
|
connection: 'Подключение',
|
||||||
|
models: 'Модели',
|
||||||
|
appearance: {
|
||||||
|
title: 'Внешний вид',
|
||||||
|
description: 'Настройте внешний вид Antigravity Manager.',
|
||||||
|
},
|
||||||
|
darkMode: 'Темная тема',
|
||||||
|
darkModeDescription: 'Включить темную тему для комфортной работы ночью.',
|
||||||
|
language: {
|
||||||
|
title: 'Язык',
|
||||||
|
description: 'Выберите язык интерфейса.',
|
||||||
|
english: 'English',
|
||||||
|
chinese: '中文 (简体)',
|
||||||
|
russian: 'Русский',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: 'О программе',
|
||||||
|
description: 'Информация о приложении.',
|
||||||
|
},
|
||||||
|
version: 'Версия',
|
||||||
|
platform: 'Платформа',
|
||||||
|
license: 'Лицензия',
|
||||||
|
openLogDir: 'Открыть',
|
||||||
|
account: {
|
||||||
|
title: 'Настройки аккаунта',
|
||||||
|
description: 'Настройка автоматического обновления и синхронизации.',
|
||||||
|
auto_refresh: 'Автообновление квот',
|
||||||
|
auto_refresh_desc: 'Периодически обновлять информацию о квотах для всех аккаунтов',
|
||||||
|
auto_sync: 'Автосинхронизация текущего',
|
||||||
|
auto_sync_desc: 'Периодически синхронизировать активный аккаунт',
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
title: 'Автозагрузка',
|
||||||
|
description: 'Управление запуском приложения при старте системы.',
|
||||||
|
auto_startup: 'Запускать вместе с системой',
|
||||||
|
auto_startup_desc: 'Запускать при входе и сворачивать в трей',
|
||||||
|
macos_hint:
|
||||||
|
'macOS требует подписанное приложение для работы элементов входа. Если автозапуск не работает, подпишите приложение или включите его вручную в системных настройках.',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Конфиденциальность',
|
||||||
|
description: 'Управление использованием данных для улучшения приложения.',
|
||||||
|
error_reporting: 'Отчеты об ошибках',
|
||||||
|
error_reporting_desc:
|
||||||
|
'Отправляйте анонимные отчеты об ошибках, чтобы помочь нам улучшить приложение. Личные данные не собираются.',
|
||||||
|
restart_note: 'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'Верхнеуровневый прокси',
|
||||||
|
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||||
|
enable: 'Включить прокси',
|
||||||
|
url: 'URL прокси',
|
||||||
|
timeout: 'Тайм-аут запроса (сек)',
|
||||||
|
},
|
||||||
|
modelMapping: {
|
||||||
|
title: 'Маппинг моделей',
|
||||||
|
description:
|
||||||
|
'Сопоставьте модели Claude Code с моделями Antigravity. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
|
||||||
|
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||||
|
targetGemini: 'Целевая модель Gemini',
|
||||||
|
addPlaceholderKey: 'например, op-3',
|
||||||
|
addPlaceholderValue: 'например, gemini-2.0-flash-exp',
|
||||||
|
noMappings: 'Нет пользовательских сопоставлений.',
|
||||||
|
mapsTo: 'Мапится в',
|
||||||
|
default: 'По умолчанию',
|
||||||
|
restoreDefaults: 'Сбросить по умолчанию',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Примеры использования',
|
||||||
|
description: 'Примеры команд для вызова локального API прокси.',
|
||||||
|
curl: 'cURL',
|
||||||
|
python: 'Python',
|
||||||
|
copy: 'Копировать',
|
||||||
|
copied: 'Скопировано!',
|
||||||
|
openai_protocol: 'Протокол OpenAI',
|
||||||
|
anthropic_protocol: 'Протокол Anthropic',
|
||||||
|
openai_tools: 'Cursor, Windsurf, NextChat',
|
||||||
|
anthropic_tools: 'Claude Code CLI',
|
||||||
|
flash: 'Быстрый (Flash)',
|
||||||
|
pro: 'Про (Pro)',
|
||||||
|
flash_preview: 'Превью (Flash Preview)',
|
||||||
|
pro_high: 'Лучший (Pro High)',
|
||||||
|
sonnet: 'Мыслящий (Sonnet)',
|
||||||
|
opus: 'Opus',
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
title: 'Служба API Прокси',
|
||||||
|
description: 'Управление локальным прокси-сервером API.',
|
||||||
|
status_running: 'Запущено',
|
||||||
|
status_stopped: 'Остановлено',
|
||||||
|
action_start: 'Запустить службу',
|
||||||
|
action_stop: 'Остановить службу',
|
||||||
|
accounts_info: '{{count}} аккаунтов доступно',
|
||||||
|
port: 'Порт прослушивания',
|
||||||
|
port_hint: 'По умолчанию 8045, требуется перезапуск для применения',
|
||||||
|
timeout: 'Тайм-аут запроса',
|
||||||
|
timeout_hint: 'По умолчанию 120с, диапазон 30-600с',
|
||||||
|
api_key: 'API Ключ',
|
||||||
|
regenerate_key: 'Перегенерировать',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Перегенерировать API ключ?',
|
||||||
|
description:
|
||||||
|
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
confirm: 'Перегенерировать',
|
||||||
|
},
|
||||||
|
key_warning: 'Держите ваш API ключ в секрете. Не передавайте его никому.',
|
||||||
|
auto_start: 'Автозапуск с приложением',
|
||||||
|
auto_start_desc: 'Запускать прокси-службу при старте приложения',
|
||||||
|
},
|
||||||
|
proxy_tab: 'Прокси',
|
||||||
|
save: 'Сохранить настройки',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
backupSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Бэкап аккаунта создан.',
|
||||||
|
},
|
||||||
|
backupError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось создать бэкап: {{error}}',
|
||||||
|
},
|
||||||
|
switchSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Аккаунт переключен.',
|
||||||
|
},
|
||||||
|
switchError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось переключить аккаунт: {{error}}',
|
||||||
|
},
|
||||||
|
deleteSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Бэкап удален.',
|
||||||
|
},
|
||||||
|
deleteError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось удалить бэкап: {{error}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
title: 'Аккаунты',
|
||||||
|
description: 'Управление пулом аккаунтов Google Gemini.',
|
||||||
|
autoSwitch: 'Авто-переключение',
|
||||||
|
addAccount: 'Добавить аккаунт',
|
||||||
|
syncFromIDE: 'Синхр. из IDE',
|
||||||
|
checkQuota: 'Проверить квоту',
|
||||||
|
polling: 'Опрос запущен',
|
||||||
|
authDialog: {
|
||||||
|
title: 'Добавить Google Аккаунт',
|
||||||
|
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||||
|
openLogin: 'Открыть страницу входа',
|
||||||
|
authCode: 'Код авторизации',
|
||||||
|
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||||
|
instruction:
|
||||||
|
'Откроется браузер для входа в Google. Скопируйте код со страницы localhost и вставьте сюда.',
|
||||||
|
verify: 'Проверить и добавить',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
active: 'Активен',
|
||||||
|
use: 'Использовать',
|
||||||
|
rateLimited: 'Лимит исчерпан',
|
||||||
|
left: 'ост.',
|
||||||
|
used: 'Исп.',
|
||||||
|
unknown: 'Неизвестный',
|
||||||
|
actions: 'Действия',
|
||||||
|
useAccount: 'Использовать этот аккаунт',
|
||||||
|
refresh: 'Обновить квоту',
|
||||||
|
delete: 'Удалить аккаунт',
|
||||||
|
noQuota: 'Нет данных',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
noAccounts: 'Нет добавленных облачных аккаунтов.',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: 'Не удалось загрузить аккаунты.',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
syncSuccess: {
|
||||||
|
title: 'Синхронизация успешна',
|
||||||
|
description: 'Импортирован {{email}} из IDE.',
|
||||||
|
},
|
||||||
|
syncFailed: {
|
||||||
|
title: 'Синхронизация не удалась',
|
||||||
|
description: 'В базе данных IDE не найдено активных аккаунтов.',
|
||||||
|
},
|
||||||
|
addSuccess: 'Аккаунт успешно добавлен!',
|
||||||
|
addFailed: {
|
||||||
|
title: 'Не удалось добавить аккаунт',
|
||||||
|
},
|
||||||
|
quotaRefreshed: 'Квота обновлена',
|
||||||
|
refreshFailed: 'Не удалось обновить квоту',
|
||||||
|
pollFailed: 'Не удалось опросить квоты всех аккаунтов',
|
||||||
|
switched: {
|
||||||
|
title: 'Аккаунт переключен!',
|
||||||
|
description: 'Перезапуск Antigravity...',
|
||||||
|
},
|
||||||
|
switchFailed: 'Не удалось переключить аккаунт',
|
||||||
|
deleted: 'Аккаунт удален',
|
||||||
|
deleteFailed: 'Не удалось удалить аккаунт',
|
||||||
|
deleteConfirm: 'Вы уверены, что хотите удалить этот аккаунт?',
|
||||||
|
autoSwitchOn: 'Авто-переключение включено',
|
||||||
|
autoSwitchOff: 'Авто-переключение выключено',
|
||||||
|
updateSettingsFailed: 'Не удалось обновить настройки',
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
selected: 'Выбрано {{count}}',
|
||||||
|
delete: 'Удалить выбранные',
|
||||||
|
refresh: 'Обновить выбранные',
|
||||||
|
selectAll: 'Выбрать все',
|
||||||
|
clear: 'Снять выделение',
|
||||||
|
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'API Прокси',
|
||||||
|
description: 'Управление локальным прокси-сервером API.',
|
||||||
|
save: 'Сохранить',
|
||||||
|
copy: 'Копировать',
|
||||||
|
regenerate: 'Перегенерировать',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Перегенерировать API ключ?',
|
||||||
|
description:
|
||||||
|
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
confirm: 'Перегенерировать',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
title: 'Статус сервиса',
|
||||||
|
description: 'Управление локальным сервером.',
|
||||||
|
running: 'Работает',
|
||||||
|
stopped: 'Остановлен',
|
||||||
|
start: 'Запустить',
|
||||||
|
stop: 'Остановить',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
port: 'Порт',
|
||||||
|
timeout: 'Тайм-аут',
|
||||||
|
api_key: 'API Ключ',
|
||||||
|
auto_start: 'Автозапуск',
|
||||||
|
auto_start_desc: 'Запускать прокси при старте приложения',
|
||||||
|
local_access: 'Доступ из локальной сети:',
|
||||||
|
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
||||||
|
show_key: 'Показать',
|
||||||
|
hide_key: 'Скрыть',
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
title: 'Маппинг моделей',
|
||||||
|
description: 'Настройка маршрутизации моделей.',
|
||||||
|
maps_to: '->',
|
||||||
|
restore: 'Сброс',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Примеры',
|
||||||
|
description: 'Примеры использования локального прокси.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+87
-10
@@ -1,22 +1,25 @@
|
|||||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
|
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
|
||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
|
||||||
import { ipcMain } from 'electron/main';
|
import { ipcMain } from 'electron/main';
|
||||||
import { ipcContext } from '@/ipc/context';
|
import { ipcContext } from '@/ipc/context';
|
||||||
import { IPC_CHANNELS } from './constants';
|
import { IPC_CHANNELS } from './constants';
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from './utils/logger';
|
||||||
import { CloudAccountRepo } from './ipc/database/cloudHandler';
|
import { CloudAccountRepo } from './ipc/database/cloudHandler';
|
||||||
|
import { initDatabase } from './ipc/database/handler';
|
||||||
import { CloudMonitorService } from './services/CloudMonitorService';
|
import { CloudMonitorService } from './services/CloudMonitorService';
|
||||||
|
|
||||||
// Static Imports to fix Bundle Resolution Errors
|
// Static Imports to fix Bundle Resolution Errors
|
||||||
import { AuthServer } from './ipc/cloud/authServer';
|
import { AuthServer } from './ipc/cloud/authServer';
|
||||||
import { bootstrapNestServer, stopNestServer } from './server/main';
|
import { bootstrapNestServer, stopNestServer } from './server/main';
|
||||||
import { initTray, setTrayLanguage } from './ipc/tray/handler';
|
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
|
||||||
import { rpcHandler } from './ipc/handler';
|
import { rpcHandler } from './ipc/handler';
|
||||||
import { ConfigManager } from './ipc/config/manager';
|
import { ConfigManager } from './ipc/config/manager';
|
||||||
|
import { AppConfig } from './types/config';
|
||||||
|
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
|
||||||
|
|
||||||
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
||||||
|
|
||||||
@@ -62,6 +65,25 @@ const inDevelopment = process.env.NODE_ENV === 'development';
|
|||||||
let globalMainWindow: BrowserWindow | null = null;
|
let globalMainWindow: BrowserWindow | null = null;
|
||||||
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
let startupConfig: AppConfig | null = null;
|
||||||
|
let shouldStartHidden = false;
|
||||||
|
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
logger.info('Second instance detected, focusing existing window');
|
||||||
|
if (app.isReady()) {
|
||||||
|
createWindow({ startHidden: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow({ startHidden: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
process.on('exit', (code) => {
|
process.on('exit', (code) => {
|
||||||
logger.info(`Process exit event triggered with code: ${code}`);
|
logger.info(`Process exit event triggered with code: ${code}`);
|
||||||
@@ -74,7 +96,22 @@ process.on('before-exit', (code) => {
|
|||||||
|
|
||||||
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow({ startHidden }: { startHidden: boolean }) {
|
||||||
|
if (globalMainWindow && !globalMainWindow.isDestroyed()) {
|
||||||
|
if (startHidden) {
|
||||||
|
globalMainWindow.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (globalMainWindow.isMinimized()) {
|
||||||
|
globalMainWindow.restore();
|
||||||
|
}
|
||||||
|
if (!globalMainWindow.isVisible()) {
|
||||||
|
globalMainWindow.show();
|
||||||
|
}
|
||||||
|
globalMainWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('createWindow: start');
|
logger.info('createWindow: start');
|
||||||
const preload = path.join(__dirname, 'preload.js');
|
const preload = path.join(__dirname, 'preload.js');
|
||||||
logger.info(`createWindow: preload path: ${preload}`);
|
logger.info(`createWindow: preload path: ${preload}`);
|
||||||
@@ -83,6 +120,7 @@ function createWindow() {
|
|||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
|
show: !startHidden,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
devTools: inDevelopment,
|
devTools: inDevelopment,
|
||||||
@@ -98,6 +136,10 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
globalMainWindow = mainWindow;
|
globalMainWindow = mainWindow;
|
||||||
logger.info('createWindow: BrowserWindow instance created');
|
logger.info('createWindow: BrowserWindow instance created');
|
||||||
|
if (startHidden) {
|
||||||
|
mainWindow.hide();
|
||||||
|
logger.info('createWindow: startHidden enabled, window hidden');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('createWindow: setting main window in ipcContext');
|
logger.info('createWindow: setting main window in ipcContext');
|
||||||
ipcContext.setMainWindow(mainWindow);
|
ipcContext.setMainWindow(mainWindow);
|
||||||
@@ -125,6 +167,7 @@ function createWindow() {
|
|||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
logger.info('Window closed event triggered');
|
logger.info('Window closed event triggered');
|
||||||
|
globalMainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
||||||
@@ -143,14 +186,28 @@ function createWindow() {
|
|||||||
const { level, message, lineNumber, sourceId } = details;
|
const { level, message, lineNumber, sourceId } = details;
|
||||||
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('focus', () => {
|
||||||
|
CloudMonitorService.handleAppFocus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('child-process-gone', (event, details) => {
|
app.on('child-process-gone', (event, details) => {
|
||||||
logger.error('Child process gone:', details);
|
logger.error('Child process gone:', details);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true;
|
||||||
|
logger.info('App before-quit event triggered - isQuitting set to true');
|
||||||
|
});
|
||||||
|
|
||||||
app.on('will-quit', (event) => {
|
app.on('will-quit', (event) => {
|
||||||
logger.info('App will quit event triggered');
|
logger.info('App will quit event triggered');
|
||||||
|
try {
|
||||||
|
destroyTray();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to destroy tray during will-quit', err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('quit', (event, exitCode) => {
|
app.on('quit', (event, exitCode) => {
|
||||||
@@ -213,13 +270,36 @@ process.on('unhandledRejection', (reason) => {
|
|||||||
|
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
|
.then(async () => {
|
||||||
|
logger.info('Step: Initialize CloudAccountRepo');
|
||||||
|
try {
|
||||||
|
await CloudAccountRepo.init();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Startup: Failed to initialize CloudAccountRepo', e);
|
||||||
|
// We might want to exit here or show a dialog, but for now we proceed
|
||||||
|
// though functionality will be broken.
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
|
||||||
|
initDatabase();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info('Step: Load Config');
|
||||||
|
const config = ConfigManager.loadConfig();
|
||||||
|
startupConfig = config;
|
||||||
|
syncAutoStart(config);
|
||||||
|
shouldStartHidden = isAutoStartLaunch() && config.auto_startup;
|
||||||
|
if (shouldStartHidden) {
|
||||||
|
logger.info('Startup: Auto-start detected, window will start hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.info('Step: setupORPC');
|
logger.info('Step: setupORPC');
|
||||||
return setupORPC();
|
return setupORPC();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
logger.info('Step: createWindow');
|
logger.info('Step: createWindow');
|
||||||
await createWindow();
|
await createWindow({ startHidden: shouldStartHidden });
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.info('Step: installExtensions (SKIPPED)');
|
logger.info('Step: installExtensions (SKIPPED)');
|
||||||
@@ -232,14 +312,11 @@ app
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
// Initialize Cloud Monitor if enabled
|
// Initialize Cloud Monitor if enabled
|
||||||
try {
|
try {
|
||||||
// Initialize DB & Migrate to Encrypted Storage
|
|
||||||
await CloudAccountRepo.init();
|
|
||||||
|
|
||||||
// Start OAuth Server
|
// Start OAuth Server
|
||||||
AuthServer.start();
|
AuthServer.start();
|
||||||
|
|
||||||
// Gateway Server (NestJS) - auto-start if enabled
|
// Gateway Server (NestJS) - auto-start if enabled
|
||||||
const config = ConfigManager.loadConfig();
|
const config = startupConfig || ConfigManager.loadConfig();
|
||||||
if (config.proxy?.auto_start) {
|
if (config.proxy?.auto_start) {
|
||||||
const port = config.proxy?.port || 8045;
|
const port = config.proxy?.port || 8045;
|
||||||
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
|
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
|
||||||
@@ -281,7 +358,7 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow({ startHidden: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
//osX only ends
|
//osX only ends
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -1,6 +1,44 @@
|
|||||||
import { ipcRenderer, contextBridge } from 'electron';
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import * as Sentry from '@sentry/electron/renderer';
|
||||||
import { IPC_CHANNELS } from './constants';
|
import { IPC_CHANNELS } from './constants';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
// Config check logic (duplicate of instrument.ts logic but adapted for preload)
|
||||||
|
let sentryEnabled = false;
|
||||||
|
try {
|
||||||
|
const home = os.homedir();
|
||||||
|
let appDataPath = '';
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
appDataPath = path.join(
|
||||||
|
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||||
|
'Antigravity',
|
||||||
|
);
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
appDataPath = path.join(home, 'Library', 'Application Support', 'Antigravity');
|
||||||
|
} else {
|
||||||
|
appDataPath = path.join(home, '.config', 'Antigravity');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const content = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
sentryEnabled = config.error_reporting_enabled === true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Preload: Failed to read config', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentryEnabled) {
|
||||||
|
// Defer Sentry init to avoid blocking main thread during startup (white screen fix)
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log('[Preload] Initializing Sentry (Deferred)');
|
||||||
|
Sentry.init({});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
|
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
|
||||||
const [serverPort] = event.ports;
|
const [serverPort] = event.ports;
|
||||||
@@ -10,6 +48,7 @@ window.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
SENTRY_ENABLED: sentryEnabled,
|
||||||
onGoogleAuthCode: (callback: (code: string) => void) => {
|
onGoogleAuthCode: (callback: (code: string) => void) => {
|
||||||
const handler = (_event: any, code: string) => callback(code);
|
const handler = (_event: any, code: string) => callback(code);
|
||||||
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
|
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
|
||||||
|
|||||||
+12
-1
@@ -1 +1,12 @@
|
|||||||
import "@/App";
|
import * as Sentry from '@sentry/electron/renderer';
|
||||||
|
|
||||||
|
if (window.electron.SENTRY_ENABLED) {
|
||||||
|
// Defer initialization to prioritize initial render
|
||||||
|
setTimeout(() => {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
import '@/App';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MainLayout } from "@/layouts/MainLayout";
|
import { MainLayout } from '@/layouts/MainLayout';
|
||||||
import { createRootRoute } from "@tanstack/react-router";
|
import { createRootRoute } from '@tanstack/react-router';
|
||||||
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
|
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+100
-1
@@ -17,9 +17,10 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setAppLanguage } from '@/actions/language';
|
import { setAppLanguage } from '@/actions/language';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, FolderOpen } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ProxyConfig } from '@/types/config';
|
import { ProxyConfig } from '@/types/config';
|
||||||
|
import { openLogDirectory } from '@/actions/system';
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -32,6 +33,7 @@ function SettingsPage() {
|
|||||||
// Sync config to local state when loaded
|
// Sync config to local state when loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
|
// eslint-disable-next-line
|
||||||
setProxyConfig(config.proxy);
|
setProxyConfig(config.proxy);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@@ -46,6 +48,10 @@ function SettingsPage() {
|
|||||||
queryFn: getPlatform,
|
queryFn: getPlatform,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAutoStartSupported =
|
||||||
|
platform === 'win32' || platform === 'darwin' || platform === 'linux';
|
||||||
|
const isMac = platform === 'darwin';
|
||||||
|
|
||||||
const handleLanguageChange = (value: string) => {
|
const handleLanguageChange = (value: string) => {
|
||||||
setAppLanguage(value, i18n);
|
setAppLanguage(value, i18n);
|
||||||
};
|
};
|
||||||
@@ -119,6 +125,7 @@ function SettingsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
||||||
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
||||||
|
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +185,49 @@ function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{isAutoStartSupported && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
'settings.startup.description',
|
||||||
|
'Control application launch behavior at system startup.',
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t(
|
||||||
|
'settings.startup.auto_startup_desc',
|
||||||
|
'Launch at sign-in and keep the app in the system tray',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.auto_startup || false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, auto_startup: checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isMac && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
'settings.startup.macos_hint',
|
||||||
|
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||||
@@ -193,9 +243,58 @@ function SettingsPage() {
|
|||||||
|
|
||||||
<div className="text-muted-foreground">{t('settings.license')}</div>
|
<div className="text-muted-foreground">{t('settings.license')}</div>
|
||||||
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openLogDirectory()}
|
||||||
|
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<span>{t('settings.openLogDir', 'Open')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Privacy & Error Reporting Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
'settings.privacy.description',
|
||||||
|
'Control how your data is used to improve the application.',
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t(
|
||||||
|
'settings.privacy.error_reporting_desc',
|
||||||
|
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.error_reporting_enabled || false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, error_reporting_enabled: checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
'settings.privacy.restart_note',
|
||||||
|
'Changes to error reporting will take effect after restarting the application.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||||
|
|||||||
@@ -6,17 +6,28 @@ import { logger } from '../utils/logger';
|
|||||||
export class CloudMonitorService {
|
export class CloudMonitorService {
|
||||||
private static intervalId: NodeJS.Timeout | null = null;
|
private static intervalId: NodeJS.Timeout | null = null;
|
||||||
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||||
|
private static DEBOUNCE_TIME = 10000; // 10 seconds
|
||||||
|
private static lastFocusTime: number = 0;
|
||||||
|
private static isPolling: boolean = false;
|
||||||
|
|
||||||
|
// Helper for testing
|
||||||
|
static resetStateForTesting() {
|
||||||
|
this.lastFocusTime = 0;
|
||||||
|
this.isPolling = false;
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
static start() {
|
static start() {
|
||||||
if (this.intervalId) return;
|
if (this.intervalId) return;
|
||||||
logger.info('Starting CloudMonitorService...');
|
logger.info('Starting CloudMonitorService...');
|
||||||
|
|
||||||
|
// Set lastFocusTime to now to prevent "double-dip" on startup (focus event immediately after start)
|
||||||
|
this.lastFocusTime = Date.now();
|
||||||
|
|
||||||
// Initial Poll
|
// Initial Poll
|
||||||
this.poll().catch((e) => logger.error('Initial poll failed', e));
|
this.poll().catch((e) => logger.error('Initial poll failed', e));
|
||||||
|
|
||||||
this.intervalId = setInterval(() => {
|
this.startInterval();
|
||||||
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
|
||||||
}, this.POLL_INTERVAL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static stop() {
|
static stop() {
|
||||||
@@ -27,39 +38,95 @@ export class CloudMonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async poll() {
|
/**
|
||||||
logger.info('CloudMonitor: Polling quotas...');
|
* Called when the application window gains focus.
|
||||||
const accounts = await CloudAccountRepo.getAccounts();
|
* Triggers an immediate poll if not rate-limited by debounce.
|
||||||
const now = Math.floor(Date.now() / 1000);
|
*/
|
||||||
|
static async handleAppFocus() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
for (const account of accounts) {
|
// 1. Concurrency Guard: If we are already polling, don't pile up requests
|
||||||
try {
|
if (this.isPolling) {
|
||||||
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
|
logger.info('Monitor: App focused, but polling is already in progress. Skipping.');
|
||||||
let accessToken = account.token.access_token;
|
return;
|
||||||
if (account.token.expiry_timestamp < now + 600) {
|
|
||||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
|
||||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
|
||||||
account.token.access_token = newToken.access_token;
|
|
||||||
account.token.expires_in = newToken.expires_in;
|
|
||||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
|
||||||
accessToken = newToken.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch Quota
|
|
||||||
// We delay slightly between requests to act human/avoid spike
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
|
||||||
|
|
||||||
// 3. Update DB
|
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
|
||||||
// Could mark status as 'error' or 'rate_limited' if 429
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check for Auto-Switch
|
// 2. Debounce: If we focused recently, don't poll again
|
||||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
if (now - this.lastFocusTime < this.DEBOUNCE_TIME) {
|
||||||
|
logger.info('Monitor: App focused, skipping poll (debounce active).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Monitor: App focused, triggering immediate poll...');
|
||||||
|
this.lastFocusTime = now;
|
||||||
|
|
||||||
|
// 3. Trigger Poll
|
||||||
|
await this.poll().catch((e) => {
|
||||||
|
logger.error('Monitor: Focus poll failed', e);
|
||||||
|
});
|
||||||
|
// 4. Reset the background interval so we don't double-poll shortly after
|
||||||
|
this.resetInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static startInterval() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
||||||
|
}, this.POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static resetInterval() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.startInterval(); // Restart the 5-minute timer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async poll() {
|
||||||
|
if (this.isPolling) {
|
||||||
|
return; // Extra safety
|
||||||
|
}
|
||||||
|
this.isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('CloudMonitor: Polling quotas...');
|
||||||
|
const accounts = await CloudAccountRepo.getAccounts();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
|
||||||
|
let accessToken = account.token.access_token;
|
||||||
|
if (account.token.expiry_timestamp < now + 600) {
|
||||||
|
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||||
|
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||||
|
account.token.access_token = newToken.access_token;
|
||||||
|
account.token.expires_in = newToken.expires_in;
|
||||||
|
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||||
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
|
accessToken = newToken.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Quota
|
||||||
|
// We delay slightly between requests to act human/avoid spike
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
||||||
|
|
||||||
|
// 3. Update DB
|
||||||
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||||
|
// Could mark status as 'error' or 'rate_limited' if 429
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for Auto-Switch
|
||||||
|
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||||
|
} finally {
|
||||||
|
this.isPolling = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
|
|||||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||||
const CLIENT_SECRET = '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';
|
const CLIENT_SECRET = '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';
|
||||||
|
|
||||||
declare const fetch: any;
|
|
||||||
|
|
||||||
const URLS = {
|
const URLS = {
|
||||||
TOKEN: 'https://oauth2.googleapis.com/token',
|
TOKEN: 'https://oauth2.googleapis.com/token',
|
||||||
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||||
@@ -20,6 +18,18 @@ const URLS = {
|
|||||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||||
|
|
||||||
|
// Request timeout in milliseconds (30 seconds)
|
||||||
|
const REQUEST_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AbortSignal that times out after the specified duration.
|
||||||
|
*/
|
||||||
|
function createTimeoutSignal(ms: number): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
|
|||||||
// --- Service Implementation ---
|
// --- Service Implementation ---
|
||||||
|
|
||||||
export class GoogleAPIService {
|
export class GoogleAPIService {
|
||||||
private static getFetchOptions(): any {
|
private static getFetchOptions() {
|
||||||
try {
|
try {
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
||||||
@@ -121,7 +131,17 @@ export class GoogleAPIService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -147,7 +167,17 @@ export class GoogleAPIService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token refresh timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -166,7 +196,17 @@ export class GoogleAPIService {
|
|||||||
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
||||||
const response = await fetch(URLS.USER_INFO, {
|
const response = await fetch(URLS.USER_INFO, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'User info request timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -221,7 +261,7 @@ export class GoogleAPIService {
|
|||||||
const projectId = await this.fetchProjectId(accessToken);
|
const projectId = await this.fetchProjectId(accessToken);
|
||||||
|
|
||||||
// 2. Build Payload
|
// 2. Build Payload
|
||||||
const payload: Record<string, any> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
payload['project'] = projectId;
|
payload['project'] = projectId;
|
||||||
}
|
}
|
||||||
@@ -285,11 +325,13 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.warn(
|
if (e instanceof Error) {
|
||||||
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
console.warn(
|
||||||
);
|
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
||||||
lastError = e;
|
);
|
||||||
|
}
|
||||||
|
lastError = e instanceof Error ? e : new Error(String(e));
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-16
@@ -1,6 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@plugin "tailwindcss-animate";
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
--color-card: hsl(var(--card));
|
--color-card: hsl(var(--card));
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
|
||||||
--font-sans: Geist, "Microsoft YaHei", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
|
--font-sans: Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
|
||||||
--font-mono: Geist Mono, monospace;
|
--font-mono: Geist Mono, monospace;
|
||||||
--font-tomorrow: Tomorrow, sans-serif;
|
--font-tomorrow: Tomorrow, sans-serif;
|
||||||
|
|
||||||
@@ -74,45 +74,44 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Geist";
|
font-family: 'Geist';
|
||||||
|
|
||||||
src: url("../assets/fonts/geist/geist.ttf") format("truetype");
|
src: url('../assets/fonts/geist/geist.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Geist Mono";
|
font-family: 'Geist Mono';
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|
||||||
src: url("../assets/fonts/geist-mono/geist-mono.ttf") format("truetype");
|
src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Tomorrow";
|
font-family: 'Tomorrow';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
src: url("../assets/fonts/tomorrow/tomorrow-regular.ttf") format("truetype");
|
src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Tomorrow";
|
font-family: 'Tomorrow';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
src: url("../assets/fonts/tomorrow/tomorrow-italic.ttf") format("truetype");
|
src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Tomorrow";
|
font-family: 'Tomorrow';
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
src: url("../assets/fonts/tomorrow/tomorrow-bold.ttf") format("truetype");
|
src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Tomorrow";
|
font-family: 'Tomorrow';
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
src: url("../assets/fonts/tomorrow/tomorrow-bold-italic.ttf")
|
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
|
||||||
format("truetype");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
listAccountsData,
|
listAccountsData,
|
||||||
addAccountSnapshot,
|
addAccountSnapshot,
|
||||||
switchAccount,
|
switchAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
} from "../../ipc/account/handler";
|
} from '../../ipc/account/handler';
|
||||||
import fs from "fs";
|
import fs from 'fs';
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("../../utils/paths", async () => {
|
vi.mock('../../utils/paths', async () => {
|
||||||
const path = await import("path");
|
const path = await import('path');
|
||||||
const agentDir = path.join(process.cwd(), "temp_test_agent");
|
const agentDir = path.join(process.cwd(), 'temp_test_agent');
|
||||||
return {
|
return {
|
||||||
getAgentDir: vi.fn(() => agentDir),
|
getAgentDir: vi.fn(() => agentDir),
|
||||||
getAccountsFilePath: vi.fn(() => path.join(agentDir, "accounts.json")),
|
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
|
||||||
getBackupsDir: vi.fn(() => path.join(agentDir, "backups")),
|
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
|
||||||
getAntigravityDbPath: vi.fn(() => path.join(agentDir, "state.vscdb")),
|
getAntigravityDbPath: vi.fn(() => path.join(agentDir, 'state.vscdb')),
|
||||||
getAntigravityExecutablePath: vi.fn(() => "mock_exec_path"),
|
getAntigravityExecutablePath: vi.fn(() => 'mock_exec_path'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../ipc/database/handler", () => ({
|
vi.mock('../../ipc/database/handler', () => ({
|
||||||
getCurrentAccountInfo: vi.fn(() => ({
|
getCurrentAccountInfo: vi.fn(() => ({
|
||||||
email: "[email protected]",
|
email: '[email protected]',
|
||||||
name: "Test User",
|
name: 'Test User',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
})),
|
})),
|
||||||
backupAccount: vi.fn((account) => ({ version: "1.0", account, data: {} })),
|
backupAccount: vi.fn((account) => ({ version: '1.0', account, data: {} })),
|
||||||
restoreAccount: vi.fn(),
|
restoreAccount: vi.fn(),
|
||||||
getDatabaseConnection: vi.fn(),
|
getDatabaseConnection: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../ipc/process/handler", () => ({
|
vi.mock('../../ipc/process/handler', () => ({
|
||||||
closeAntigravity: vi.fn(),
|
closeAntigravity: vi.fn(),
|
||||||
startAntigravity: vi.fn(),
|
startAntigravity: vi.fn(),
|
||||||
isProcessRunning: vi.fn(() => Promise.resolve(false)),
|
isProcessRunning: vi.fn(() => Promise.resolve(false)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Account Handler", () => {
|
describe('Account Handler', () => {
|
||||||
const testAgentDir = path.join(process.cwd(), "temp_test_agent");
|
const testAgentDir = path.join(process.cwd(), 'temp_test_agent');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (fs.existsSync(testAgentDir)) {
|
if (fs.existsSync(testAgentDir)) {
|
||||||
@@ -54,22 +54,22 @@ describe("Account Handler", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add account snapshot", async () => {
|
it('should add account snapshot', async () => {
|
||||||
const account = await addAccountSnapshot();
|
const account = await addAccountSnapshot();
|
||||||
expect(account.email).toBe("[email protected]");
|
expect(account.email).toBe('[email protected]');
|
||||||
|
|
||||||
const accounts = await listAccountsData();
|
const accounts = await listAccountsData();
|
||||||
expect(accounts).toHaveLength(1);
|
expect(accounts).toHaveLength(1);
|
||||||
expect(accounts[0].email).toBe("[email protected]");
|
expect(accounts[0].email).toBe('[email protected]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should switch account", async () => {
|
it('should switch account', async () => {
|
||||||
const account = await addAccountSnapshot();
|
const account = await addAccountSnapshot();
|
||||||
await switchAccount(account.id);
|
await switchAccount(account.id);
|
||||||
// Mocks should be called, verified by implementation logic not throwing
|
// Mocks should be called, verified by implementation logic not throwing
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete account", async () => {
|
it('should delete account', async () => {
|
||||||
const account = await addAccountSnapshot();
|
const account = await addAccountSnapshot();
|
||||||
await deleteAccount(account.id);
|
await deleteAccount(account.id);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { CloudMonitorService } from '../../services/CloudMonitorService';
|
||||||
|
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||||
|
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||||
|
import { AutoSwitchService } from '../../services/AutoSwitchService';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../../ipc/database/cloudHandler');
|
||||||
|
vi.mock('../../services/GoogleAPIService');
|
||||||
|
vi.mock('../../services/AutoSwitchService');
|
||||||
|
vi.mock('../../utils/logger');
|
||||||
|
|
||||||
|
describe('CloudMonitorService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
CloudMonitorService.resetStateForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
CloudMonitorService.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start polling on start() and set up interval', async () => {
|
||||||
|
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
CloudMonitorService.start();
|
||||||
|
|
||||||
|
// Should call initial poll
|
||||||
|
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Fast forward 5 minutes
|
||||||
|
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
|
||||||
|
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
pollSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should poll accounts correctly', async () => {
|
||||||
|
const mockAccounts = [
|
||||||
|
{
|
||||||
|
id: 'acc1',
|
||||||
|
email: '[email protected]',
|
||||||
|
token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||||
|
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||||
|
|
||||||
|
// Start poll but don't await immediately, as it pauses
|
||||||
|
const pollPromise = CloudMonitorService.poll();
|
||||||
|
|
||||||
|
// Advance time to pass the 1s sleep
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
// Now await
|
||||||
|
await pollPromise;
|
||||||
|
|
||||||
|
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
||||||
|
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
|
||||||
|
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||||
|
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||||
|
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh token if expired during poll', async () => {
|
||||||
|
const mockAccounts = [
|
||||||
|
{
|
||||||
|
id: 'acc1',
|
||||||
|
email: '[email protected]',
|
||||||
|
token: {
|
||||||
|
access_token: 'old_token',
|
||||||
|
refresh_token: 'ref_token',
|
||||||
|
expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||||
|
vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({
|
||||||
|
access_token: 'new_token',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
});
|
||||||
|
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||||
|
|
||||||
|
// Same async pattern
|
||||||
|
const pollPromise = CloudMonitorService.poll();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await pollPromise;
|
||||||
|
|
||||||
|
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
||||||
|
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||||
|
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleAppFocus (Smart Refresh)', () => {
|
||||||
|
it('should trigger poll when focused after debounce time', async () => {
|
||||||
|
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
CloudMonitorService.start();
|
||||||
|
vi.setSystemTime(Date.now() + 20000);
|
||||||
|
|
||||||
|
await CloudMonitorService.handleAppFocus();
|
||||||
|
|
||||||
|
// Called once by start, once by focus
|
||||||
|
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT trigger poll if debounced (focused too soon)', async () => {
|
||||||
|
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
CloudMonitorService.start();
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + 1000);
|
||||||
|
|
||||||
|
await CloudMonitorService.handleAppFocus();
|
||||||
|
|
||||||
|
// Called once by start, 0 by focus
|
||||||
|
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT trigger poll if already polling (concurrency guard)', async () => {
|
||||||
|
// Let's spy on poll to silence start's poll
|
||||||
|
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||||
|
CloudMonitorService.start();
|
||||||
|
pollSpy.mockRestore(); // Restore so we can test the real guard logic
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + 20000);
|
||||||
|
|
||||||
|
// 2. Mock getAccounts to delay
|
||||||
|
let resolveGetAccounts: (value: unknown) => void;
|
||||||
|
const getAccountsPromise = new Promise((resolve) => {
|
||||||
|
resolveGetAccounts = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never);
|
||||||
|
|
||||||
|
// 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep
|
||||||
|
// Actually poll() calls getAccounts first thing.
|
||||||
|
const p1 = CloudMonitorService.handleAppFocus();
|
||||||
|
|
||||||
|
// Allow p1 to start execution and enter poll()
|
||||||
|
// We invoke one run loop
|
||||||
|
// But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await.
|
||||||
|
|
||||||
|
// 4. Trigger second focus immediately
|
||||||
|
const p2 = CloudMonitorService.handleAppFocus();
|
||||||
|
|
||||||
|
// 5. Release the first poll
|
||||||
|
resolveGetAccounts!([
|
||||||
|
{ id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Advance timer for the 1s sleep inside poll
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('polling is already in progress'),
|
||||||
|
);
|
||||||
|
// getAccounts should be called once (by the first unblocked poll)
|
||||||
|
// The second one blocked by guard before calling poll
|
||||||
|
expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset interval after successful focus poll', async () => {
|
||||||
|
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||||
|
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||||
|
|
||||||
|
// Spy on poll to consume the start() call
|
||||||
|
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||||
|
CloudMonitorService.start();
|
||||||
|
pollSpy.mockRestore(); // Restore real poll
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + 20000);
|
||||||
|
|
||||||
|
// Needs to handle async poll
|
||||||
|
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const focusPromise = CloudMonitorService.handleAppFocus();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000); // For poll sleep
|
||||||
|
await focusPromise;
|
||||||
|
|
||||||
|
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||||
|
// 1 for start (initial), 1 for reset = 2
|
||||||
|
expect(setIntervalSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock module to test timeout behavior
|
||||||
|
describe('GoogleAPIService Timeout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should abort request after timeout', async () => {
|
||||||
|
const TIMEOUT_MS = 100;
|
||||||
|
|
||||||
|
// Recreate the same logic used in GoogleAPIService
|
||||||
|
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
};
|
||||||
|
|
||||||
|
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Verify signal is not aborted initially
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// Fast-forward time
|
||||||
|
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||||
|
|
||||||
|
// Signal should now be aborted
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set aborted state when controller.abort() is called', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Initially not aborted
|
||||||
|
expect(controller.signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// Abort the controller
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Signal should now be aborted
|
||||||
|
expect(controller.signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform AbortError to user-friendly message', () => {
|
||||||
|
// This tests our error transformation logic from GoogleAPIService
|
||||||
|
const handleAbortError = (err: Error) => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortError = new Error('The operation was aborted');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
|
||||||
|
expect(() => handleAbortError(abortError)).toThrow(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transform non-AbortError', () => {
|
||||||
|
const handleAbortError = (err: Error) => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkError = new Error('Network failure');
|
||||||
|
networkError.name = 'NetworkError';
|
||||||
|
|
||||||
|
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from '../../utils/logger';
|
||||||
import fs from "fs";
|
import fs from 'fs';
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
|
|
||||||
vi.mock("../../utils/paths", async () => {
|
vi.mock('../../utils/paths', async () => {
|
||||||
const path = await import("path");
|
const path = await import('path');
|
||||||
return {
|
return {
|
||||||
getAgentDir: vi.fn(() => path.join(process.cwd(), "temp_test_logs")),
|
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Logger Utilities", () => {
|
describe('Logger Utilities', () => {
|
||||||
const testLogDir = path.join(process.cwd(), "temp_test_logs");
|
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
|
||||||
const testLogFile = path.join(testLogDir, "app.log");
|
const testLogFile = path.join(testLogDir, 'app.log');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
console.log("Test Log Dir:", testLogDir);
|
console.log('Test Log Dir:', testLogDir);
|
||||||
if (fs.existsSync(testLogDir)) {
|
if (fs.existsSync(testLogDir)) {
|
||||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
fs.rmSync(testLogDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
fs.mkdirSync(testLogDir, { recursive: true });
|
fs.mkdirSync(testLogDir, { recursive: true });
|
||||||
console.log("Dir created:", fs.existsSync(testLogDir));
|
console.log('Dir created:', fs.existsSync(testLogDir));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -29,24 +29,24 @@ describe("Logger Utilities", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create log file if it does not exist", () => {
|
it('should create log file if it does not exist', () => {
|
||||||
logger.info("Test message");
|
logger.info('Test message');
|
||||||
expect(fs.existsSync(testLogFile)).toBe(true);
|
expect(fs.existsSync(testLogFile)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should write formatted message to file", () => {
|
it('should write formatted message to file', () => {
|
||||||
const message = "Test info message";
|
const message = 'Test info message';
|
||||||
logger.info(message);
|
logger.info(message);
|
||||||
const content = fs.readFileSync(testLogFile, "utf-8");
|
const content = fs.readFileSync(testLogFile, 'utf-8');
|
||||||
expect(content).toContain("[INFO]");
|
expect(content).toContain('[INFO]');
|
||||||
expect(content).toContain(message);
|
expect(content).toContain(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log error messages", () => {
|
it('should log error messages', () => {
|
||||||
const message = "Test error message";
|
const message = 'Test error message';
|
||||||
logger.error(message);
|
logger.error(message);
|
||||||
const content = fs.readFileSync(testLogFile, "utf-8");
|
const content = fs.readFileSync(testLogFile, 'utf-8');
|
||||||
expect(content).toContain("[ERROR]");
|
expect(content).toContain('[ERROR]');
|
||||||
expect(content).toContain(message);
|
expect(content).toContain(message);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+172
-47
@@ -1,57 +1,182 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import {
|
|
||||||
isProcessRunning,
|
|
||||||
closeAntigravity,
|
|
||||||
startAntigravity,
|
|
||||||
} from "../../ipc/process/handler";
|
|
||||||
import { exec } from "child_process";
|
|
||||||
|
|
||||||
vi.mock("child_process", () => {
|
// Mock find-process module
|
||||||
return {
|
vi.mock('find-process', () => ({
|
||||||
exec: vi.fn(),
|
default: vi.fn(),
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock promisify to return a function that returns a promise
|
|
||||||
vi.mock("util", () => ({
|
|
||||||
promisify: (fn: unknown) => fn,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Process Handler", () => {
|
// Mock logger to avoid console output during tests
|
||||||
it("should check if process is running", async () => {
|
vi.mock('../../utils/logger', () => ({
|
||||||
// Mock exec to return stdout
|
logger: {
|
||||||
const execMock = exec as unknown as ReturnType<typeof vi.fn>;
|
debug: vi.fn(),
|
||||||
execMock.mockImplementation((cmd, cb) => {
|
info: vi.fn(),
|
||||||
if (cb) cb(null, { stdout: "12345" }, { stderr: "" });
|
warn: vi.fn(),
|
||||||
return { stdout: "12345" };
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock paths module to avoid child_process issues
|
||||||
|
vi.mock('../../utils/paths', () => ({
|
||||||
|
getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'),
|
||||||
|
isWsl: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||||
|
import findProcess from 'find-process';
|
||||||
|
|
||||||
|
describe('Process Handler', () => {
|
||||||
|
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isProcessRunning', () => {
|
||||||
|
it('should return true when Antigravity main process is found on macOS', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait, promisify wraps exec.
|
it('should return false when only helper processes are found', async () => {
|
||||||
// If I mock exec, promisify(exec) will use the mock.
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
// But my mock implementation needs to match what promisify expects or I need to mock the promisified function.
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
// Since I mocked 'util', promisify returns the fn itself.
|
|
||||||
// So execAsync IS exec.
|
|
||||||
// But exec signature is (cmd, options, callback) or (cmd, callback).
|
|
||||||
// My implementation calls execAsync(command).
|
|
||||||
// So I should mock exec to return a Promise if I mocked promisify to return fn?
|
|
||||||
// No, if promisify returns fn, then `await execAsync(cmd)` means `await exec(cmd)`.
|
|
||||||
// `exec` returns a ChildProcess, not a Promise.
|
|
||||||
// So my mock of `util` is wrong if I want `await execAsync` to work.
|
|
||||||
// Better to mock the module that exports execAsync if possible, or mock `util` correctly.
|
|
||||||
// Or just mock `child_process` and use `vi.mocked(exec)`.
|
|
||||||
// But `promisify` is the key.
|
|
||||||
|
|
||||||
// Let's rely on vitest mocking capabilities.
|
mockFindProcess.mockResolvedValue([
|
||||||
// If I don't mock util, promisify will wrap the mocked exec.
|
{
|
||||||
// The mocked exec should behave like real exec (taking a callback).
|
pid: 12346,
|
||||||
|
name: 'Antigravity Helper (Renderer)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (Renderer).app --type=renderer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pid: 12347,
|
||||||
|
name: 'Antigravity Helper (GPU)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (GPU).app --type=gpu-process',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when only manager process is found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12348,
|
||||||
|
name: 'Antigravity Manager',
|
||||||
|
cmd: '/Applications/Antigravity Manager.app/Contents/MacOS/Antigravity Manager',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no processes are found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip self process', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 12345, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345, // Same as current PID
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when Antigravity.exe is found on Windows', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity.exe',
|
||||||
|
cmd: 'C:\\Program Files\\Antigravity\\Antigravity.exe',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when antigravity is found on Linux', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'antigravity',
|
||||||
|
cmd: '/usr/bin/antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle find-process errors gracefully', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockRejectedValue(new Error('Process enumeration failed'));
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude processes with --type= argument', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity --type=utility',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip complex mocking tests for now as they are fragile without proper setup.
|
describe('Module exports', () => {
|
||||||
// I'll write a basic test structure.
|
it('should export all required functions', () => {
|
||||||
|
expect(isProcessRunning).toBeDefined();
|
||||||
it("should be defined", () => {
|
expect(closeAntigravity).toBeDefined();
|
||||||
expect(isProcessRunning).toBeDefined();
|
expect(startAntigravity).toBeDefined();
|
||||||
expect(closeAntigravity).toBeDefined();
|
});
|
||||||
expect(startAntigravity).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import keytar from 'keytar';
|
||||||
|
import { safeStorage } from 'electron';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { decryptWithMigration } from '../../utils/security';
|
||||||
|
|
||||||
|
const primaryHex = '11'.repeat(32);
|
||||||
|
const fallbackHex = '22'.repeat(32);
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
safeStorage: {
|
||||||
|
isEncryptionAvailable: vi.fn(() => true),
|
||||||
|
decryptString: vi.fn(() => primaryHex),
|
||||||
|
encryptString: vi.fn((value: string) => Buffer.from(value)),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getPath: vi.fn(() => 'C:\\test'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readFile: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
rename: vi.fn(),
|
||||||
|
unlink: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('keytar', () => ({
|
||||||
|
default: {
|
||||||
|
findCredentials: vi.fn(async () => []),
|
||||||
|
getPassword: vi.fn(async () => fallbackHex),
|
||||||
|
setPassword: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fsMock = vi.mocked(fs, { deep: true });
|
||||||
|
const keytarMock = vi.mocked(keytar, { deep: true });
|
||||||
|
const safeStorageMock = vi.mocked(safeStorage, { deep: true });
|
||||||
|
|
||||||
|
function encryptWithKey(key: Buffer, text: string): string {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
safeStorageMock.isEncryptionAvailable.mockReturnValue(true);
|
||||||
|
safeStorageMock.decryptString.mockReturnValue(primaryHex);
|
||||||
|
|
||||||
|
fsMock.readFile.mockImplementation(async (_path, encoding) => {
|
||||||
|
if (encoding === 'utf8') {
|
||||||
|
return 'not-hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from('encrypted');
|
||||||
|
});
|
||||||
|
fsMock.writeFile.mockResolvedValue(undefined);
|
||||||
|
fsMock.rename.mockResolvedValue(undefined);
|
||||||
|
fsMock.unlink.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
keytarMock.findCredentials.mockResolvedValue([]);
|
||||||
|
keytarMock.getPassword.mockResolvedValue(fallbackHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptWithMigration', () => {
|
||||||
|
it('falls back to legacy key and re-encrypts', async () => {
|
||||||
|
const plaintext = '{"token":"legacy"}';
|
||||||
|
const ciphertext = encryptWithKey(Buffer.from(fallbackHex, 'hex'), plaintext);
|
||||||
|
|
||||||
|
const result = await decryptWithMigration(ciphertext);
|
||||||
|
|
||||||
|
expect(result.value).toBe(plaintext);
|
||||||
|
expect(result.usedFallback).toBe('keytar');
|
||||||
|
expect(result.reencrypted).toBeTypeOf('string');
|
||||||
|
expect(result.reencrypted).not.toBe(ciphertext);
|
||||||
|
expect(keytarMock.getPassword).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
if (result.reencrypted) {
|
||||||
|
const migrated = await decryptWithMigration(result.reencrypted);
|
||||||
|
expect(migrated.value).toBe(plaintext);
|
||||||
|
expect(migrated.usedFallback).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use fallback when primary key works', async () => {
|
||||||
|
const plaintext = '{"token":"primary"}';
|
||||||
|
const ciphertext = encryptWithKey(Buffer.from(primaryHex, 'hex'), plaintext);
|
||||||
|
|
||||||
|
const result = await decryptWithMigration(ciphertext);
|
||||||
|
|
||||||
|
expect(result.value).toBe(plaintext);
|
||||||
|
expect(result.usedFallback).toBeUndefined();
|
||||||
|
expect(result.reencrypted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws migration error code when legacy keys are unavailable', async () => {
|
||||||
|
keytarMock.getPassword.mockResolvedValue(null);
|
||||||
|
fsMock.readFile.mockImplementation(async (_path, encoding) => {
|
||||||
|
if (encoding === 'utf8') {
|
||||||
|
return 'not-hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from('encrypted');
|
||||||
|
});
|
||||||
|
|
||||||
|
const plaintext = '{"token":"legacy"}';
|
||||||
|
const ciphertext = encryptWithKey(Buffer.from('33'.repeat(32), 'hex'), plaintext);
|
||||||
|
|
||||||
|
await expect(decryptWithMigration(ciphertext)).rejects.toThrow('ERR_DATA_MIGRATION_FAILED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { render } from "@testing-library/react";
|
import { render } from '@testing-library/react';
|
||||||
import { test, expect } from "vitest";
|
import { test, expect } from 'vitest';
|
||||||
import ToggleTheme from "@/components/toggle-theme";
|
import ToggleTheme from '@/components/toggle-theme';
|
||||||
|
|
||||||
test("renders ToggleTheme", () => {
|
test('renders ToggleTheme', () => {
|
||||||
const { getByRole } = render(<ToggleTheme />);
|
const { getByRole } = render(<ToggleTheme />);
|
||||||
const isButton = getByRole("button");
|
const isButton = getByRole('button');
|
||||||
|
|
||||||
expect(isButton).toBeInTheDocument();
|
expect(isButton).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("has icon", () => {
|
test('has icon', () => {
|
||||||
const { getByRole } = render(<ToggleTheme />);
|
const { getByRole } = render(<ToggleTheme />);
|
||||||
const button = getByRole("button");
|
const button = getByRole('button');
|
||||||
const icon = button.querySelector("svg");
|
const icon = button.querySelector('svg');
|
||||||
|
|
||||||
expect(icon).toBeInTheDocument();
|
expect(icon).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("is moon icon", () => {
|
test('is moon icon', () => {
|
||||||
const svgIconClassName: string = "lucide-moon";
|
const svgIconClassName: string = 'lucide-moon';
|
||||||
const { getByRole } = render(<ToggleTheme />);
|
const { getByRole } = render(<ToggleTheme />);
|
||||||
const svg = getByRole("button").querySelector("svg");
|
const svg = getByRole('button').querySelector('svg');
|
||||||
|
|
||||||
expect(svg?.classList).toContain(svgIconClassName);
|
expect(svg?.classList).toContain(svgIconClassName);
|
||||||
});
|
});
|
||||||
Vendored
+1
@@ -7,6 +7,7 @@ declare global {
|
|||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
|
SENTRY_ENABLED: boolean;
|
||||||
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
|
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
|
||||||
changeLanguage: (lang: string) => void;
|
changeLanguage: (lang: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-1
@@ -20,8 +20,11 @@ export const AppConfigSchema = z.object({
|
|||||||
theme: z.string(),
|
theme: z.string(),
|
||||||
auto_refresh: z.boolean(),
|
auto_refresh: z.boolean(),
|
||||||
refresh_interval: z.number(), // minutes
|
refresh_interval: z.number(), // minutes
|
||||||
auto_sync: z.boolean(), // 是否自动同步
|
auto_sync: z.boolean(),
|
||||||
sync_interval: z.number(), // minutes
|
sync_interval: z.number(), // minutes
|
||||||
|
auto_startup: z.boolean(),
|
||||||
|
error_reporting_enabled: z.boolean(),
|
||||||
|
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
|
||||||
default_export_path: z.string().nullable().optional(), // 导出路径
|
default_export_path: z.string().nullable().optional(), // 导出路径
|
||||||
proxy: ProxyConfigSchema,
|
proxy: ProxyConfigSchema,
|
||||||
});
|
});
|
||||||
@@ -37,6 +40,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
refresh_interval: 15,
|
refresh_interval: 15,
|
||||||
auto_sync: false,
|
auto_sync: false,
|
||||||
sync_interval: 5,
|
sync_interval: 5,
|
||||||
|
auto_startup: false,
|
||||||
|
error_reporting_enabled: true, // Default to disabled for privacy
|
||||||
|
privacy_consent_asked: false, // Whether the user has been asked for consent
|
||||||
default_export_path: null,
|
default_export_path: null,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ThemeMode = "dark" | "light" | "system";
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { AppConfig } from '../types/config';
|
||||||
|
|
||||||
|
export const AUTO_START_ARG = '--autostart';
|
||||||
|
const LINUX_AUTOSTART_FILENAME = 'antigravity-manager.desktop';
|
||||||
|
|
||||||
|
function getLinuxAutoStartPath() {
|
||||||
|
const dir = path.join(os.homedir(), '.config', 'autostart');
|
||||||
|
return path.join(dir, LINUX_AUTOSTART_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutoStartLaunch(argv = process.argv) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return argv.includes(AUTO_START_ARG);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32' || process.platform === 'darwin') {
|
||||||
|
try {
|
||||||
|
const settings = app.getLoginItemSettings();
|
||||||
|
return settings.wasOpenedAtLogin;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AutoStart: Failed to read login item settings', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWindowsOrMacAutoStart(enabled: boolean) {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: enabled,
|
||||||
|
openAsHidden: enabled,
|
||||||
|
path: process.execPath,
|
||||||
|
args: enabled ? [AUTO_START_ARG] : [],
|
||||||
|
});
|
||||||
|
const settings = app.getLoginItemSettings();
|
||||||
|
if (settings.openAtLogin !== enabled) {
|
||||||
|
logger.warn(
|
||||||
|
`AutoStart: Login item mismatch (expected ${enabled}, actual ${settings.openAtLogin})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(`AutoStart: Login item set to ${enabled}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AutoStart: Failed to update login item settings', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupLinuxAutoStartEntries(currentPath: string) {
|
||||||
|
const autoStartDir = path.dirname(currentPath);
|
||||||
|
if (!fs.existsSync(autoStartDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyNames = [
|
||||||
|
`${app.getName()}.desktop`,
|
||||||
|
`${app.getName().replace(/\s+/g, '')}.desktop`,
|
||||||
|
'Antigravity Manager.desktop',
|
||||||
|
'AntigravityManager.desktop',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of legacyNames) {
|
||||||
|
const candidate = path.join(autoStartDir, name);
|
||||||
|
if (candidate === currentPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(candidate);
|
||||||
|
logger.info(`AutoStart: Removed legacy autostart entry ${candidate}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('AutoStart: Failed to remove legacy autostart entry', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLinuxAutoStart(enabled: boolean) {
|
||||||
|
const autoStartPath = getLinuxAutoStartPath();
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
if (fs.existsSync(autoStartPath)) {
|
||||||
|
fs.unlinkSync(autoStartPath);
|
||||||
|
logger.info('AutoStart: Removed Linux autostart entry');
|
||||||
|
}
|
||||||
|
cleanupLinuxAutoStartEntries(autoStartPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoStartDir = path.dirname(autoStartPath);
|
||||||
|
if (!fs.existsSync(autoStartDir)) {
|
||||||
|
fs.mkdirSync(autoStartDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const execPath = process.execPath.replace(/"/g, '\\"');
|
||||||
|
const content = [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
`Name=${app.getName()}`,
|
||||||
|
`Exec="${execPath}" ${AUTO_START_ARG}`,
|
||||||
|
'X-GNOME-Autostart-enabled=true',
|
||||||
|
'Hidden=false',
|
||||||
|
'NoDisplay=false',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
cleanupLinuxAutoStartEntries(autoStartPath);
|
||||||
|
fs.writeFileSync(autoStartPath, content, 'utf-8');
|
||||||
|
logger.info('AutoStart: Created Linux autostart entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAutoStart(config: AppConfig) {
|
||||||
|
const enabled = Boolean(config.auto_startup);
|
||||||
|
|
||||||
|
if (process.platform === 'win32' || process.platform === 'darwin') {
|
||||||
|
syncWindowsOrMacAutoStart(enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
syncLinuxAutoStart(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
|
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
|
||||||
|
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
|
||||||
|
const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED';
|
||||||
|
const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE';
|
||||||
|
const DATA_MIGRATION_ERROR_CODE = 'ERR_DATA_MIGRATION_FAILED';
|
||||||
|
const DATA_MIGRATION_HINT_RELOGIN = 'HINT_RELOGIN';
|
||||||
|
const DATA_MIGRATION_HINT_CLEAR_DATA = 'HINT_CLEAR_DATA';
|
||||||
|
|
||||||
|
const KEYCHAIN_HINT_I18N_MAP: Record<string, string> = {
|
||||||
|
[KEYCHAIN_HINT_TRANSLOCATION]: 'error.keychainHint.translocation',
|
||||||
|
[KEYCHAIN_HINT_KEYCHAIN_DENIED]: 'error.keychainHint.keychainDenied',
|
||||||
|
[KEYCHAIN_HINT_SIGN_NOTARIZE]: 'error.keychainHint.signNotarize',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATA_MIGRATION_HINT_I18N_MAP: Record<string, string> = {
|
||||||
|
[DATA_MIGRATION_HINT_RELOGIN]: 'error.dataMigrationHint.relogin',
|
||||||
|
[DATA_MIGRATION_HINT_CLEAR_DATA]: 'error.dataMigrationHint.clearData',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveKeychainMessage(hintCode: string | undefined, t: TFunction): string {
|
||||||
|
const base = t('error.keychainUnavailable');
|
||||||
|
if (!hintCode) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintKey = KEYCHAIN_HINT_I18N_MAP[hintCode];
|
||||||
|
if (!hintKey) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} ${t(hintKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataMigrationMessage(hintCode: string | undefined, t: TFunction): string {
|
||||||
|
const base = t('error.dataMigrationFailed');
|
||||||
|
if (!hintCode) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintKey = DATA_MIGRATION_HINT_I18N_MAP[hintCode];
|
||||||
|
if (!hintKey) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} ${t(hintKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalizedErrorMessage(error: unknown, t: TFunction): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const rawMessage = error.message;
|
||||||
|
const [code, hint] = rawMessage.split('|');
|
||||||
|
if (code === KEYCHAIN_ERROR_CODE) {
|
||||||
|
return resolveKeychainMessage(hint, t);
|
||||||
|
}
|
||||||
|
if (code === DATA_MIGRATION_ERROR_CODE) {
|
||||||
|
return resolveDataMigrationMessage(hint, t);
|
||||||
|
}
|
||||||
|
return rawMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
const rawMessage = String((error as { message?: unknown }).message ?? '');
|
||||||
|
const [code, hint] = rawMessage.split('|');
|
||||||
|
if (code === KEYCHAIN_ERROR_CODE) {
|
||||||
|
return resolveKeychainMessage(hint, t);
|
||||||
|
}
|
||||||
|
if (code === DATA_MIGRATION_ERROR_CODE) {
|
||||||
|
return resolveDataMigrationMessage(hint, t);
|
||||||
|
}
|
||||||
|
if (rawMessage) {
|
||||||
|
return rawMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
@@ -4,6 +4,23 @@ import { getAgentDir } from './paths';
|
|||||||
|
|
||||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
|
||||||
|
const LOG_WINDOW_MS = 30_000;
|
||||||
|
const MAX_LOG_ENTRIES = 200;
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
formatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryReporter = (payload: {
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
error?: Error;
|
||||||
|
logs: LogEntry[];
|
||||||
|
}) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringify an object, handling circular references
|
* Safely stringify an object, handling circular references
|
||||||
* This prevents "Converting circular structure to JSON" errors
|
* This prevents "Converting circular structure to JSON" errors
|
||||||
@@ -33,6 +50,9 @@ function safeStringify(obj: unknown): string {
|
|||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private logFilePath: string;
|
private logFilePath: string;
|
||||||
|
private recentLogs: LogEntry[] = [];
|
||||||
|
private sentryReporter: SentryReporter | null = null;
|
||||||
|
private sentryEnabled = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
@@ -75,10 +95,55 @@ class Logger {
|
|||||||
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pruneLogs(now: number) {
|
||||||
|
while (this.recentLogs.length > 0 && now - this.recentLogs[0].timestamp > LOG_WINDOW_MS) {
|
||||||
|
this.recentLogs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.recentLogs.length > MAX_LOG_ENTRIES) {
|
||||||
|
this.recentLogs = this.recentLogs.slice(-MAX_LOG_ENTRIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractError(args: unknown[]): Error | undefined {
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSentryReporter(reporter: SentryReporter | null) {
|
||||||
|
this.sentryReporter = reporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorReportingEnabled(enabled: boolean) {
|
||||||
|
this.sentryEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||||
const formattedMessage = this.formatMessage(level, message, ...args);
|
const formattedMessage = this.formatMessage(level, message, ...args);
|
||||||
|
const now = Date.now();
|
||||||
|
this.recentLogs.push({
|
||||||
|
timestamp: now,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
formatted: formattedMessage,
|
||||||
|
});
|
||||||
|
this.pruneLogs(now);
|
||||||
|
|
||||||
this.writeToConsole(level, message, ...args);
|
this.writeToConsole(level, message, ...args);
|
||||||
this.writeToFile(formattedMessage);
|
this.writeToFile(formattedMessage);
|
||||||
|
|
||||||
|
if (level === 'error' && this.sentryEnabled && this.sentryReporter) {
|
||||||
|
this.sentryReporter({
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
error: this.extractError(args),
|
||||||
|
logs: [...this.recentLogs],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, ...args: unknown[]) {
|
info(message: string, ...args: unknown[]) {
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export function getAntigravityDbPaths(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
|
paths.push(path.join(appData, 'User', 'globalStorage', 'state.vscdb'));
|
||||||
|
paths.push(path.join(appData, 'User', 'state.vscdb'));
|
||||||
paths.push(path.join(appData, 'state.vscdb'));
|
paths.push(path.join(appData, 'state.vscdb'));
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user