Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda59ed3a2 | ||
|
|
18389b9753 | ||
|
|
2ae2216f1e | ||
|
|
ed94abf0d6 | ||
|
|
f572ae4652 | ||
|
|
36848b46dc | ||
|
|
5bda4b19df | ||
|
|
476d297e4b | ||
|
|
ce7147025d | ||
|
|
080fb67974 | ||
|
|
f6f8069ce4 | ||
|
|
6328378da5 | ||
|
|
d25f2948c3 | ||
|
|
63956c9c2d | ||
|
|
a785640d7a | ||
|
|
1265d044f6 | ||
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db | ||
|
|
a988a33d21 | ||
|
|
e959ee346e | ||
|
|
5a2168030e | ||
|
|
00bf4dea0b | ||
|
|
02080588b7 | ||
|
|
d5ee08d5a0 | ||
|
|
e844aa9545 | ||
|
|
0d4e2ab21f | ||
|
|
f15bb48fdd | ||
|
|
515648d64d | ||
|
|
22265e153c | ||
|
|
ad39a735b5 | ||
|
|
6a07bc0a10 | ||
|
|
e91457eceb | ||
|
|
01e3f8f8fd | ||
|
|
bd3d41aed1 | ||
|
|
d2945a6e8a | ||
|
|
89f39710c7 | ||
|
|
1bce5d331d | ||
|
|
47be175e27 | ||
|
|
f0718db374 | ||
|
|
4b12f1220d | ||
|
|
47c14763de | ||
|
|
37a9750df2 | ||
|
|
f708de24c0 | ||
|
|
6e7f2252ad | ||
|
|
e96d3e0e8e | ||
|
|
12ab4e0ce7 | ||
|
|
013f3290f3 | ||
|
|
f224fea081 | ||
|
|
a438070268 | ||
|
|
e927ac9506 | ||
|
|
465eb5d0a4 | ||
|
|
37b0fdeeb0 |
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,28 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win32
|
||||||
|
arch: x64
|
||||||
|
- os: windows-11-arm
|
||||||
|
platform: win32
|
||||||
|
arch: arm64
|
||||||
|
- os: macos-15-intel
|
||||||
|
platform: darwin
|
||||||
|
arch: x64
|
||||||
|
- os: macos-14
|
||||||
|
platform: darwin
|
||||||
|
arch: arm64
|
||||||
|
- os: macos-14
|
||||||
|
platform: darwin
|
||||||
|
arch: universal
|
||||||
|
- os: ubuntu-latest
|
||||||
|
platform: linux
|
||||||
|
arch: x64
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
platform: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
@@ -29,10 +49,14 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.platform == 'linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y rpm libsecret-1-dev pkg-config
|
sudo apt-get install -y fakeroot rpm libsecret-1-dev pkg-config
|
||||||
|
|
||||||
|
- name: Install WiX Toolset
|
||||||
|
if: matrix.platform == 'win32' && matrix.arch == 'x64'
|
||||||
|
run: choco install wixtoolset --version=3.14.0 -y
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -40,4 +64,8 @@ jobs:
|
|||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npm run publish
|
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 -- --platform=${{ matrix.platform }} --arch=${{ matrix.arch }}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Semantic Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for semantic-release to analyze commits
|
||||||
|
persist-credentials: false # We use a token in the next steps
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
|
||||||
|
run: npx semantic-release
|
||||||
@@ -102,3 +102,6 @@ out/
|
|||||||
|
|
||||||
# Tanstack
|
# Tanstack
|
||||||
.tanstack/
|
.tanstack/
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
/coverage
|
/coverage
|
||||||
/.vite
|
/.vite
|
||||||
README.md
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
/src/routeTree.gen.ts
|
/src/routeTree.gen.ts
|
||||||
.agents/
|
.agents/
|
||||||
openspec/
|
openspec/
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||||
|
}
|
||||||
@@ -20,158 +20,169 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
|||||||
|
|
||||||
<!-- OPENSPEC:END -->
|
<!-- OPENSPEC:END -->
|
||||||
|
|
||||||
# 用户指南
|
# User Guide
|
||||||
|
|
||||||
本文是 AI 助手在本仓库工作的用户级指南。中文为主、工具与脚本偏好统一、并纳入你补充的技术栈与开发注意事项。
|
This document is a user-level guide for AI assistants working in this repository. It standardizes language, tool/script preferences, and development notes for the current tech stack.
|
||||||
|
|
||||||
## 💬 沟通约定
|
## 💬 Communication Conventions
|
||||||
|
|
||||||
- **语言**: 对话、待办、代码相关内容(注释、UI 文案、提交信息、PR 描述等)统一使用中文。
|
- **Language**: Use English consistently for conversation, TODOs, and code-related content (comments, UI copy, commit messages, PR descriptions, and similar artifacts).
|
||||||
- **结论先行**: 回答先给出核心结论/摘要,再补充细节。
|
- **Conclusion first**: Start with the core conclusion/summary, then provide details.
|
||||||
- **引用**: 引用具体代码时,务必给出完整文件路径(如 `src/main.ts:42`)。
|
- **References**: When citing code, always provide full file paths (for example, `src/main.ts:42`).
|
||||||
|
|
||||||
## 💻 运行环境与工具
|
## 💻 Runtime and Tooling
|
||||||
|
|
||||||
- **Runtime**: Node.js (Electron 环境)
|
- **Runtime**: Node.js (Electron environment)
|
||||||
- **Node**: 建议 Node.js 20+
|
- **Node**: Recommended Node.js 20+
|
||||||
- **包管理器**: `npm` (本项目包含 package-lock.json,强制使用 npm)
|
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
|
||||||
- **构建工具**: Electron Forge + Vite
|
- **Build tools**: Electron Forge + Vite
|
||||||
- **终端环境**: Windows (PowerShell) / 可安全使用 VSCode MCP 工具
|
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
|
||||||
|
|
||||||
## 🧩 技术栈总览 (Tech Stack)
|
## 🧩 Tech Stack Overview
|
||||||
|
|
||||||
- **Frontend**:
|
- **Frontend**:
|
||||||
- React 19, TypeScript
|
- React 19, TypeScript
|
||||||
- Tailwind CSS v4, `clsx`, `tailwind-merge`, `tailwindcss-animate`
|
- Tailwind CSS v4, `clsx`, `tailwind-merge`, `tailwindcss-animate`
|
||||||
- Radix UI (Primitives), Lucide React (Icons), Sonner (Toast)
|
- Radix UI (Primitives), Lucide React (Icons), Simple Icons (`@icons-pack/react-simple-icons`)
|
||||||
|
- `class-variance-authority` (CVA), `react-i18next` + `i18next`
|
||||||
- TanStack Router (Routing), TanStack Query (State Management)
|
- TanStack Router (Routing), TanStack Query (State Management)
|
||||||
- Components: 模块化设计,`src/components`
|
- Components: Modular design under `src/components`
|
||||||
- **Backend (Electron Main/Server)**:
|
- **Backend (Electron Main/Server)**:
|
||||||
- Electron (Main/Preload/Renderer 架构)
|
- Electron (Main/Preload/Renderer architecture)
|
||||||
- NestJS (用于内部代理/网关服务,由 Main Process 启动)
|
- NestJS (internal proxy/gateway service, started by main process)
|
||||||
- Better-SQLite3 (本地数据库), Drizzle ORM (如果后续引入) / Raw SQL
|
- Better-SQLite3 (local database), Drizzle ORM / Raw SQL
|
||||||
- ORPC (Type-safe RPC)
|
- ORPC (type-safe RPC)
|
||||||
- Zod (Validation)
|
- gRPC (`@grpc/grpc-js`, `@grpc/proto-loader`)
|
||||||
|
- Logging: `winston` + `winston-daily-rotate-file`
|
||||||
|
- Zod (validation)
|
||||||
- **Testing**:
|
- **Testing**:
|
||||||
- Vitest (Unit/Integration Tests)
|
- Vitest (unit/integration), Testing Library
|
||||||
- Playwright (E2E Tests)
|
- Playwright (E2E)
|
||||||
|
|
||||||
## 📁 目录结构
|
## 📁 Directory Structure
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
.
|
.
|
||||||
├─ src/
|
├─ src/
|
||||||
│ ├─ components/ # React UI 组件 (ui/ 存放基础组件)
|
│ ├─ actions/ # App actions and flow orchestration
|
||||||
│ ├─ hooks/ # Custom React Hooks
|
│ ├─ assets/ # Static assets
|
||||||
│ ├─ ipc/ # Electron IPC 处理逻辑 (Database, Config, etc.)
|
│ ├─ components/ # React UI components (base components under ui/)
|
||||||
│ ├─ layouts/ # 页面布局组件
|
│ ├─ constants/ # Constants
|
||||||
│ ├─ lib/ # 通用工具库
|
│ ├─ hooks/ # Custom React hooks
|
||||||
│ ├─ routes/ # TanStack Router 路由定义
|
│ ├─ ipc/ # Electron IPC logic (Database, Config, etc.)
|
||||||
│ ├─ server/ # NestJS 后端服务逻辑 (Gateway/Proxy)
|
│ ├─ layouts/ # Layout components
|
||||||
│ ├─ services/ # 业务服务层
|
│ ├─ lib/ # Shared low-level utilities
|
||||||
│ ├─ styles/ # 全局样式 (Tailwind class)
|
│ ├─ localization/ # i18n translation resources
|
||||||
│ ├─ types/ # TypeScript 类型定义
|
│ ├─ mocks/ # Mock data for tests and development
|
||||||
│ ├─ utils/ # 通用工具函数
|
│ ├─ routes/ # TanStack Router route definitions
|
||||||
│ ├─ App.tsx # React 应用入口
|
│ ├─ server/ # NestJS backend logic (Gateway/Proxy)
|
||||||
│ ├─ main.ts # Electron 主进程入口
|
│ ├─ services/ # Service layer
|
||||||
│ ├─ preload.ts # Electron 预加载脚本
|
│ ├─ styles/ # Global styles (Tailwind classes)
|
||||||
│ └─ renderer.ts # Electron 渲染进程入口
|
│ ├─ tests/ # Test code
|
||||||
├─ forge.config.ts # Electron Forge 配置
|
│ ├─ types/ # TypeScript type definitions
|
||||||
|
│ ├─ utils/ # Utility functions
|
||||||
|
│ ├─ App.tsx # React app entry
|
||||||
|
│ ├─ main.ts # Electron main entry
|
||||||
|
│ ├─ preload.ts # Electron preload script
|
||||||
|
│ └─ renderer.ts # Electron renderer entry
|
||||||
|
├─ forge.config.ts # Electron Forge config
|
||||||
└─ package.json
|
└─ package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧱 组件架构(Component Architecture)
|
## 🧱 Component Architecture
|
||||||
|
|
||||||
- **模块化组件**: 每个组件使用独立目录,至少包含 `.tsx` 以及可能的样式或子组件。
|
- **Modular components**: Each component should have its own directory, with at least a `.tsx` file and optional styles/subcomponents.
|
||||||
- **共享能力**: 通用函数置于 `src/utils/`,底层通用封装置于 `src/lib/`。
|
- **Shared capabilities**: General helpers in `src/utils/`; low-level shared wrappers in `src/lib/`.
|
||||||
- **服务层**: 统一在 `src/services/` 或 `src/ipc/` 中封装数据访问,前端仅依赖 IPC 或 RPC 调用。
|
- **Service layer**: Centralize data access in `src/services/` or `src/ipc/`; frontend should consume IPC or RPC only.
|
||||||
|
|
||||||
## 📦 常用脚本 (Scripts)
|
## 📦 Common Scripts
|
||||||
|
|
||||||
所有命令使用 `npm` 执行:
|
Use `npm` for all commands:
|
||||||
|
|
||||||
- **开发 (Dev)**:
|
- **Development (Dev)**:
|
||||||
- `npm start` - 启动 Electron 开发环境 (Electron Forge)
|
- `npm start` - Start Electron dev environment (Electron Forge)
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
- `npm run lint` - Run ESLint checks
|
||||||
- `npm run format` - 运行 Prettier 格式化检查
|
- `npm run format` - Run Prettier check
|
||||||
|
- `npm run format:write` - Auto-format with Prettier
|
||||||
|
- `npm run type-check` - Run TypeScript type check
|
||||||
|
|
||||||
- **构建 (Build)**:
|
- **Build**:
|
||||||
- `npm run package` - 打包应用 (生成的只有应用包)
|
- `npm run package` - Package app (application bundle only)
|
||||||
- `npm run make` - 构建并生成分发安装包 (Make)
|
- `npm run make` - Build and generate distributable installers
|
||||||
- `npm run publish` - 发布应用
|
- `npm run publish` - Publish app
|
||||||
|
|
||||||
- **测试 (Test)**:
|
- **Testing**:
|
||||||
- `npm test` - 运行 Vitest 单元测试
|
- `npm test` - Run Vitest tests
|
||||||
- `npm run test:unit` - 同上
|
- `npm run test:watch` - Run Vitest in watch mode
|
||||||
- `npm run test:e2e` - 运行 Playwright 端到端测试
|
- `npm run test:unit` - Same as above for unit-focused runs
|
||||||
- `npm run test:all` - 运行所有测试
|
- `npm run test:e2e` - Run Playwright E2E tests
|
||||||
|
- `npm run test:all` - Run all tests
|
||||||
|
|
||||||
|
### Running a Single Test
|
||||||
|
|
||||||
### 运行单个测试 (Running Single Tests)
|
- Unit test: `npm run test:unit path/to/test.test.ts`
|
||||||
|
- E2E test: `npm run test:e2e path/to/test.spec.ts`
|
||||||
|
- Type check: `npm run type-check`
|
||||||
|
|
||||||
- 单元测试: `npm run test:unit path/to/test.test.ts`
|
## 🧪 Development Notes
|
||||||
- E2E 测试: `npm run test:e2e path/to/test.spec.ts`
|
|
||||||
- 类型检查: `npm run type-check`
|
|
||||||
|
|
||||||
## 🧪 开发注意事项(Development Notes)
|
- **Build**: Build stage may ignore TS/ESLint errors depending on project/CI configuration.
|
||||||
|
- **DevTools**: `code-inspector-plugin` is integrated; use `Shift + Click` on page elements to jump to source code.
|
||||||
|
- **React**: React Strict Mode is disabled.
|
||||||
|
- **NestJS**: Runs as an Electron child process; logs are visible in main-process console.
|
||||||
|
|
||||||
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
|
## Security and Data
|
||||||
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
|
|
||||||
- **React**: React Strict Mode 关闭。
|
|
||||||
- **NestJS**: 作为 Electron 子进程运行,日志输出在主进程控制台。
|
|
||||||
|
|
||||||
## �️ 安全与数据 (Security & Data)
|
- **Security**: Never commit secrets; use environment variables for sensitive config; validate all user input; encrypt sensitive data.
|
||||||
|
- **Database**: Use Better-SQLite3; encapsulate operations in services layer; always use prepared statements; test DB operations independently.
|
||||||
|
- **i18n**: Use `react-i18next`; keys should use kebab-case; translation files are stored in `src/localization/`.
|
||||||
|
|
||||||
- **安全**: 绝不提交密钥;敏感配置使用环境变量;验证所有用户输入;敏感数据加密存储。
|
## 📝 Conventions
|
||||||
- **数据库**: 使用 Better-SQLite3;所有操作封装在 Services 层;必须使用 Prepared Statements;独立测试 DB 操作。
|
|
||||||
- **国际化**: 使用 `react-i18next`;Key 使用 kebab-case;翻译文件存放在 `src/localization/`。
|
|
||||||
|
|
||||||
## �📝 代码规范 (Conventions)
|
- **File naming**:
|
||||||
|
- Components: PascalCase (for example, `Button.tsx`)
|
||||||
|
- Tools/config: camelCase or kebab-case
|
||||||
|
- **Import paths**: Use `@/` alias for `src/`.
|
||||||
|
- **Type safety**: Avoid `any`; enforce end-to-end type safety with Zod + TypeScript.
|
||||||
|
- **Component design**:
|
||||||
|
- Prefer Radix UI Primitives.
|
||||||
|
- Use Tailwind utility classes; avoid CSS Modules unless necessary.
|
||||||
|
- **API communication**: Frontend should prioritize ORPC client or IPC for strong type inference.
|
||||||
|
|
||||||
- **文件命名**:
|
### Naming Specifics
|
||||||
- 组件: PascalCase (e.g., `Button.tsx`)
|
|
||||||
- 工具/配置: camelCase 或 kebab-case
|
|
||||||
- **导入路径**: 使用 `@/` 别名指向 `src/` 目录。
|
|
||||||
- **类型安全**: 严禁使用 `any`,利用 Zod 和 TypeScript 确保全链路类型安全。
|
|
||||||
- **组件设计**:
|
|
||||||
- 优先使用 Radix UI Primitives。
|
|
||||||
- 样式使用 Tailwind Utility Classes,避免 CSS Modules (除非必要)。
|
|
||||||
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
|
|
||||||
|
|
||||||
### 命名约定 (Naming Specifics)
|
- **Functions/Variables**: camelCase (for example, `handleClick`, `isCurrent`)
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE (for example, `LOCAL_STORAGE_KEYS`)
|
||||||
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
|
|
||||||
- **Files**:
|
- **Files**:
|
||||||
- Services: `ServiceName.service.ts`
|
- Services: `ServiceName.service.ts`
|
||||||
- Types: `type-name.ts`
|
- Types: `type-name.ts`
|
||||||
|
|
||||||
### 导入组织 (Import Organization)
|
### Import Organization
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. React 和核心库
|
// 1. React and core libraries
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
// 2. 外部依赖 (按字母顺序)
|
// 2. External dependencies (alphabetical order)
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
// 3. 内部导入 (使用 @ 别名)
|
// 3. Internal imports (using @ alias)
|
||||||
import { Account } from '@/types/account';
|
import { Account } from '@/types/account';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
```
|
```
|
||||||
|
|
||||||
### 组件结构 (Component Structure)
|
### Component Structure
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. 导入
|
// 1. Imports
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
// 2. 类型定义
|
// 2. Type definitions
|
||||||
interface ComponentProps { /* props */ }
|
interface ComponentProps { /* props */ }
|
||||||
|
|
||||||
// 3. 组件实现
|
// 3. Component implementation
|
||||||
export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
||||||
// 4. Hooks
|
// 4. Hooks
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -180,14 +191,14 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
> 提交前请确保执行 `npm run lint` 和 `npm run format`。
|
> Before commit, run `npm run lint` and `npm run format`.
|
||||||
|
|
||||||
## 📝 终端输出与引用规范
|
## 📝 Terminal Output and References
|
||||||
|
|
||||||
- 代码块优先,避免 Markdown 表格与 mermaid(Claude 代码块渲染更稳定)。
|
- Prefer code blocks; avoid Markdown tables and Mermaid unless necessary.
|
||||||
- 表格(如需)左对齐;中文字符等宽注意显示差异。
|
- If tables are used, keep them left-aligned and check display consistency.
|
||||||
|
|
||||||
示例:
|
Example:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
+------+---------+---------+
|
+------+---------+---------+
|
||||||
@@ -198,77 +209,77 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
|||||||
+------+---------+---------+
|
+------+---------+---------+
|
||||||
```
|
```
|
||||||
|
|
||||||
### 引用规范(References)
|
### Reference Rules
|
||||||
|
|
||||||
- 外部资源:使用完整可点击链接(Issue、文档、API 参考)。
|
- External resources: use full clickable links (issues, docs, API references).
|
||||||
- 源码位置:使用完整文件路径(可附行号)。
|
- Source code location: use full file paths (optionally with line numbers).
|
||||||
|
|
||||||
示例:
|
Example:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
- “resolveFilePath 负责该逻辑”
|
- "resolveFilePath owns this logic"
|
||||||
- “VSCode 在撤销操作上存在既知限制”
|
- "VSCode has a known limitation in undo behavior"
|
||||||
|
|
||||||
🔗 References:
|
References:
|
||||||
- resolveFilePath: src/utils/workspace.ts:40
|
- resolveFilePath: src/utils/workspace.ts:40
|
||||||
- VSCode undo limitation: https://github.com/microsoft/vscode/issues/77190
|
- VSCode undo limitation: https://github.com/microsoft/vscode/issues/77190
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏷️ Markdown 书写
|
## 🏷️ Markdown Writing
|
||||||
|
|
||||||
- 代码块语言不要留空,无法确定时使用 `plaintext`。
|
- Always specify a language for fenced code blocks; use `plaintext` if unsure.
|
||||||
- 标题后保留一行空行,便于渲染与阅读。
|
- Keep one blank line after headings for readability.
|
||||||
|
|
||||||
## 换行规范
|
## Line-Break Rule
|
||||||
|
|
||||||
return 等语句不要与其他语句在同一行,而是单独保持一行
|
`return` and similar statements should not share a line with other statements. Keep them on separate lines.
|
||||||
|
|
||||||
## 💭 注释规范
|
## 💭 Commenting Rules
|
||||||
|
|
||||||
- 必注释场景:复杂业务/算法、特殊行为、重要设计取舍、关键参考链接。
|
- Required comment scenarios: complex business logic/algorithms, non-obvious behaviors, important design tradeoffs, and key reference links.
|
||||||
- 原则:
|
- Principles:
|
||||||
- 注释“为什么”(Why),非“做了什么”(What)、非变更记录(Changelog)。
|
- Explain **why**, not **what**, and not changelog history.
|
||||||
- 修改代码时同步更新注释(过时注释比无注释更糟)。
|
- Update comments whenever related code changes.
|
||||||
- 优先使用 JSDoc,复杂函数先给高层概览,函数体内按步骤(1、2、3…)标注关键过程。
|
- Prefer JSDoc; for complex functions, start with high-level overview, then annotate key steps (1, 2, 3...).
|
||||||
- 中英文之间适当空格,提升可读性;不要为删除的旧代码写注释。
|
- Keep spacing between English and Chinese words if both appear for readability; do not comment deleted legacy code.
|
||||||
|
|
||||||
“质量自检”问题:六个月后新同事看到这条注释能得到什么有用信息?若答案是“没有”,删掉它。
|
Quality self-check: six months later, what useful context does a new teammate gain from this comment? If the answer is "none", remove it.
|
||||||
|
|
||||||
示例:
|
Example:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/**
|
/**
|
||||||
* 处理支付请求,多步骤校验
|
* Handle payment request with multi-step validation.
|
||||||
*/
|
*/
|
||||||
function processPayment(request: PaymentRequest) {
|
function processPayment(request: PaymentRequest) {
|
||||||
// 1. 数据校验
|
// 1. Input validation
|
||||||
// 2. 风控评估(低/中/高差异处理)
|
// 2. Risk evaluation (low/medium/high paths)
|
||||||
// 3. 网关调用
|
// 3. Gateway call
|
||||||
// 4. 用户通知
|
// 4. User notification
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum BudgetType {
|
export enum BudgetType {
|
||||||
Free = 'free',
|
Free = 'free',
|
||||||
/** ✅ 推荐使用 JSDoc,而不是行尾注释 */
|
/** ✅ Prefer JSDoc over end-of-line comments */
|
||||||
Package = 'package',
|
Package = 'package',
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ 开发指南
|
## 🛠️ Development Guide
|
||||||
|
|
||||||
### 通用原则
|
### General Principles
|
||||||
|
|
||||||
- 优先稳定与可维护性,其次再谈性能优化。
|
- Prioritize stability and maintainability before optimization.
|
||||||
- 面对不确定性:明确假设、取舍与验证方案;先沟通再实施。
|
- For uncertainty, state assumptions/tradeoffs/validation approach clearly, then implement.
|
||||||
- 信任既定前置条件,避免对承诺不变量的过度防御;若出现冲突,更新方案而非额外兜底。
|
- Trust agreed preconditions; avoid excessive defensive coding against guaranteed invariants.
|
||||||
- 老代码重构保守推进,新功能实现可采用更现代方式。
|
- Refactor legacy code conservatively; use modern approaches for new features where appropriate.
|
||||||
- 避免过早优化:先以简单直接的实现达成功能,确需时再优化(避免先行加缓存/抖动或拆分成过多文件)。
|
- Avoid premature optimization: implement simple and direct first; optimize only when justified.
|
||||||
- 对于 if,while 等条件、循环语句必须加上花括号,不能与return、break、continue 等语句同级。
|
- Always use braces for control flow (`if`, `while`, and similar statements).
|
||||||
|
|
||||||
### 错误处理 (Error Handling)
|
### Error Handling
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 异步操作使用 try-catch
|
// Use try-catch for async operations
|
||||||
try {
|
try {
|
||||||
const result = await someOperation();
|
const result = await someOperation();
|
||||||
return result;
|
return result;
|
||||||
@@ -277,92 +288,92 @@ try {
|
|||||||
throw new Error('Failed to complete operation');
|
throw new Error('Failed to complete operation');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用正确的错误类型
|
// Use proper error typing
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
/* 处理 Error 实例 */
|
/* handle Error instance */
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 新功能实现
|
### New Feature Implementation
|
||||||
|
|
||||||
- 代码应清晰、可读、可复用、有效率且可测试。
|
- Code should be clear, readable, reusable, efficient, and testable.
|
||||||
- 倾向选用成熟可靠的现代 API。
|
- Prefer mature and reliable modern APIs.
|
||||||
|
|
||||||
### 重构与修 Bug
|
### Refactoring and Bug Fixing
|
||||||
|
|
||||||
- 倾向增量式改动;如需大重构,先就范围对齐再推进。
|
- Prefer incremental changes; align scope first before large refactors.
|
||||||
- 保持原结构与风格,避免过度抽象引入新风险。
|
- Preserve existing structure and style; avoid over-abstraction risk.
|
||||||
|
|
||||||
### 开发生命周期(Checklist)
|
### Development Lifecycle Checklist
|
||||||
|
|
||||||
探索/规划:
|
Exploration / planning:
|
||||||
|
|
||||||
- \[ ] 充分理解需求,分步思考与列计划(3–6 步)
|
- \[ ] Fully understand requirements; break down into 3-6 steps
|
||||||
- \[ ] 优先查阅文档与既有方案
|
- \[ ] Review documentation and existing solutions first
|
||||||
- \[ ] 通过阅读实际代码验证想法
|
- \[ ] Validate ideas by reading actual code
|
||||||
- \[ ] 形成 TODO 列表
|
- \[ ] Build a TODO list
|
||||||
|
|
||||||
实现/重构/修复:
|
Implementation / refactor / fix:
|
||||||
|
|
||||||
- [ ] 阅读相关模板与周边代码,沿用既有模式
|
- [ ] Review related templates and surrounding code; follow existing patterns
|
||||||
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
|
- [ ] Fail fast on invalid inputs/states
|
||||||
- [ ] 在约束内尽力提升前端交互与体验
|
- [ ] Improve frontend interaction and UX within constraints
|
||||||
|
|
||||||
验收/校验:
|
Acceptance / validation:
|
||||||
|
|
||||||
- \[ ] 通过测试或临时脚本验证实现
|
- \[ ] Validate implementation through tests or temporary scripts
|
||||||
- \[ ] 多次增量修改后回看是否可合并为一次更连贯的修改
|
- \[ ] After multiple incremental edits, evaluate whether changes should be consolidated
|
||||||
- \[ ] 运行质量检查
|
- \[ ] Run quality checks
|
||||||
- \[ ] 更新相关文档
|
- \[ ] Update related docs
|
||||||
|
|
||||||
总结/输出:
|
Summary / output:
|
||||||
|
|
||||||
- \[ ] 检查输出格式要求
|
- \[ ] Verify output formatting requirements
|
||||||
- \[ ] 列出与原计划偏差与关键决策,便于人工复核
|
- \[ ] List deviations from plan and key decisions for human review
|
||||||
- \[ ] 提供优化建议
|
- \[ ] Provide optimization suggestions
|
||||||
- \[ ] 在结尾提供完整引用链接
|
- \[ ] Include full references at the end
|
||||||
|
|
||||||
## 🔍 代码质量与 Lint
|
## 🔍 Code Quality and Lint
|
||||||
|
|
||||||
- 变量命名具描述性(如 `mutationObserver`、`button`、`element`),避免 `mo`、`btn`、`el`。
|
- Use descriptive variable names (`mutationObserver`, `button`, `element`) and avoid `mo`, `btn`, `el`.
|
||||||
- 检查缺失的重要注释,并保持注释语言一致。
|
- Check for missing critical comments and keep comment language consistent.
|
||||||
- 使用 VSCode MCP 诊断工具查看 TS/ESLint 报告并修复关键问题。
|
- Use VSCode MCP diagnostics for TS/ESLint issues and fix key findings.
|
||||||
- 若新增/修改测试,需运行并修复测试后再提交。
|
- If tests are added/updated, run and fix them before submission.
|
||||||
|
|
||||||
## ⛔ 需要显式确认的操作
|
## ⛔ Operations Requiring Explicit Confirmation
|
||||||
|
|
||||||
- 运行具有破坏性的命令
|
- Running destructive commands
|
||||||
- 执行 `git commit`、`git push`
|
- Executing `git commit` or `git push`
|
||||||
- 新建测试文件(先由维护者进行人工审核)
|
- Creating new test files (maintainer review required first)
|
||||||
|
|
||||||
## 🔧 工具偏好与命令
|
## 🔧 Tool Preferences and Commands
|
||||||
|
|
||||||
包与脚本:
|
Packages and scripts:
|
||||||
|
|
||||||
- `npm install` (或简写 `npm i`)
|
- `npm install` (or `npm i`)
|
||||||
|
|
||||||
Shell:
|
Shell:
|
||||||
|
|
||||||
- 在工作区根目录执行命令。
|
- Run commands in repository root.
|
||||||
- 路径建议使用引号包裹。
|
- Quote file paths when appropriate.
|
||||||
|
|
||||||
Web 搜索:
|
Web search:
|
||||||
|
|
||||||
- `WebSearch` 获取最新内容;不足时用 `mcp__SearXNG__search` 聚合搜索
|
- Use `WebSearch` for latest information; use `mcp__SearXNG__search` when needed.
|
||||||
|
|
||||||
文档/用法检索:
|
Documentation/usage lookup:
|
||||||
|
|
||||||
- `context7` 获取依赖的最新用法
|
- Use `context7` for latest dependency usage.
|
||||||
|
|
||||||
VSCode MCP (如可用):
|
VSCode MCP (if available):
|
||||||
|
|
||||||
- `mcp__vscode-mcp__get_references` 辅助重构范围判定
|
- `mcp__vscode-mcp__get_references` for refactor impact analysis
|
||||||
- `mcp__vscode-mcp__rename_symbol` 安全重命名
|
- `mcp__vscode-mcp__rename_symbol` for safe renaming
|
||||||
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
|
- `mcp__vscode-mcp__get_symbol_lsp_info` for types/signatures/definitions
|
||||||
|
|
||||||
## 🚨 本地质量检查(可选流程)
|
## 🚨 Local Quality Checks (Optional Flow)
|
||||||
|
|
||||||
在完成一组修改后并行执行三类检查,而非直接跑全量 lint:
|
After a set of changes, run these three checks in parallel instead of full lint immediately:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
Task(subagent_type: "quick-code-review", description: "Code review", prompt: "[change description]")
|
Task(subagent_type: "quick-code-review", description: "Code review", prompt: "[change description]")
|
||||||
@@ -370,16 +381,16 @@ Task(subagent_type: "diagnostics", description: "Diagnostics", prompt: "[same as
|
|||||||
Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[same as above]")
|
Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[same as above]")
|
||||||
```
|
```
|
||||||
|
|
||||||
change description 示例:
|
`change description` example:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
- Modified files: 相对路径列表
|
- Modified files: list of relative paths
|
||||||
- Context: 需求/业务背景
|
- Context: requirement/business background
|
||||||
```
|
```
|
||||||
|
|
||||||
流程:初检 → 修复关键问题 → 复检 → 迭代至关键问题清零。
|
Flow: initial check -> fix key issues -> re-check -> iterate until key issues are resolved.
|
||||||
|
|
||||||
注意:上述工具为只读分析,需自行修复;传入精确文件路径,避免用笼统目录。
|
Note: these tools are read-only analyzers; you still need to apply fixes manually. Pass precise file paths, not broad directories.
|
||||||
|
|
||||||
<skills_system priority="1">
|
<skills_system priority="1">
|
||||||
|
|
||||||
|
|||||||
+147
-3
@@ -1,9 +1,153 @@
|
|||||||
|
<a name="readme-top"></a>
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
## [0.7.0](https://github.com/Draculabo/AntigravityManager/compare/v0.6.0...v0.7.0) (2026-02-06)
|
||||||
|
|
||||||
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).
|
|
||||||
|
* add multi-arch release artifacts and MSI packaging ([#65](https://github.com/Draculabo/AntigravityManager/issues/65)) ([f572ae4](https://github.com/Draculabo/AntigravityManager/commit/f572ae4652937efb25ab66defcfa55ddf65ac484))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* restore account switching on Antigravity 1.16.5 and migrate db sync to drizzle ([#69](https://github.com/Draculabo/AntigravityManager/issues/69)) ([ed94abf](https://github.com/Draculabo/AntigravityManager/commit/ed94abf0d6e90c0a1bf9c76f1ec3fdea091f6c4b))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
* translate repository documentation to English ([#72](https://github.com/Draculabo/AntigravityManager/issues/72)) ([18389b9](https://github.com/Draculabo/AntigravityManager/commit/18389b975392f63441ede8369f13f6ef068214ab))
|
||||||
|
|
||||||
|
### ♻️ Code Refactoring
|
||||||
|
|
||||||
|
* migrate to winston and enable daily rotated app logs ([#71](https://github.com/Draculabo/AntigravityManager/issues/71)) ([2ae2216](https://github.com/Draculabo/AntigravityManager/commit/2ae2216f1ed3fa503ffd87a14cafd3921077a5c4))
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/Draculabo/AntigravityManager/compare/v0.5.0...v0.6.0) (2026-02-04)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* add cloud reset time UI ([#56](https://github.com/Draculabo/AntigravityManager/issues/56)) ([f6f8069](https://github.com/Draculabo/AntigravityManager/commit/f6f8069ce4673ae7027c6ba248daa18c8b602218))
|
||||||
|
* windows install guidance ([#63](https://github.com/Draculabo/AntigravityManager/issues/63)) ([ce71470](https://github.com/Draculabo/AntigravityManager/commit/ce7147025d37648a82f6f6b300502ef4462bfde6))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* correct Windows install notice path ([5bda4b1](https://github.com/Draculabo/AntigravityManager/commit/5bda4b19dfc9ce64735cd39600d41a5deefc26ee))
|
||||||
|
* **proxy:** route Claude Code CLI requests on /v1/chat/completions to Anthropic handler ([#61](https://github.com/Draculabo/AntigravityManager/issues/61)) ([476d297](https://github.com/Draculabo/AntigravityManager/commit/476d297e4b2dd3015f7ebcbb915b643730816dc6))
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* **i18n:** add Russian localization ([#48](https://github.com/Draculabo/AntigravityManager/issues/48)) ([63956c9](https://github.com/Draculabo/AntigravityManager/commit/63956c9c2d60f829a998237abe6ade675fdb01ed))
|
||||||
|
* Implement collapsible sidebar and refined status bar UI ([#45](https://github.com/Draculabo/AntigravityManager/issues/45)) ([1265d04](https://github.com/Draculabo/AntigravityManager/commit/1265d044f69e52fba7da72fde6c47a0b85c58232))
|
||||||
|
* sentry integration ([#51](https://github.com/Draculabo/AntigravityManager/issues/51)) ([a785640](https://github.com/Draculabo/AntigravityManager/commit/a785640d7a51b65a6852383401bd7c284b716975))
|
||||||
|
|
||||||
|
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
|
||||||
|
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
|
||||||
|
|
||||||
|
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
- remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
|
||||||
|
|
||||||
|
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
- update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
|
||||||
|
|
||||||
|
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
|
||||||
|
|
||||||
|
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
- add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
|
||||||
|
- clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
|
||||||
|
|
||||||
|
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
- add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
|
||||||
|
|
||||||
|
### 🔧 Continuous Integration
|
||||||
|
|
||||||
|
- setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
|
||||||
|
- trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-01-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
|
||||||
|
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-01-23
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- Verify Google OAuth code automatically after receipt
|
||||||
|
- Add button to open logs folder
|
||||||
|
- Add expiration warning for Google OAuth authentication
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
|
||||||
|
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
|
||||||
|
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Add VS Code settings for auto-formatting and ESLint
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-01-19
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed tray icon not appearing in production builds on Windows
|
||||||
|
- Used `extraResource` config to properly copy assets outside of ASAR package
|
||||||
|
- Added debug logging for tray icon path resolution
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-01-19
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
|
||||||
|
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||||
|
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Applied Prettier formatting to entire codebase (68 files)
|
||||||
|
- Added node globals to ESLint configuration
|
||||||
|
|
||||||
## [0.2.0] - 2026-01-16
|
## [0.2.0] - 2026-01-16
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ Download the latest release for your platform from the [Releases](https://github
|
|||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Windows | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| macOS | [.dmg installer](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) |
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -184,7 +184,7 @@
|
|||||||
|
|
||||||
| 平台 | 下载链接 |
|
| 平台 | 下载链接 |
|
||||||
| ------- | ------------------------------------------------------------------------------ |
|
| ------- | ------------------------------------------------------------------------------ |
|
||||||
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| 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) |
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+60
-60
@@ -1,96 +1,96 @@
|
|||||||
# 云端账号管理与智能切换 (Cloud Account Features)
|
# Cloud Account Management and Intelligent Switching
|
||||||
|
|
||||||
本文档详细介绍了 Antigravity Manager 中实现的云端 AI 账号管理与切换功能。该功能允许用户管理多个 Google 账号,监控 API 配额,并一键无缝切换 IDE 的当前活动账号。
|
This document explains the cloud AI account management and switching capabilities in Antigravity Manager. The feature allows users to manage multiple Google accounts, monitor API quotas, and seamlessly switch the active IDE account with one click.
|
||||||
|
|
||||||
## 1. 核心功能
|
## 1. Core Features
|
||||||
|
|
||||||
### 1.1 账号池管理
|
### 1.1 Account Pool Management
|
||||||
|
|
||||||
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
|
- **Add accounts**: Add accounts via Google OAuth authorization code.
|
||||||
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
|
- **List view**: Display all added accounts, including avatar, email, and last used time.
|
||||||
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
|
- **Status monitoring**: Show real-time account status (Active, Rate Limited, Expired) and whether the account is currently active.
|
||||||
- **删除账号**:支持从本地数据库中移除账号。
|
- **Delete accounts**: Remove accounts from the local database.
|
||||||
|
|
||||||
### 1.2 配额实时监控
|
### 1.2 Real-Time Quota Monitoring
|
||||||
|
|
||||||
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
|
- **Multi-model support**: Track quota usage for models such as `gemini-pro`, `claude-3-5-sonnet`, and more.
|
||||||
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
|
- **Visual indicators**: Use progress bars and color states (green/yellow/red) to show remaining quota percentage.
|
||||||
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
|
- **Auto/manual refresh**: Support manual quota refresh; the system also checks quota automatically before switching.
|
||||||
|
|
||||||
### 1.4 智能自动切换 (Intelligent Auto-Switching)
|
### 1.4 Intelligent Auto-Switching
|
||||||
|
|
||||||
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
|
- **Unlimited pool mode**: When the current account quota is low (`<5%`) or rate-limited, the system automatically finds and switches to the best backup account.
|
||||||
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
|
- **Background monitoring**: Built-in `CloudMonitorService` polls quota status for all accounts every 5 minutes by default.
|
||||||
- **全局开关**:用户可在界面上一键开启或关闭此功能。
|
- **Global toggle**: Users can enable or disable this feature with one click in the UI.
|
||||||
|
|
||||||
## 2. 技术实现
|
## 2. Technical Implementation
|
||||||
|
|
||||||
### 2.1 数据库设计 (`cloud_accounts.db`)
|
### 2.1 Database Design (`cloud_accounts.db`)
|
||||||
|
|
||||||
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
|
Account information is stored in SQLite, independent from the IDE's local database.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE accounts (
|
CREATE TABLE accounts (
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
provider TEXT NOT NULL, -- 'google' | 'anthropic'
|
provider TEXT NOT NULL, -- 'google' | 'anthropic'
|
||||||
email TEXT NOT NULL, -- 邮箱
|
email TEXT NOT NULL, -- Email
|
||||||
name TEXT, -- 显示名称
|
name TEXT, -- Display name
|
||||||
avatar_url TEXT, -- 头像 URL
|
avatar_url TEXT, -- Avatar URL
|
||||||
token_json TEXT NOT NULL, -- OAuth Token (JSON)
|
token_json TEXT NOT NULL, -- OAuth token (JSON)
|
||||||
quota_json TEXT, -- 配额数据 (JSON)
|
quota_json TEXT, -- Quota data (JSON)
|
||||||
created_at INTEGER NOT NULL, -- 创建时间戳
|
created_at INTEGER NOT NULL, -- Created timestamp
|
||||||
last_used INTEGER NOT NULL, -- 最后使用时间戳
|
last_used INTEGER NOT NULL, -- Last-used timestamp
|
||||||
status TEXT DEFAULT 'active', -- 账号状态
|
status TEXT DEFAULT 'active', -- Account status
|
||||||
is_active INTEGER DEFAULT 0 -- 是否为当前 IDE 活动账号
|
is_active INTEGER DEFAULT 0 -- Whether this is the currently active IDE account
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 全局配置表
|
-- Global settings table
|
||||||
CREATE TABLE settings (
|
CREATE TABLE settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 IPC 接口设计
|
### 2.2 IPC Interface Design
|
||||||
|
|
||||||
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
|
The backend exposes the following oRPC interfaces via `src/ipc/cloud`:
|
||||||
|
|
||||||
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
|
- `addGoogleAccount(authCode: string)`: Exchange token and save account.
|
||||||
- `listCloudAccounts()`: 获取账号列表。
|
- `listCloudAccounts()`: Get account list.
|
||||||
- `refreshAccountQuota(accountId: string)`: 刷新指定账号的配额和 Token。
|
- `refreshAccountQuota(accountId: string)`: Refresh quota and token for a specific account.
|
||||||
- `switchCloudAccount(accountId: string)`: 执行账号切换流程。
|
- `switchCloudAccount(accountId: string)`: Run the account switch workflow.
|
||||||
- `deleteCloudAccount(accountId: string)`: 删除账号。
|
- `deleteCloudAccount(accountId: string)`: Delete an account.
|
||||||
- `getAutoSwitchEnabled()` / `setAutoSwitchEnabled(enabled)`: 获取/设置自动切换开关。
|
- `getAutoSwitchEnabled()` / `setAutoSwitchEnabled(enabled)`: Get/set auto-switch state.
|
||||||
- `forcePollCloudMonitor()`: 手动触发后台轮询。
|
- `forcePollCloudMonitor()`: Manually trigger background polling.
|
||||||
|
|
||||||
### 2.3 Token 注入原理 (`ProtobufUtils`)
|
### 2.3 Token Injection Mechanism (`ProtobufUtils`)
|
||||||
|
|
||||||
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
|
The IDE stores authentication data in the `ItemTable` table of `state.vscdb`, under the key `jetskiStateSync.agentManagerInitState`.
|
||||||
value 是一个 Base64 编码的 Protobuf 二进制数据。
|
The value is Base64-encoded Protobuf binary.
|
||||||
|
|
||||||
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
|
We implemented `src/utils/protobuf.ts`, which can:
|
||||||
|
|
||||||
1. **解码**:解析 Varint 和 Length-Delimited 字段。
|
1. **Decode**: Parse Varint and length-delimited fields.
|
||||||
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
|
2. **Modify**: Locate and remove old Field 6 (`OAuthTokenInfo`).
|
||||||
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
|
3. **Rebuild**: Build a new Field 6 using new access/refresh tokens and insert it.
|
||||||
|
|
||||||
### 2.4 切换流程
|
### 2.4 Switching Workflow
|
||||||
|
|
||||||
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
|
1. **Token check**: Check whether the target account token is near expiration; refresh automatically if needed.
|
||||||
2. **停止进程**:优雅关闭 Antigravity 进程。
|
2. **Stop process**: Gracefully stop the Antigravity process.
|
||||||
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
|
3. **Inject token**: Call `CloudAccountRepo.injectCloudToken` to update the IDE database.
|
||||||
4. **更新状态**:将目标账号标记为 `is_active = 1`,其他账号设为 0。
|
4. **Update state**: Mark target account as `is_active = 1`, set others to `0`.
|
||||||
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
|
5. **Restart process**: Restart the IDE so it loads new credentials.
|
||||||
|
|
||||||
### 2.5 自动切换逻辑 (`AutoSwitchService`)
|
### 2.5 Auto-Switch Logic (`AutoSwitchService`)
|
||||||
|
|
||||||
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
|
1. **Monitor**: `CloudMonitorService` polls quotas for all accounts every 5 minutes.
|
||||||
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
|
2. **Evaluate**: If all key model quotas of the active account are below `5%`, or status is `rate_limited`.
|
||||||
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
|
3. **Select**: Filter accounts with `active` status and sufficient quota, then sort by remaining quota.
|
||||||
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
|
4. **Execute**: Automatically call `switchCloudAccount` and notify the user.
|
||||||
|
|
||||||
### 2.6 安全加固 (Security Hardening)
|
### 2.6 Security Hardening
|
||||||
|
|
||||||
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
|
- **Key management**: Use native OS credential stores (Windows Credential Manager / macOS Keychain) via `keytar` to securely store the AES-256 master key.
|
||||||
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
|
- **Data encryption**: Encrypt all sensitive fields (`token_json`, `quota_json`) with `AES-256-GCM` before writing to SQLite.
|
||||||
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
- **Auto migration**: Automatically detect and migrate legacy plaintext data at startup to ensure a smooth security upgrade.
|
||||||
@@ -25,4 +25,14 @@ export default defineConfig([
|
|||||||
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
+420
-9
@@ -1,27 +1,216 @@
|
|||||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
import crypto from 'crypto';
|
||||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
|
||||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
|
||||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
|
||||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
|
||||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
|
||||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
|
||||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||||
|
import type {
|
||||||
|
HookFunction,
|
||||||
|
HookFunctionErrorCallback,
|
||||||
|
TargetArch,
|
||||||
|
TargetPlatform,
|
||||||
|
} from '@electron/packager';
|
||||||
|
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||||
|
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||||
|
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||||
|
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||||
|
import { MakerWix } from '@electron-forge/maker-wix';
|
||||||
|
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||||
|
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||||
|
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||||
|
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||||
|
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
|
||||||
|
import setLanguages from 'electron-packager-languages';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
|
|
||||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||||
|
const ResolvedMakerAppImage = MakerAppImage;
|
||||||
|
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
||||||
|
const windowsExecutableName = 'antigravity-manager';
|
||||||
|
|
||||||
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||||
|
|
||||||
|
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/;
|
||||||
|
const platformNamesMap: Record<string, string> = {
|
||||||
|
darwin: 'macos',
|
||||||
|
linux: 'linux',
|
||||||
|
win32: 'windows',
|
||||||
|
};
|
||||||
|
const ymlBaseNameMap: Record<string, string> = {
|
||||||
|
darwin: 'latest-mac',
|
||||||
|
linux: 'latest-linux',
|
||||||
|
win32: 'latest',
|
||||||
|
};
|
||||||
|
const ignorePatterns = [
|
||||||
|
/^\/\.git/,
|
||||||
|
/^\/\.github/,
|
||||||
|
/^\/\.vscode/,
|
||||||
|
/^\/\.idea/,
|
||||||
|
/^\/openspec/,
|
||||||
|
/^\/docs?/,
|
||||||
|
/^\/scripts?/,
|
||||||
|
/^\/tests?/,
|
||||||
|
/^\/mocks?/,
|
||||||
|
/^\/src/,
|
||||||
|
/^\/node_modules\/\.cache/,
|
||||||
|
];
|
||||||
|
const setLanguagesHook = setLanguages([...keepLanguages.values()]);
|
||||||
|
const packagerAfterCopy: HookFunction[] = [
|
||||||
|
(
|
||||||
|
buildPath: string,
|
||||||
|
electronVersion: string,
|
||||||
|
platform: TargetPlatform,
|
||||||
|
arch: TargetArch,
|
||||||
|
callback: HookFunctionErrorCallback,
|
||||||
|
) => {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguagesHook(buildPath, electronVersion, platform, arch, callback);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeArtifactName(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return 'app';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '.')
|
||||||
|
.replace(/[^a-zA-Z0-9.]/g, '')
|
||||||
|
.replace(/\.+/g, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSquirrelArtifact(artifactPath: string) {
|
||||||
|
const fileName = path.basename(artifactPath);
|
||||||
|
if (fileName === 'RELEASES') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifactPath.endsWith('.nupkg');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapArchName(arch: string, mapping: Record<string, string>) {
|
||||||
|
return mapping[arch] || arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArtifactFileName({
|
||||||
|
baseName,
|
||||||
|
version,
|
||||||
|
arch,
|
||||||
|
extension,
|
||||||
|
}: {
|
||||||
|
baseName: string;
|
||||||
|
version: string;
|
||||||
|
arch: string;
|
||||||
|
extension: string;
|
||||||
|
}) {
|
||||||
|
if (extension === '.rpm') {
|
||||||
|
return `${baseName}-${version}-1.${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.deb') {
|
||||||
|
return `${baseName}_${version}_${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.AppImage') {
|
||||||
|
return `${baseName}_${version}_${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.dmg') {
|
||||||
|
return `${baseName}_${version}_${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.exe') {
|
||||||
|
return `${baseName}_${version}_${arch}-setup${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.msi') {
|
||||||
|
return `${baseName}_${version}_${arch}_en-US${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === '.zip') {
|
||||||
|
return `${baseName}_${version}_${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseName}_${version}_${arch}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateYmlFileName(platform: string, arch: string) {
|
||||||
|
const baseName = ymlBaseNameMap[platform];
|
||||||
|
if (!baseName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return arch === 'universal' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChecksumArchLabel(platform: string, arch: string) {
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return mapArchName(arch, { x64: 'amd64', arm64: 'aarch64' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return mapArchName(arch, { x64: 'x64', arm64: 'arm64', universal: 'universal' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return mapArchName(arch, { x64: 'x64', arm64: 'arm64' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appImageMaker = new ResolvedMakerAppImage({
|
||||||
|
config: {
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
file: 'images/32x32.png',
|
||||||
|
size: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'images/64x64.png',
|
||||||
|
size: 64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'images/128x128.png',
|
||||||
|
size: 128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'images/[email protected]',
|
||||||
|
size: 256,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
|
||||||
|
|
||||||
const config: ForgeConfig = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: {
|
asar: {
|
||||||
unpack: '**/{better-sqlite3,keytar}/**/*',
|
unpack: '**/{better-sqlite3,keytar}/**/*',
|
||||||
},
|
},
|
||||||
name: 'Antigravity Manager',
|
name: 'Antigravity Manager',
|
||||||
executableName: 'antigravity-manager',
|
executableName: windowsExecutableName,
|
||||||
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
|
||||||
|
afterCopy: packagerAfterCopy,
|
||||||
|
ignore: ignorePatterns,
|
||||||
|
prune: true,
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
hooks: {
|
hooks: {
|
||||||
@@ -62,8 +251,230 @@ const config: ForgeConfig = {
|
|||||||
console.warn(`Assets directory not found: ${assetsSrc}`);
|
console.warn(`Assets directory not found: ${assetsSrc}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
postMake: async (_config, makeResults) => {
|
||||||
|
if (!makeResults?.length) {
|
||||||
|
return makeResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ymlByTarget = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
basePath: string;
|
||||||
|
fileName: string;
|
||||||
|
yml: {
|
||||||
|
version?: string;
|
||||||
|
files: {
|
||||||
|
url: string;
|
||||||
|
sha512: string;
|
||||||
|
size: number;
|
||||||
|
}[];
|
||||||
|
releaseDate?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const checksumByTarget = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
basePath: string;
|
||||||
|
fileName: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
makeResults = makeResults.map((result) => {
|
||||||
|
const productName = normalizeArtifactName(
|
||||||
|
result.packageJSON.productName || result.packageJSON.name,
|
||||||
|
);
|
||||||
|
const platformName = platformNamesMap[result.platform] || result.platform;
|
||||||
|
const version = result.packageJSON.version;
|
||||||
|
const platformKey = result.platform;
|
||||||
|
const archKey = result.arch;
|
||||||
|
const updateFileName = getUpdateYmlFileName(platformKey, archKey);
|
||||||
|
const updateKey = updateFileName ? `${platformKey}-${archKey}` : null;
|
||||||
|
const checksumKey = `${platformKey}-${archKey}`;
|
||||||
|
const checksumArchLabel = getChecksumArchLabel(platformKey, archKey);
|
||||||
|
const checksumFileName = `sha256sums-${platformName}-${checksumArchLabel}.txt`;
|
||||||
|
|
||||||
|
if (!checksumByTarget.has(checksumKey)) {
|
||||||
|
checksumByTarget.set(checksumKey, {
|
||||||
|
basePath: '',
|
||||||
|
fileName: checksumFileName,
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFileName && updateKey && !ymlByTarget.has(updateKey)) {
|
||||||
|
ymlByTarget.set(updateKey, {
|
||||||
|
basePath: '',
|
||||||
|
fileName: updateFileName,
|
||||||
|
yml: {
|
||||||
|
version,
|
||||||
|
files: [],
|
||||||
},
|
},
|
||||||
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateState = updateKey ? ymlByTarget.get(updateKey)! : null;
|
||||||
|
const checksumState = checksumByTarget.get(checksumKey)!;
|
||||||
|
|
||||||
|
result.artifacts = result.artifacts
|
||||||
|
.map((artifact) => {
|
||||||
|
if (!artifact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSquirrelArtifact(artifact)) {
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artifactRegex.test(artifact)) {
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checksumState.basePath) {
|
||||||
|
checksumState.basePath = path.dirname(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateState && !updateState.basePath) {
|
||||||
|
updateState.basePath = path.dirname(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(artifact);
|
||||||
|
let archLabel = archKey;
|
||||||
|
if (platformKey === 'linux' && extension === '.rpm') {
|
||||||
|
archLabel = mapArchName(archKey, { x64: 'x86_64', arm64: 'aarch64' });
|
||||||
|
} else if (platformKey === 'linux' && extension === '.deb') {
|
||||||
|
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'arm64' });
|
||||||
|
} else if (platformKey === 'linux' && extension === '.AppImage') {
|
||||||
|
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'aarch64' });
|
||||||
|
} else if (platformKey === 'darwin') {
|
||||||
|
archLabel = mapArchName(archKey, {
|
||||||
|
x64: 'x64',
|
||||||
|
arm64: 'arm64',
|
||||||
|
universal: 'universal',
|
||||||
|
});
|
||||||
|
} else if (platformKey === 'win32') {
|
||||||
|
archLabel = mapArchName(archKey, { x64: 'x64', arm64: 'arm64' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newArtifact = `${path.dirname(artifact)}/${getArtifactFileName({
|
||||||
|
baseName: productName,
|
||||||
|
version,
|
||||||
|
arch: archLabel,
|
||||||
|
extension,
|
||||||
|
})}`;
|
||||||
|
if (newArtifact !== artifact) {
|
||||||
|
fs.renameSync(artifact, newArtifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileData = fs.readFileSync(newArtifact);
|
||||||
|
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
|
||||||
|
const { size } = fs.statSync(newArtifact);
|
||||||
|
|
||||||
|
if (updateState) {
|
||||||
|
updateState.yml.files.push({
|
||||||
|
url: path.basename(newArtifact),
|
||||||
|
sha512: hash,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checksumState.lines.push(`${sha256} ${path.basename(newArtifact)}`);
|
||||||
|
} catch {
|
||||||
|
console.error(`Failed to hash ${newArtifact}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArtifact;
|
||||||
|
})
|
||||||
|
.filter((artifact) => artifact !== null);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseDate = new Date().toISOString();
|
||||||
|
for (const [updateKey, updateState] of ymlByTarget.entries()) {
|
||||||
|
if (!updateState.basePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState.yml.releaseDate = releaseDate;
|
||||||
|
const ymlPath = path.join(updateState.basePath, updateState.fileName);
|
||||||
|
fs.writeFileSync(ymlPath, yamlStringify(updateState.yml));
|
||||||
|
|
||||||
|
const [platform, arch] = updateKey.split('-');
|
||||||
|
const sampleResult = makeResults.find(
|
||||||
|
(result) => result.platform === platform && result.arch === arch,
|
||||||
|
);
|
||||||
|
if (!sampleResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
makeResults.push({
|
||||||
|
artifacts: [ymlPath],
|
||||||
|
platform: sampleResult.platform,
|
||||||
|
arch: sampleResult.arch,
|
||||||
|
packageJSON: sampleResult.packageJSON,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [checksumKey, checksumState] of checksumByTarget.entries()) {
|
||||||
|
if (!checksumState.basePath || checksumState.lines.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksumPath = path.join(checksumState.basePath, checksumState.fileName);
|
||||||
|
fs.writeFileSync(checksumPath, `${checksumState.lines.join('\n')}\n`);
|
||||||
|
|
||||||
|
const [platform, arch] = checksumKey.split('-');
|
||||||
|
const sampleResult = makeResults.find(
|
||||||
|
(result) => result.platform === platform && result.arch === arch,
|
||||||
|
);
|
||||||
|
if (!sampleResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
makeResults.push({
|
||||||
|
artifacts: [checksumPath],
|
||||||
|
platform: sampleResult.platform,
|
||||||
|
arch: sampleResult.arch,
|
||||||
|
packageJSON: sampleResult.packageJSON,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeResults;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
makers: [
|
||||||
|
new MakerSquirrel({
|
||||||
|
setupIcon: 'images/icon.ico',
|
||||||
|
iconUrl:
|
||||||
|
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
|
||||||
|
}),
|
||||||
|
...(process.platform === 'win32' && process.arch === 'x64'
|
||||||
|
? [
|
||||||
|
new MakerWix({
|
||||||
|
language: 1033,
|
||||||
|
icon: path.join(process.cwd(), 'images', 'icon.ico'),
|
||||||
|
exe: `${windowsExecutableName}.exe`,
|
||||||
|
ui: { chooseDirectory: true },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
new MakerDMG(
|
||||||
|
{
|
||||||
|
overwrite: true,
|
||||||
|
icon: 'images/icon.icns',
|
||||||
|
iconSize: 160,
|
||||||
|
},
|
||||||
|
['darwin'],
|
||||||
|
),
|
||||||
|
new MakerZIP({}, ['darwin']),
|
||||||
|
appImageMaker,
|
||||||
|
new MakerRpm({}),
|
||||||
|
new MakerDeb({}),
|
||||||
|
],
|
||||||
publishers: [
|
publishers: [
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
# 设计:多语言支持
|
# Design: Multilingual Support
|
||||||
|
|
||||||
## 架构
|
## Architecture
|
||||||
|
|
||||||
应用使用 `react-i18next` 进行国际化,并配合 `i18next-browser-languagedetector` 进行语言检测。配置集中在 `src/localization/i18n.ts` 中。
|
The application uses `react-i18next` for i18n and `i18next-browser-languagedetector` for language detection. Configuration is centralized in `src/localization/i18n.ts`.
|
||||||
|
|
||||||
### 语言资源
|
### Language Resources
|
||||||
|
|
||||||
翻译资源将作为 JSON 对象构建在 `i18n.ts` 中(如果太大则分文件)。
|
Translation resources are built as JSON objects in `i18n.ts` (or split into files if they grow large).
|
||||||
我们将在 `resources` 对象中添加 `zh-CN` 键。
|
We add a `zh-CN` key in the `resources` object.
|
||||||
|
|
||||||
### 默认语言策略
|
### Default Language Strategy
|
||||||
|
|
||||||
我们将引入 `LanguageDetector` 插件。
|
We introduce the `LanguageDetector` plugin.
|
||||||
`i18n.use(LanguageDetector).init({...})` 将被配置为首先检查 `localStorage`,然后检查 `navigator`(浏览器/系统语言)。
|
`i18n.use(LanguageDetector).init({...})` is configured to check `localStorage` first, then `navigator` (browser/system language).
|
||||||
`fallbackLng` 将保持为 `'en'`,作为最后的兜底。
|
`fallbackLng` remains `'en'` as the final fallback.
|
||||||
|
|
||||||
### 键映射
|
### Key Mapping
|
||||||
|
|
||||||
我们将把现有的英文字符串映射到语义键(例如 `home.title`, `settings.theme`)。
|
Existing English strings are mapped to semantic keys (for example, `home.title`, `settings.theme`).
|
||||||
|
|
||||||
## 数据流
|
## Data Flow
|
||||||
|
|
||||||
1. 应用启动。
|
1. App starts.
|
||||||
2. `i18next` 初始化,通过 `LanguageDetector` 确定语言:
|
2. `i18next` initializes and determines language via `LanguageDetector`:
|
||||||
* 检查 `localStorage` 是否有保存的偏好。
|
* Check whether `localStorage` has a saved preference.
|
||||||
* 如果无,检查 `navigator.language`。
|
* If not, check `navigator.language`.
|
||||||
* 如果检测到的语言受支持(`en` 或 `zh-CN`),则使用该语言。
|
* If detected language is supported (`en` or `zh-CN`), use it.
|
||||||
* 否则,使用 `fallbackLng` (`en`)。
|
* Otherwise, use `fallbackLng` (`en`).
|
||||||
3. 组件使用 `useTranslation` hook 根据当前语言获取字符串。
|
3. Components use `useTranslation` to resolve strings for the active language.
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
# 提案:应用本地化(自动检测系统语言)
|
# Proposal: App Localization (Automatic System Language Detection)
|
||||||
|
|
||||||
**变更 ID:** `localize-app-auto-detect`
|
**Change ID:** `localize-app-auto-detect`
|
||||||
|
|
||||||
## 摘要
|
## Summary
|
||||||
|
|
||||||
为 Antigravity Manager 应用添加多语言支持,并根据用户的操作系统语言自动设置默认语言。如果系统语言不受支持,则回退到英语。这包括更新 i18n 配置,添加中文翻译资源,并集成语言检测插件。
|
Add multilingual support to Antigravity Manager and automatically set the default UI language based on the user's OS language. If the OS language is unsupported, fall back to English. This includes updating i18n configuration, adding Chinese translation resources, and integrating a language detection plugin.
|
||||||
|
|
||||||
## 动机
|
## Motivation
|
||||||
|
|
||||||
为了提供最佳的用户体验,应用应该能够适应用户的首选语言环境。自动检测系统语言可以减少用户的配置步骤,提供更自然的初次使用体验。
|
To provide a better user experience, the app should adapt to users' preferred language environment. Automatically detecting system language reduces setup effort and improves first-run experience.
|
||||||
|
|
||||||
## 建议变更
|
## Proposed Changes
|
||||||
|
|
||||||
1. **集成语言检测:** 安装并配置 `i18next-browser-languagedetector` 以自动检测用户语言。
|
1. **Integrate language detection:** Install and configure `i18next-browser-languagedetector` to auto-detect user language.
|
||||||
2. **更新 i18n 配置:** 修改 `src/localization/i18n.ts`,启用语言检测,并设置适当的回退语言(英语)。
|
2. **Update i18n config:** Modify `src/localization/i18n.ts` to enable language detection and define a fallback language (English).
|
||||||
3. **添加中文资源:** 为 `zh-CN` 创建一个新的翻译资源文件,包含所有应用字符串。
|
3. **Add Chinese resources:** Create `zh-CN` translation resources covering all app strings.
|
||||||
4. **更新语言操作:** 确保 `src/actions/language.ts` 正确处理自动检测和手动覆盖的逻辑。
|
4. **Update language actions:** Ensure `src/actions/language.ts` correctly handles auto-detection and manual override.
|
||||||
5. **UI 更新:** 确保所有 UI 组件使用翻译键而不是硬编码字符串。
|
5. **UI updates:** Ensure all UI components use translation keys instead of hardcoded text.
|
||||||
|
|
||||||
## 考虑的替代方案
|
## Alternatives Considered
|
||||||
|
|
||||||
* **强制默认中文:** 已拒绝,为了适应更广泛的国际用户群体,并尊重用户的系统设置。
|
* **Force default Chinese:** Rejected, to support a broader international user base and respect system settings.
|
||||||
* **强制默认英语:** 已拒绝,同上。
|
* **Force default English:** Rejected, same reason.
|
||||||
|
|
||||||
## 风险
|
## Risks
|
||||||
|
|
||||||
* **检测失败:** 在某些特殊环境(如 Linux 某些发行版)下,语言检测可能不准确,需要确保回退机制可靠。
|
* **Detection failure:** In certain environments (for example, some Linux distros), language detection may be inaccurate. Fallback behavior must remain reliable.
|
||||||
|
|
||||||
## 验收标准
|
## Acceptance Criteria
|
||||||
|
|
||||||
1. **自动检测:**
|
1. **Automatic detection:**
|
||||||
* 在系统语言设置为中文(简体)的环境下启动应用,应用界面默认显示为中文。
|
* When OS language is Simplified Chinese, app defaults to Chinese on first launch.
|
||||||
* 在系统语言设置为英语或其他非中文语言的环境下启动应用,应用界面默认显示为英语。
|
* When OS language is English or another non-Chinese language, app defaults to English on first launch.
|
||||||
2. **手动切换:**
|
2. **Manual switching:**
|
||||||
* 用户可以在设置页面将语言从中文切换到英语,界面应立即更新,无需重启。
|
* Users can switch from Chinese to English in Settings, and UI updates immediately without restart.
|
||||||
* 用户可以在设置页面将语言从英语切换到中文,界面应立即更新,无需重启。
|
* Users can switch from English to Chinese in Settings, and UI updates immediately without restart.
|
||||||
3. **持久化:**
|
3. **Persistence:**
|
||||||
* 用户手动更改语言后,重启应用,应用应保持用户选择的语言,而不是重新检测系统语言。
|
* After users manually change language, app restart preserves the selected language instead of re-detecting.
|
||||||
4. **完整性:**
|
4. **Completeness:**
|
||||||
* 应用的所有可见文本(包括主页、设置页、状态栏、弹窗提示)都应正确显示对应的语言,没有遗漏的硬编码字符串。
|
* All visible strings (home page, settings page, status bar, popups) render correctly in the selected language with no leftover hardcoded text.
|
||||||
@@ -1,59 +1,59 @@
|
|||||||
# 规格:本地化
|
# Spec: Localization
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: 自动语言检测
|
### Requirement: Automatic Language Detection
|
||||||
|
|
||||||
应用在首次启动时 MUST 自动检测用户的系统语言并设置相应的界面语言。
|
On first launch, the application MUST automatically detect the user's system language and set the corresponding UI language.
|
||||||
|
|
||||||
#### Scenario: 系统语言为中文
|
#### Scenario: System language is Chinese
|
||||||
|
|
||||||
Given 用户首次打开应用
|
Given the user opens the app for the first time
|
||||||
And 用户的系统语言设置为中文 (zh-CN)
|
And the user's system language is set to Chinese (`zh-CN`)
|
||||||
And 本地存储中没有保存语言偏好
|
And no language preference is saved in local storage
|
||||||
When 应用加载时
|
When the app loads
|
||||||
Then 界面应该显示为简体中文 (`zh-CN`)
|
Then the UI should be displayed in Simplified Chinese (`zh-CN`)
|
||||||
|
|
||||||
#### Scenario: 系统语言为其他(非中文)
|
#### Scenario: System language is non-Chinese
|
||||||
|
|
||||||
Given 用户首次打开应用
|
Given the user opens the app for the first time
|
||||||
And 用户的系统语言设置为法语 (fr)
|
And the user's system language is set to French (`fr`)
|
||||||
And 本地存储中没有保存语言偏好
|
And no language preference is saved in local storage
|
||||||
When 应用加载时
|
When the app loads
|
||||||
Then 界面应该回退显示为英语 (`en`)
|
Then the UI should fall back to English (`en`)
|
||||||
|
|
||||||
### Requirement: 语言切换
|
### Requirement: Language Switching
|
||||||
|
|
||||||
用户 MUST 能够在支持的语言之间切换,且手动选择 MUST 覆盖自动检测。
|
Users MUST be able to switch between supported languages, and manual selection MUST override automatic detection.
|
||||||
|
|
||||||
#### Scenario: 切换语言
|
#### Scenario: Switch language
|
||||||
|
|
||||||
Given 用户在设置页面
|
Given the user is on the settings page
|
||||||
When 用户从语言下拉菜单中选择 "English"
|
When the user selects "English" from the language dropdown
|
||||||
Then 界面应该立即切换为英语
|
Then the UI should switch to English immediately
|
||||||
And 偏好应该保存到本地存储
|
And the preference should be saved to local storage
|
||||||
|
|
||||||
### Requirement: 持久化
|
### Requirement: Persistence
|
||||||
|
|
||||||
用户的手动语言偏好 MUST 在会话之间持久化,并优先于系统语言。
|
The user's manual language preference MUST persist across sessions and take precedence over system language.
|
||||||
|
|
||||||
#### Scenario: 持久化语言偏好
|
#### Scenario: Persist language preference
|
||||||
|
|
||||||
Given 用户已手动选择 "English" 作为首选语言
|
Given the user has manually selected "English" as preferred language
|
||||||
And 用户的系统语言设置为中文
|
And the user's system language is Chinese
|
||||||
When 用户重启应用
|
When the user restarts the app
|
||||||
Then 界面应该显示为英语(尊重用户选择)
|
Then the UI should display English (respecting user preference)
|
||||||
|
|
||||||
## MODIFIED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: i18n 配置更新
|
### Requirement: Update i18n Configuration
|
||||||
|
|
||||||
i18n 配置 MUST 更新以支持语言检测插件。
|
i18n configuration MUST be updated to support the language detection plugin.
|
||||||
|
|
||||||
#### Scenario: i18n 配置
|
#### Scenario: i18n configuration
|
||||||
|
|
||||||
`src/localization/i18n.ts` 文件必须更新为:
|
`src/localization/i18n.ts` must be updated to:
|
||||||
|
|
||||||
1. 集成 `i18next-browser-languagedetector`。
|
1. Integrate `i18next-browser-languagedetector`.
|
||||||
2. 配置检测顺序:`['localStorage', 'navigator']`。
|
2. Configure detection order as `['localStorage', 'navigator']`.
|
||||||
3. 包含 `zh-CN` 资源对象。
|
3. Include `zh-CN` resource objects.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# 任务:应用本地化(自动检测系统语言)
|
# Tasks: App Localization (Automatic System Language Detection)
|
||||||
|
|
||||||
* \[x] 安装 `i18next-browser-languagedetector` 依赖。 <!-- id: install-dependency -->
|
* \[x] Install `i18next-browser-languagedetector`. <!-- id: install-dependency -->
|
||||||
* \[x] 更新 `src/localization/i18n.ts` 以配置语言检测器并包含 `zh-CN` 资源。 <!-- id: update-i18n-config -->
|
* \[x] Update `src/localization/i18n.ts` to configure language detection and include `zh-CN` resources. <!-- id: update-i18n-config -->
|
||||||
* \[x] 验证 `src/actions/language.ts` 逻辑,确保与自动检测兼容。 <!-- id: verify-language-actions -->
|
* \[x] Verify `src/actions/language.ts` logic is compatible with auto-detection. <!-- id: verify-language-actions -->
|
||||||
* \[x] 更新 `src/components/StatusBar.tsx` 以使用翻译。 <!-- id: localize-statusbar -->
|
* \[x] Update `src/components/StatusBar.tsx` to use translations. <!-- id: localize-statusbar -->
|
||||||
* \[x] 更新 `src/components/AccountCard.tsx` 以使用翻译。 <!-- id: localize-accountcard -->
|
* \[x] Update `src/components/AccountCard.tsx` to use translations. <!-- id: localize-accountcard -->
|
||||||
* \[x] 更新 `src/pages/index.tsx`(主页)以使用翻译。 <!-- id: localize-homepage -->
|
* \[x] Update `src/pages/index.tsx` (home page) to use translations. <!-- id: localize-homepage -->
|
||||||
* \[x] 更新 `src/pages/settings.tsx` 以使用翻译并添加语言切换器。 <!-- id: localize-settings -->
|
* \[x] Update `src/pages/settings.tsx` to use translations and add a language switcher. <!-- id: localize-settings -->
|
||||||
* \[x] 运行应用验证语言检测和手动切换功能。 <!-- id: verify-localization -->
|
* \[x] Run the app to verify language detection and manual switching. <!-- id: verify-localization -->
|
||||||
+97
-94
@@ -2,105 +2,108 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Antigravity Manager 是一个综合性的 Antigravity 增强工具,旨在打破 AI 开发的资源限制。它不仅解决了 Antigravity IDE 客户端无法原生支持多账号切换的痛点,还引入了企业级的 AI 服务账号池管理功能。通过接管应用的配置状态和云端 API 资源,它允许用户在无限个本地账号和云端 AI 服务之间无缝切换,实现 "无限" 的 AI 调用体验。
|
Antigravity Manager is a comprehensive enhancement tool for Antigravity, designed to remove AI development resource limits. It not only solves the lack of native multi-account switching in Antigravity IDE, but also introduces enterprise-grade AI account pool management. By taking control of app configuration state and cloud API resources, it allows users to switch seamlessly between unlimited local accounts and cloud AI services for an effectively "unlimited" AI usage experience.
|
||||||
|
|
||||||
**核心目标:**
|
**Core goals:**
|
||||||
|
|
||||||
* **本地多账号切换**:无缝切换 Antigravity IDE 的本地用户状态,保护数据完整性(自动备份)。
|
* **Local multi-account switching**: Seamlessly switch Antigravity IDE local user state while protecting data integrity (automatic backup).
|
||||||
* **AI 资源池管理**(新):集中管理 Google Gemini、Claude 3.5 等 AI 服务的账号池。
|
* **AI resource pool management (new)**: Centrally manage account pools for Google Gemini, Claude 3.5, and similar services.
|
||||||
* **智能配额监控**(新):实时监控 API 配额(Quota)和健康度,提供可视化仪表盘。
|
* **Intelligent quota monitoring (new)**: Monitor API quota and health in real time with a visual dashboard.
|
||||||
* **自动资源调度**(新):当当前账号配额耗尽时,自动切换到最佳可用账号(Token 保活与轮询)。
|
* **Automatic resource scheduling (new)**: Automatically switch to the best available account when the current one runs out of quota (token keepalive + polling).
|
||||||
* **跨平台支持**:统一的 macOS、Windows、Linux 体验。
|
* **Cross-platform support**: Consistent macOS, Windows, and Linux experience.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
* **Electron 39** - 跨平台桌面应用框架
|
* **Electron 39** - Cross-platform desktop framework
|
||||||
* **React 19.2** - UI 框架
|
* **React 19.2** - UI framework
|
||||||
* **TypeScript 5.9** - 类型安全的开发语言
|
* **TypeScript 5.9** - Type-safe development language
|
||||||
* **Shadcn UI** - 现代化 UI 组件库
|
* **Shadcn UI** - Modern UI component library
|
||||||
* **Tailwind CSS 4** - 样式框架
|
* **Tailwind CSS 4** - Styling framework
|
||||||
* **oRPC** - 类型安全的 IPC 通信
|
* **oRPC** - Type-safe IPC communication
|
||||||
* **TanStack Router** - 文件路由
|
* **TanStack Router** - File-based routing
|
||||||
* **React Query** - 服务器状态管理
|
* **React Query** - Server state management
|
||||||
* **better-sqlite3** - SQLite 数据库访问
|
* **better-sqlite3** - SQLite data access
|
||||||
* **Vite** - 构建工具
|
* **Vite** - Build tool
|
||||||
* **Vitest** - 单元测试
|
* **Vitest** - Unit testing
|
||||||
* **Playwright** - 端到端测试
|
* **Playwright** - End-to-end testing
|
||||||
* **Electron Forge** - 应用打包和分发
|
* **Electron Forge** - Packaging and distribution
|
||||||
|
|
||||||
## Project Conventions
|
## Project Conventions
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
|
|
||||||
* TypeScript strict mode
|
* TypeScript strict mode
|
||||||
* ESLint + Prettier 自动格式化
|
* ESLint + Prettier auto-formatting
|
||||||
* 函数和变量使用 camelCase
|
* Use camelCase for functions and variables
|
||||||
* 组件使用 PascalCase
|
* Use PascalCase for components
|
||||||
* 接口使用 PascalCase,以 I 开头(可选)
|
* Use PascalCase for interfaces, optionally prefixed with `I`
|
||||||
* 类型使用 PascalCase
|
* Use PascalCase for types
|
||||||
* 2 空格缩进
|
* 2-space indentation
|
||||||
* 单引号字符串
|
* Single-quoted strings
|
||||||
* 尾随逗号
|
* Trailing commas
|
||||||
|
|
||||||
### Architecture Patterns
|
### Architecture Patterns
|
||||||
|
|
||||||
```
|
```plaintext
|
||||||
src/
|
src/
|
||||||
├── main.ts # Electron 主进程
|
├── main.ts # Electron main process
|
||||||
├── preload.ts # 预加载脚本
|
├── preload.ts # Preload script
|
||||||
├── renderer.ts # 渲染进程入口
|
├── renderer.ts # Renderer entry
|
||||||
├── ipc/ # IPC 通信层
|
├── ipc/ # IPC communication layer
|
||||||
│ ├── account/ # 本地 IDE 账号管理 IPC
|
│ ├── account/ # Local IDE account IPC
|
||||||
│ ├── cloud/ # 云端 AI 资源管理 IPC(新)
|
│ ├── cloud/ # Cloud AI resource IPC (new)
|
||||||
│ ├── database/ # 数据库操作 IPC
|
│ ├── database/ # Database operation IPC
|
||||||
│ └── process/ # 进程管理 IPC
|
│ └── process/ # Process management IPC
|
||||||
├── services/ # 业务逻辑服务层(新)
|
├── services/ # Business service layer (new)
|
||||||
│ ├── QuotaService.ts # 配额监控服务
|
│ ├── QuotaService.ts # Quota monitoring service
|
||||||
│ └── TokenService.ts # Token 保活服务
|
│ └── TokenService.ts # Token keepalive service
|
||||||
├── actions/ # 渲染进程操作(oRPC 客户端)
|
├── actions/ # Renderer actions (oRPC client)
|
||||||
├── components/ # React 组件
|
├── components/ # React components
|
||||||
├── routes/ # 页面路由
|
├── routes/ # Page routes
|
||||||
├── layouts/ # 布局组件
|
├── layouts/ # Layout components
|
||||||
├── utils/ # 工具函数
|
├── utils/ # Utility functions
|
||||||
├── types/ # TypeScript 类型定义
|
├── types/ # TypeScript type definitions
|
||||||
└── tests/ # 测试文件
|
└── tests/ # Test files
|
||||||
```
|
```
|
||||||
|
|
||||||
**设计模式:**
|
**Design patterns:**
|
||||||
|
|
||||||
* **关注点分离**:主进程处理系统操作和网络代理,渲染进程处理 UI。
|
* **Separation of concerns**: Main process handles system operations and network proxy; renderer handles UI.
|
||||||
* **Service 层**:引入 Service 层处理复杂的配额轮询和 Token 刷新逻辑。
|
* **Service layer**: Encapsulates complex quota polling and token refresh logic.
|
||||||
* **类型安全 IPC**:使用 oRPC 确保主进程和渲染进程之间的类型安全通信。
|
* **Type-safe IPC**: Uses oRPC to enforce type-safe communication between main and renderer.
|
||||||
* **状态管理**:React Query 管理服务器状态和异步配额数据。
|
* **State management**: React Query manages server state and async quota data.
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
|
|
||||||
* **单元测试(Vitest)**:测试 IPC 处理程序、Service 逻辑、工具函数。
|
* **Unit tests (Vitest)**: IPC handlers, service logic, utility functions.
|
||||||
* **集成测试(Vitest)**:测试完整的 IPC 流程。
|
* **Integration tests (Vitest)**: End-to-end IPC flow.
|
||||||
* **端到端测试(Playwright)**:测试用户工作流。
|
* **End-to-end tests (Playwright)**: User workflows.
|
||||||
* **覆盖率目标**:核心逻辑 >80%。
|
* **Coverage target**: Core logic > 80%.
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
|
|
||||||
* **主分支**:`main`
|
* **Main branch**: `main`
|
||||||
* **开发分支**:`develop`
|
* **Development branch**: `develop`
|
||||||
* **提交规范**:语义化提交 (feat, fix, docs, refactor, test, chore)
|
* **Commit style**: Conventional commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`)
|
||||||
|
|
||||||
## Domain Context
|
## Domain Context
|
||||||
|
|
||||||
### 1. Antigravity IDE 数据结构(本地)
|
### 1. Antigravity IDE Data Structure (Local)
|
||||||
|
|
||||||
|
**Database locations:**
|
||||||
|
|
||||||
**数据库位置:**
|
|
||||||
* 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`
|
||||||
|
|
||||||
**关键数据库键:**
|
**Key database fields:**
|
||||||
* `antigravityAuthStatus`: 认证状态和用户信息。
|
|
||||||
|
|
||||||
### 2. AI 服务资源结构(云端/新)
|
* `antigravityAuthStatus`: Authentication status and user information.
|
||||||
|
|
||||||
|
### 2. AI Service Resource Structure (Cloud / New)
|
||||||
|
|
||||||
|
**Account model:**
|
||||||
|
|
||||||
**账号模型:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
@@ -115,49 +118,49 @@ src/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 进程管理策略
|
### 3. Process Management Strategy
|
||||||
|
|
||||||
* **三阶段关闭**:优雅关闭 -> SIGTERM -> SIGKILL。
|
* **Three-phase shutdown**: Graceful close -> SIGTERM -> SIGKILL.
|
||||||
|
|
||||||
## Important Constraints
|
## Important Constraints
|
||||||
|
|
||||||
### 技术约束
|
### Technical Constraints
|
||||||
|
|
||||||
* **数据库访问**:访问本地 IDE 数据库时需注意文件锁。
|
* **Database access**: Handle file locks when accessing local IDE databases.
|
||||||
* **网络访问**:需要访问外部 API (Google/Anthropic) 以获取配额信息。
|
* **Network access**: External API access (Google/Anthropic) is required for quota retrieval.
|
||||||
* **凭证安全**:OAuth Token 和 Refresh Token 必须使用系统级加密存储 (Keytar / SQLite Encrypted)。
|
* **Credential security**: OAuth and refresh tokens must use system-level encrypted storage (Keytar / encrypted SQLite).
|
||||||
* **并发控制**:轮询配额时需注意频率,避免触发 API 封控。
|
* **Concurrency control**: Quota polling frequency must be controlled to avoid API throttling or blocking.
|
||||||
|
|
||||||
### 用户体验约束
|
### UX Constraints
|
||||||
|
|
||||||
* **响应时间**:账号切换应在 5 秒内完成。
|
* **Response time**: Account switching should complete within 5 seconds.
|
||||||
* **静默运行**:支持最小化到托盘,后台自动刷新 Token。
|
* **Silent mode**: Support tray minimization and background token refresh.
|
||||||
* **数据安全**:切换前必须自动备份当前状态。
|
* **Data safety**: Always back up current state before switching.
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
|
|
||||||
### 系统依赖
|
### System Dependencies
|
||||||
|
|
||||||
* **Antigravity IDE**:本地管理目标。
|
* **Antigravity IDE**: Local target to manage.
|
||||||
* **SQLite**:本地数据读取。
|
* **SQLite**: Local data reads.
|
||||||
|
|
||||||
### Node.js 依赖(重点)
|
### Node.js Dependencies (Key)
|
||||||
|
|
||||||
* `better-sqlite3`: 数据库访问。
|
* `better-sqlite3`: Database access.
|
||||||
* `electron`: 桌面框架。
|
* `electron`: Desktop framework.
|
||||||
* `node-fetch` / `axios`: 网络请求。
|
* `node-fetch` / `axios`: Network requests.
|
||||||
* `tar` / `adm-zip`: 备份压缩。
|
* `tar` / `adm-zip`: Backup compression.
|
||||||
|
|
||||||
### 外部服务
|
### External Services
|
||||||
|
|
||||||
* **Google Gemini API**: 用于配额检查。
|
* **Google Gemini API**: For quota checks.
|
||||||
* **Anthropic API**: 用于配额检查。
|
* **Anthropic API**: For quota checks.
|
||||||
* **OAuth 2.0 端点**: 用于账号授权。
|
* **OAuth 2.0 endpoints**: For account authorization.
|
||||||
|
|
||||||
### 文件系统依赖
|
### Filesystem Dependencies
|
||||||
|
|
||||||
* `~/.antigravity-agent/`
|
* `~/.antigravity-agent/`
|
||||||
* `accounts.json`: 本地 IDE 账号索引。
|
* `accounts.json`: Local IDE account index.
|
||||||
* `cloud_accounts.db`: (新) 云端账号池数据库 (SQLite)。
|
* `cloud_accounts.db`: (new) Cloud account pool database (SQLite).
|
||||||
* `backups/`: 备份文件。
|
* `backups/`: Backup files.
|
||||||
* `app.log`: 应用日志。
|
* `app.log`: Application log.
|
||||||
Generated
+10001
-236
File diff suppressed because it is too large.
Load diff
+20
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.2.0",
|
"version": "0.7.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,
|
||||||
@@ -25,8 +25,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.10.2",
|
"@electron-forge/cli": "^7.10.2",
|
||||||
"@electron-forge/maker-deb": "^7.10.2",
|
"@electron-forge/maker-deb": "^7.10.2",
|
||||||
|
"@electron-forge/maker-dmg": "^7.10.2",
|
||||||
"@electron-forge/maker-rpm": "^7.10.2",
|
"@electron-forge/maker-rpm": "^7.10.2",
|
||||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||||
|
"@electron-forge/maker-wix": "^7.10.2",
|
||||||
"@electron-forge/maker-zip": "^7.10.2",
|
"@electron-forge/maker-zip": "^7.10.2",
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
||||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||||
@@ -37,7 +39,12 @@
|
|||||||
"@electron/rebuild": "^3.7.1",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
|
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
|
||||||
"@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",
|
||||||
@@ -53,8 +60,10 @@
|
|||||||
"@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-packager-languages": "^0.6.0",
|
||||||
"electron-playwright-helpers": "^1.8.2",
|
"electron-playwright-helpers": "^1.8.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -67,6 +76,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",
|
||||||
@@ -85,7 +96,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",
|
||||||
@@ -102,6 +112,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",
|
||||||
@@ -114,8 +127,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"drizzle-orm": "^0.39.1",
|
||||||
"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",
|
||||||
@@ -132,6 +147,9 @@
|
|||||||
"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",
|
||||||
|
"winston": "^3.19.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Semantic Release Configuration
|
||||||
|
*
|
||||||
|
* This configuration uses the specific release rules requested by the user,
|
||||||
|
* but adapts the plugin configuration to work with standard Conventional Commits
|
||||||
|
* instead of Gitmoji (since the project history doesn't use emojis).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
|
||||||
|
const GITHUB_BASE_URL = 'https://github.com';
|
||||||
|
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
||||||
|
|
||||||
|
const releaseRules = [
|
||||||
|
{
|
||||||
|
release: 'minor',
|
||||||
|
type: 'feat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'patch',
|
||||||
|
type: 'fix',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'patch',
|
||||||
|
type: 'perf',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'patch',
|
||||||
|
type: 'style',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'patch',
|
||||||
|
type: 'refactor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'patch',
|
||||||
|
type: 'build',
|
||||||
|
},
|
||||||
|
{ release: 'patch', scope: 'README', type: 'docs' },
|
||||||
|
{ release: 'patch', scope: 'README.md', type: 'docs' },
|
||||||
|
{ release: false, type: 'docs' },
|
||||||
|
{
|
||||||
|
release: false,
|
||||||
|
type: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: false,
|
||||||
|
type: 'ci',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: false,
|
||||||
|
type: 'chore',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: false,
|
||||||
|
type: 'wip',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'major',
|
||||||
|
type: 'BREAKING CHANGE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'major',
|
||||||
|
scope: 'BREAKING CHANGE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
release: 'major',
|
||||||
|
subject: '*BREAKING CHANGE*',
|
||||||
|
},
|
||||||
|
{ release: 'patch', subject: '*force release*' },
|
||||||
|
{ release: 'patch', subject: '*force patch*' },
|
||||||
|
{ release: 'minor', subject: '*force minor*' },
|
||||||
|
{ release: 'major', subject: '*force major*' },
|
||||||
|
{ release: false, subject: '*skip release*' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getGithubUsernameFromEmail = (email) => {
|
||||||
|
if (!email) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = email.match(/^(?:\d+\+)?(?<username>[a-z0-9-]+)@users\.noreply\.github\.com$/i);
|
||||||
|
|
||||||
|
return match?.groups?.username;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBotIdentity = (value) => {
|
||||||
|
return typeof value === 'string' && /bot/i.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildReleaseFooter = (commits) => {
|
||||||
|
const mergedPullRequests = new Map();
|
||||||
|
const contributors = new Map();
|
||||||
|
|
||||||
|
(commits || []).forEach((commit) => {
|
||||||
|
const message = [commit?.message, commit?.subject, commit?.header].filter(Boolean).join('\n');
|
||||||
|
if (message) {
|
||||||
|
const mergeMatches = message.match(/pull request #(?<number>\d+)/gi);
|
||||||
|
if (mergeMatches) {
|
||||||
|
mergeMatches.forEach((match) => {
|
||||||
|
const number = match.replace(/\D/g, '');
|
||||||
|
if (number) {
|
||||||
|
mergedPullRequests.set(number, {
|
||||||
|
number,
|
||||||
|
url: `${REPO_URL}/pull/${number}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const squashMatches = message.match(/\(#(?<number>\d+)\)/g);
|
||||||
|
if (squashMatches) {
|
||||||
|
squashMatches.forEach((match) => {
|
||||||
|
const number = match.replace(/\D/g, '');
|
||||||
|
if (number) {
|
||||||
|
mergedPullRequests.set(number, {
|
||||||
|
number,
|
||||||
|
url: `${REPO_URL}/pull/${number}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = commit?.author || commit?.committer;
|
||||||
|
const username = getGithubUsernameFromEmail(author?.email);
|
||||||
|
const displayName = username ? `@${username}` : author?.name;
|
||||||
|
|
||||||
|
if (!displayName || isBotIdentity(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username && isBotIdentity(username)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contributors.set(username || displayName, {
|
||||||
|
name: displayName,
|
||||||
|
url: username ? `${GITHUB_BASE_URL}/${username}` : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const footerLines = [];
|
||||||
|
const sortedPullRequests = Array.from(mergedPullRequests.values()).sort(
|
||||||
|
(left, right) => Number(left.number) - Number(right.number),
|
||||||
|
);
|
||||||
|
if (sortedPullRequests.length > 0) {
|
||||||
|
footerLines.push('### 🔀 Merged Pull Requests');
|
||||||
|
sortedPullRequests.forEach((pullRequest) => {
|
||||||
|
footerLines.push(`- [#${pullRequest.number}](${pullRequest.url})`);
|
||||||
|
});
|
||||||
|
footerLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedContributors = Array.from(contributors.values()).sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
);
|
||||||
|
if (sortedContributors.length > 0) {
|
||||||
|
footerLines.push('### 🙌 Contributors');
|
||||||
|
sortedContributors.forEach((contributor) => {
|
||||||
|
if (contributor.url) {
|
||||||
|
footerLines.push(`- [${contributor.name}](${contributor.url})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
footerLines.push(`- ${contributor.name}`);
|
||||||
|
});
|
||||||
|
footerLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
footerLines.push(`Full Changelog: ${FULL_CHANGELOG_URL}`);
|
||||||
|
|
||||||
|
return footerLines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendReleaseNotes = (notes, commits) => {
|
||||||
|
const trimmedNotes = (notes || '').trimEnd();
|
||||||
|
const footer = buildReleaseFooter(commits);
|
||||||
|
|
||||||
|
if (!footer) {
|
||||||
|
return trimmedNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${trimmedNotes}\n\n${footer}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendReleaseNotesPlugin = {
|
||||||
|
publish: async (pluginConfig, context) => {
|
||||||
|
if (!context?.nextRelease?.notes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.nextRelease.notes = appendReleaseNotes(context.nextRelease.notes, context.commits);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseConfig = {
|
||||||
|
branches: ['main', { name: 'beta', prerelease: true }],
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'@semantic-release/commit-analyzer',
|
||||||
|
{
|
||||||
|
preset: 'conventionalcommits',
|
||||||
|
releaseRules: releaseRules,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'@semantic-release/release-notes-generator',
|
||||||
|
{
|
||||||
|
preset: 'conventionalcommits',
|
||||||
|
presetConfig: {
|
||||||
|
types: [
|
||||||
|
{ type: 'feat', section: '✨ Features' },
|
||||||
|
{ type: 'fix', section: '🐛 Bug Fixes' },
|
||||||
|
{ type: 'perf', section: '⚡ Performance Improvements' },
|
||||||
|
{ type: 'revert', section: '⏪ Reverts' },
|
||||||
|
{ type: 'docs', section: '📝 Documentation' },
|
||||||
|
{ type: 'style', section: '💄 Styles' },
|
||||||
|
{ type: 'refactor', section: '♻️ Code Refactoring' },
|
||||||
|
{ type: 'test', section: '✅ Tests' },
|
||||||
|
{ type: 'build', section: '👷 Build System' },
|
||||||
|
{ type: 'ci', section: '🔧 Continuous Integration' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'@semantic-release/changelog',
|
||||||
|
{
|
||||||
|
changelogFile: 'CHANGELOG.md',
|
||||||
|
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
|
||||||
|
appendReleaseNotesPlugin,
|
||||||
|
[
|
||||||
|
'@semantic-release/github',
|
||||||
|
{
|
||||||
|
successComment: false,
|
||||||
|
failComment: false,
|
||||||
|
labels: false,
|
||||||
|
releaseName: 'v${nextRelease.version}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'@semantic-release/git',
|
||||||
|
{
|
||||||
|
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||||
|
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
releaseConfig.__internal = {
|
||||||
|
appendReleaseNotes,
|
||||||
|
buildReleaseFooter,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = releaseConfig;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ipc } from '@/ipc/manager';
|
||||||
|
|
||||||
|
export function openLogDirectory() {
|
||||||
|
return ipc.client.system.openLogDirectory();
|
||||||
|
}
|
||||||
@@ -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,7 +33,7 @@ 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 });
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -50,11 +50,64 @@ export function CloudAccountCard({
|
|||||||
|
|
||||||
// Helpers to get quota color
|
// Helpers to get quota color
|
||||||
const getQuotaColor = (percentage: number) => {
|
const getQuotaColor = (percentage: number) => {
|
||||||
if (percentage > 80) return 'text-green-500';
|
if (percentage > 80) {
|
||||||
if (percentage > 20) return 'text-yellow-500';
|
return 'text-green-500';
|
||||||
|
}
|
||||||
|
if (percentage > 20) {
|
||||||
|
return 'text-yellow-500';
|
||||||
|
}
|
||||||
return 'text-red-500';
|
return 'text-red-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTimeRemaining = (dateStr: string) => {
|
||||||
|
const targetDate = new Date(dateStr);
|
||||||
|
if (Number.isNaN(targetDate.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (isBefore(targetDate, now)) {
|
||||||
|
return '0h 0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate difference in hours and minutes
|
||||||
|
const diffHrs = differenceInHours(targetDate, now);
|
||||||
|
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
|
||||||
|
if (diffHrs >= 24) {
|
||||||
|
const diffDays = Math.floor(diffHrs / 24);
|
||||||
|
const remainingHrs = diffHrs % 24;
|
||||||
|
return `${diffDays}d ${remainingHrs}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${diffHrs}h ${diffMins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResetTimeLabel = (resetTime?: string) => {
|
||||||
|
if (!resetTime) {
|
||||||
|
return t('cloud.card.resetUnknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = formatTimeRemaining(resetTime);
|
||||||
|
if (!remaining) {
|
||||||
|
return t('cloud.card.resetUnknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResetTimeTitle = (resetTime?: string) => {
|
||||||
|
if (!resetTime) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetDate = new Date(resetTime);
|
||||||
|
if (Number.isNaN(resetDate.getTime())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
const modelQuotas = Object.entries(account.quota?.models || {});
|
const modelQuotas = Object.entries(account.quota?.models || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,15 +222,30 @@ export function CloudAccountCard({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{modelQuotas.length > 0 ? (
|
{modelQuotas.length > 0 ? (
|
||||||
modelQuotas.map(([modelName, info]) => (
|
modelQuotas.map(([modelName, info]) => (
|
||||||
<div key={modelName} className="flex items-center justify-between text-sm">
|
<div
|
||||||
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
|
key={modelName}
|
||||||
|
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||||
{modelName.replace('models/', '')}
|
{modelName.replace('models/', '')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
|
<span
|
||||||
|
className="text-muted-foreground text-[10px] leading-none"
|
||||||
|
title={getResetTimeTitle(info.resetTime)}
|
||||||
|
>
|
||||||
|
{getResetTimeLabel(info.resetTime)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||||
|
>
|
||||||
{info.percentage}%
|
{info.percentage}%
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{t('cloud.card.left')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -372,7 +394,7 @@ export function CloudAccountList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{accounts?.map((account) => (
|
{accounts?.map((account) => (
|
||||||
<CloudAccountCard
|
<CloudAccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Play, Square, Loader2 } from 'lucide-react';
|
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
export const StatusBar: React.FC = () => {
|
interface StatusBarProps {
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -40,45 +45,101 @@ 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 ? t('status.checking') : isRunning ? t('status.running') : t('status.stopped')}
|
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
||||||
|
<span className="text-sm leading-tight font-medium">
|
||||||
|
{isLoading
|
||||||
|
? t('status.checking')
|
||||||
|
: isRunning
|
||||||
|
? t('status.running')
|
||||||
|
: t('status.stopped')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
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" />
|
||||||
)}
|
)}
|
||||||
|
<span className="ml-2 font-semibold">
|
||||||
{isRunning ? t('action.stop') : t('action.start')}
|
{isRunning ? t('action.stop') : t('action.start')}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
logger.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);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from './handler';
|
} from './handler';
|
||||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export const cloudRouter = os.router({
|
export const cloudRouter = os.router({
|
||||||
addGoogleAccount: os
|
addGoogleAccount: os
|
||||||
@@ -72,7 +73,7 @@ export const cloudRouter = os.router({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||||
throw error;
|
throw 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveConfig(config: AppConfig): Promise<void> {
|
||||||
const configPath = this.getConfigPath();
|
const configPath = this.getConfigPath();
|
||||||
const content = JSON.stringify(config, null, 2);
|
const content = JSON.stringify(config, null, 2);
|
||||||
fs.writeFileSync(configPath, content, 'utf-8');
|
|
||||||
|
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}`);
|
logger.info(`Config: Saved to ${configPath}`);
|
||||||
} catch (e) {
|
})
|
||||||
|
.catch((e) => {
|
||||||
logger.error('Config: Failed to save config', e);
|
logger.error('Config: Failed to save config', e);
|
||||||
throw 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);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
+605
-226
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
export interface DbConfigOptions {
|
||||||
|
readOnly?: boolean;
|
||||||
|
busyTimeoutMs?: number;
|
||||||
|
enableWal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrizzleConnection {
|
||||||
|
raw: Database.Database;
|
||||||
|
orm: BetterSQLite3Database<typeof schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||||
|
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||||
|
const enableWal = options.enableWal ?? !options.readOnly;
|
||||||
|
if (enableWal) {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
}
|
||||||
|
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDrizzleConnection(
|
||||||
|
dbPath: string,
|
||||||
|
options: Database.Options = {},
|
||||||
|
config: DbConfigOptions = {},
|
||||||
|
): DrizzleConnection {
|
||||||
|
const raw = new Database(dbPath, options);
|
||||||
|
configureDatabase(raw, config);
|
||||||
|
const orm = drizzle(raw, { schema });
|
||||||
|
return { raw, orm };
|
||||||
|
}
|
||||||
+115
-55
@@ -1,12 +1,47 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||||
|
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||||
|
import { parseRow } from '../../utils/sqlite';
|
||||||
|
import { openDrizzleConnection } from './dbConnection';
|
||||||
|
import { itemTable } from './schema';
|
||||||
|
|
||||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||||
|
'antigravityAuthStatus',
|
||||||
|
'jetskiStateSync.agentManagerInitState',
|
||||||
|
'antigravityUnifiedStateSync.oauthToken',
|
||||||
|
];
|
||||||
|
|
||||||
|
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||||
|
return openDrizzleConnection(
|
||||||
|
dbPath,
|
||||||
|
{ readonly: readOnly, fileMustExist: false },
|
||||||
|
{ readOnly, busyTimeoutMs: 3000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { raw } = getDatabaseConnection();
|
||||||
|
raw.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.
|
||||||
@@ -47,9 +82,9 @@ function ensureDatabaseExists(dbPath: string): void {
|
|||||||
/**
|
/**
|
||||||
* Gets a database connection.
|
* Gets a database connection.
|
||||||
* @param dbPath {string} The path to the database file.
|
* @param dbPath {string} The path to the database file.
|
||||||
* @returns {Database.Database} The database connection.
|
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||||
*/
|
*/
|
||||||
export function getDatabaseConnection(dbPath?: string): Database.Database {
|
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||||
|
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
@@ -59,7 +94,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
ensureDatabaseExists(targetPath);
|
ensureDatabaseExists(targetPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
return openIdeDb(targetPath);
|
||||||
} 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,67 +104,86 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readItemValue(
|
||||||
|
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||||
|
key: string,
|
||||||
|
context: string,
|
||||||
|
): string | null {
|
||||||
|
const rows = orm
|
||||||
|
.select({ value: itemTable.value })
|
||||||
|
.from(itemTable)
|
||||||
|
.where(eq(itemTable.key, key))
|
||||||
|
.all();
|
||||||
|
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current account info.
|
* Gets the current account info.
|
||||||
* @returns {AccountInfo} The current account info.
|
* @returns {AccountInfo} The current account info.
|
||||||
*/
|
*/
|
||||||
export function getCurrentAccountInfo(): AccountInfo {
|
export function getCurrentAccountInfo(): AccountInfo {
|
||||||
// NOTE Database existence is now handled by getDatabaseConnection
|
// NOTE Database existence is now handled by getDatabaseConnection
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection();
|
connection = getDatabaseConnection();
|
||||||
|
const { orm } = connection;
|
||||||
|
|
||||||
// Query for auth status
|
// Query for auth status
|
||||||
const authRow = db
|
const authValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'antigravityAuthStatus',
|
||||||
|
'ide.itemTable.antigravityAuthStatus',
|
||||||
|
);
|
||||||
let authStatus = null;
|
let authStatus = null;
|
||||||
if (authRow) {
|
if (authValue) {
|
||||||
try {
|
try {
|
||||||
authStatus = JSON.parse(authRow.value);
|
authStatus = JSON.parse(authValue);
|
||||||
} catch {
|
} catch {
|
||||||
// NOTE Ignore JSON parse errors
|
// NOTE Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||||
const initRow = db
|
const initValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'jetskiStateSync.agentManagerInitState',
|
||||||
|
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||||
|
);
|
||||||
let initState = null;
|
let initState = null;
|
||||||
if (initRow) {
|
if (initValue) {
|
||||||
try {
|
try {
|
||||||
initState = JSON.parse(initRow.value);
|
initState = JSON.parse(initValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for google.antigravity
|
// Query for google.antigravity
|
||||||
const googleRow = db
|
const googleValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'google.antigravity',
|
||||||
|
'ide.itemTable.google.antigravity',
|
||||||
|
);
|
||||||
let googleState = null;
|
let googleState = null;
|
||||||
if (googleRow) {
|
if (googleValue) {
|
||||||
try {
|
try {
|
||||||
googleState = JSON.parse(googleRow.value);
|
googleState = JSON.parse(googleValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for antigravityUserSettings.allUserSettings
|
// Query for antigravityUserSettings.allUserSettings
|
||||||
const settingsRow = db
|
const settingsValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'antigravityUserSettings.allUserSettings',
|
||||||
|
'ide.itemTable.antigravityUserSettings.allUserSettings',
|
||||||
|
);
|
||||||
let settingsState = null;
|
let settingsState = null;
|
||||||
if (settingsRow) {
|
if (settingsValue) {
|
||||||
try {
|
try {
|
||||||
settingsState = JSON.parse(settingsRow.value);
|
settingsState = JSON.parse(settingsValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
@@ -164,28 +218,28 @@ export function getCurrentAccountInfo(): AccountInfo {
|
|||||||
logger.error('Failed to get current account info', error);
|
logger.error('Failed to get current account info', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (db) db.close();
|
if (connection) {
|
||||||
|
connection.raw.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection();
|
connection = getDatabaseConnection();
|
||||||
|
const { orm } = connection;
|
||||||
|
|
||||||
// NOTE Backup only specific keys
|
// NOTE Backup only specific keys
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const key of KEYS_TO_BACKUP) {
|
for (const key of KEYS_TO_BACKUP) {
|
||||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||||
| { value: string }
|
if (value) {
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
try {
|
try {
|
||||||
data[key] = JSON.parse(row.value);
|
data[key] = JSON.parse(value);
|
||||||
} catch {
|
} catch {
|
||||||
data[key] = row.value;
|
data[key] = value;
|
||||||
}
|
}
|
||||||
logger.debug(`Backed up key: ${key}`);
|
logger.debug(`Backed up key: ${key}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -206,7 +260,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac
|
|||||||
logger.error('Failed to backup account', error);
|
logger.error('Failed to backup account', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (db) db.close();
|
if (connection) {
|
||||||
|
connection.raw.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,32 +313,36 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restoring database: ${dbPath}`);
|
logger.info(`Restoring database: ${dbPath}`);
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection(dbPath);
|
connection = getDatabaseConnection(dbPath);
|
||||||
|
const { orm } = connection;
|
||||||
const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)');
|
orm.transaction((tx) => {
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
|
||||||
// NOTE Only restore the keys that were backed up
|
// NOTE Only restore the keys that were backed up
|
||||||
for (const key of KEYS_TO_BACKUP) {
|
for (const key of KEYS_TO_BACKUP) {
|
||||||
if (key in backup.data) {
|
if (key in backup.data) {
|
||||||
const value = backup.data[key];
|
const value = backup.data[key];
|
||||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
insert.run(key, stringValue);
|
tx.insert(itemTable)
|
||||||
|
.values({ key, value: stringValue })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: itemTable.key,
|
||||||
|
set: { value: stringValue },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
logger.debug(`Restored key: ${key}`);
|
logger.debug(`Restored key: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction();
|
|
||||||
logger.info(`Database restoration complete: ${dbPath}`);
|
logger.info(`Database restoration complete: ${dbPath}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (db) db.close();
|
if (connection) {
|
||||||
|
connection.raw.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
export const accounts = sqliteTable('accounts', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
name: text('name'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
tokenJson: text('token_json').notNull(),
|
||||||
|
quotaJson: text('quota_json'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
lastUsed: integer('last_used').notNull(),
|
||||||
|
status: text('status'),
|
||||||
|
isActive: integer('is_active').notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable('settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemTable = sqliteTable('ItemTable', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value'),
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||||
import { ConfigManager } from '../config/manager';
|
import { ConfigManager } from '../config/manager';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the gateway server (NestJS)
|
* Start the gateway server (NestJS)
|
||||||
@@ -20,7 +21,7 @@ export const startGateway = async (port: number): Promise<boolean> => {
|
|||||||
|
|
||||||
return await bootstrapNestServer(proxyConfig);
|
return await bootstrapNestServer(proxyConfig);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to start gateway:', e);
|
logger.error('Failed to start gateway:', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -32,7 +33,7 @@ export const stopGateway = async (): Promise<boolean> => {
|
|||||||
try {
|
try {
|
||||||
return await stopNestServer();
|
return await stopNestServer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to stop gateway:', e);
|
logger.error('Failed to stop gateway:', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,7 +54,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
@@ -28,7 +28,7 @@ try {
|
|||||||
logger.info(`Database router check: ${!!db}`);
|
logger.info(`Database router check: ${!!db}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error logging router:', e);
|
logger.error('Error logging router:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
||||||
+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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-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 {
|
|
||||||
// macOS and Linux (native)
|
|
||||||
command = 'pgrep -xi Antigravity';
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Checking process with command: ${command}`);
|
|
||||||
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(command, { timeout: 4000 });
|
const matches = await findProcess('name', searchName, true);
|
||||||
logger.debug(`Process check stdout: ${stdout.trim()}`);
|
for (const proc of matches) {
|
||||||
|
if (typeof proc.pid === 'number') {
|
||||||
if (platform === 'win32' || isWsl()) {
|
processMap.set(proc.pid, proc);
|
||||||
return stdout.includes('Antigravity.exe');
|
|
||||||
} else {
|
|
||||||
// pgrep returns PIDs if found, empty if not
|
|
||||||
return stdout.trim().length > 0;
|
|
||||||
}
|
}
|
||||||
} catch (cmdError) {
|
|
||||||
if (platform === 'win32' && !isWsl()) {
|
|
||||||
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
|
|
||||||
// Fallback to wmic (filtered)
|
|
||||||
try {
|
|
||||||
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
|
|
||||||
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
|
|
||||||
return stdout.includes('ProcessId');
|
|
||||||
} catch (wmicError) {
|
|
||||||
logger.error('wmic fallback also failed', wmicError);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw cmdError;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking process status:', error);
|
if (isPgrepNoMatchError(error)) {
|
||||||
// pgrep returns exit code 1 if no process found
|
sawNoMatch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = Array.from(processMap.values());
|
||||||
|
if (processes.length === 0 && sawNoMatch) {
|
||||||
|
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
|
||||||
|
|
||||||
|
for (const proc of processes) {
|
||||||
|
// Skip self
|
||||||
|
if (proc.pid === currentPid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = proc.name?.toLowerCase() || '';
|
||||||
|
const cmd = proc.cmd?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Skip manager process
|
||||||
|
if (
|
||||||
|
name.includes('manager') ||
|
||||||
|
cmd.includes('manager') ||
|
||||||
|
cmd.includes('antigravity-manager')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip helper processes
|
||||||
|
if (isHelperProcess(name, cmd)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// macOS: Check for Antigravity.app in path
|
||||||
|
if (cmd.includes('antigravity.app')) {
|
||||||
|
logger.debug(
|
||||||
|
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check if the process name is exactly 'Antigravity' (main process)
|
||||||
|
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
|
||||||
|
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
// Windows: Check for Antigravity.exe
|
||||||
|
if (name === 'antigravity.exe' || name === 'antigravity') {
|
||||||
|
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linux: Check for antigravity in name or path (but not tools)
|
||||||
|
if (
|
||||||
|
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||||
|
!name.includes('tools')
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking process status with find-process:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import { os } from '@orpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
||||||
import { systemHandler } from './system/handler';
|
import { systemHandler } from './system/handler';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Log middleware setup
|
// Log middleware setup
|
||||||
const logMiddleware = os.middleware(async (opts: any) => {
|
const logMiddleware = os.middleware(async (opts: any) => {
|
||||||
@@ -21,7 +22,7 @@ const logMiddleware = os.middleware(async (opts: any) => {
|
|||||||
const result = await next({});
|
const result = await next({});
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
logger.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
@@ -51,9 +51,14 @@ export function initTray(mainWindow: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
+96
-16
@@ -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,25 +51,83 @@ 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>
|
className={cn(
|
||||||
<p className="text-muted-foreground mt-1 text-xs">Manager</p>
|
'overflow-hidden transition-all duration-300',
|
||||||
|
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">Antigravity</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground mt-1 overflow-hidden text-xs whitespace-nowrap transition-all duration-300',
|
||||||
|
isCollapsed ? 'h-0 opacity-0' : 'h-auto opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Manager
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-2 px-4">
|
<nav className="flex-1 space-y-2 px-2">
|
||||||
{navItems.map((item) => (
|
<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
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={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',
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
location.pathname === item.to
|
isActive
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
@@ -57,16 +135,18 @@ export const MainLayout: React.FC = () => {
|
|||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { mapClaudeModelToGemini } from './ModelMapping';
|
import { mapClaudeModelToGemini } from './ModelMapping';
|
||||||
import { cleanJsonSchema } from './JsonSchemaUtils';
|
import { cleanJsonSchema } from './JsonSchemaUtils';
|
||||||
import { SignatureStore } from './SignatureStore';
|
import { SignatureStore } from './SignatureStore';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
import {
|
import {
|
||||||
ClaudeRequest,
|
ClaudeRequest,
|
||||||
Message,
|
Message,
|
||||||
@@ -51,7 +52,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 +101,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 +153,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;
|
||||||
@@ -378,7 +379,7 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
|||||||
|
|
||||||
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||||
if (hasFunctions) {
|
if (hasFunctions) {
|
||||||
console.info('Skipping googleSearch injection due to existing functionDeclarations');
|
logger.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { GeminiPart, Usage, UsageMetadata } from './types';
|
import { GeminiPart, Usage, UsageMetadata } from './types';
|
||||||
import { SignatureStore } from './SignatureStore';
|
import { SignatureStore } from './SignatureStore';
|
||||||
import { decodeSignature } from './signature-utils';
|
import { decodeSignature } from './signature-utils';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
|
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
|
||||||
|
|
||||||
@@ -249,7 +250,7 @@ export class StreamingState {
|
|||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
this.parseErrorCount++;
|
this.parseErrorCount++;
|
||||||
|
|
||||||
console.warn(
|
logger.warn(
|
||||||
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
|
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ export class StreamingState {
|
|||||||
|
|
||||||
// Emit error event if too many errors
|
// Emit error event if too many errors
|
||||||
if (this.parseErrorCount > 3) {
|
if (this.parseErrorCount > 3) {
|
||||||
console.error(
|
logger.error(
|
||||||
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
|
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
|
||||||
);
|
);
|
||||||
chunks.push(
|
chunks.push(
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
const logger = {
|
import { logger } from '../../utils/logger';
|
||||||
log: (msg: string) => console.log(msg),
|
|
||||||
warn: (msg: string) => console.warn(msg),
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||||
// Directly supported models
|
// Directly supported models
|
||||||
@@ -78,7 +75,7 @@ export function resolveModelRoute(
|
|||||||
): string {
|
): string {
|
||||||
// 1. Check custom exact mapping (Highest priority)
|
// 1. Check custom exact mapping (Highest priority)
|
||||||
if (customMapping[originalModel]) {
|
if (customMapping[originalModel]) {
|
||||||
logger.log(
|
logger.info(
|
||||||
`[Router] Using custom exact mapping: ${originalModel} -> ${customMapping[originalModel]}`,
|
`[Router] Using custom exact mapping: ${originalModel} -> ${customMapping[originalModel]}`,
|
||||||
);
|
);
|
||||||
return customMapping[originalModel];
|
return customMapping[originalModel];
|
||||||
@@ -98,7 +95,7 @@ export function resolveModelRoute(
|
|||||||
lowerModel === 'gpt-4'
|
lowerModel === 'gpt-4'
|
||||||
) {
|
) {
|
||||||
if (openaiMapping['gpt-4-series']) {
|
if (openaiMapping['gpt-4-series']) {
|
||||||
logger.log(
|
logger.info(
|
||||||
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||||
);
|
);
|
||||||
return openaiMapping['gpt-4-series'];
|
return openaiMapping['gpt-4-series'];
|
||||||
@@ -113,7 +110,7 @@ export function resolveModelRoute(
|
|||||||
lowerModel.includes('turbo')
|
lowerModel.includes('turbo')
|
||||||
) {
|
) {
|
||||||
if (openaiMapping['gpt-4o-series']) {
|
if (openaiMapping['gpt-4o-series']) {
|
||||||
logger.log(
|
logger.info(
|
||||||
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
|
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
|
||||||
);
|
);
|
||||||
return openaiMapping['gpt-4o-series'];
|
return openaiMapping['gpt-4o-series'];
|
||||||
@@ -124,13 +121,13 @@ export function resolveModelRoute(
|
|||||||
if (lowerModel.startsWith('gpt-5')) {
|
if (lowerModel.startsWith('gpt-5')) {
|
||||||
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
|
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
|
||||||
if (openaiMapping['gpt-5-series']) {
|
if (openaiMapping['gpt-5-series']) {
|
||||||
logger.log(
|
logger.info(
|
||||||
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
||||||
);
|
);
|
||||||
return openaiMapping['gpt-5-series'];
|
return openaiMapping['gpt-5-series'];
|
||||||
}
|
}
|
||||||
if (openaiMapping['gpt-4-series']) {
|
if (openaiMapping['gpt-4-series']) {
|
||||||
logger.log(
|
logger.info(
|
||||||
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||||
);
|
);
|
||||||
return openaiMapping['gpt-4-series'];
|
return openaiMapping['gpt-4-series'];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||||
import { QuotaData, Tier, LoadProjectResponse, QuotaApiResponse } from './types';
|
import { QuotaData, Tier, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
||||||
@@ -48,15 +49,15 @@ export class QuotaService {
|
|||||||
const subscriptionTier = data.paidTier?.id || data.currentTier?.id;
|
const subscriptionTier = data.paidTier?.id || data.currentTier?.id;
|
||||||
|
|
||||||
if (subscriptionTier) {
|
if (subscriptionTier) {
|
||||||
console.log(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
|
logger.info(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [projectId, subscriptionTier];
|
return [projectId, subscriptionTier];
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [undefined, undefined];
|
return [undefined, undefined];
|
||||||
@@ -86,7 +87,7 @@ export class QuotaService {
|
|||||||
const url = QUOTA_API_URL;
|
const url = QUOTA_API_URL;
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
|
|
||||||
console.log(`Sending quota request to ${url}`);
|
logger.info(`Sending quota request to ${url}`);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -104,11 +105,11 @@ export class QuotaService {
|
|||||||
subscriptionTier: subscriptionTier,
|
subscriptionTier: subscriptionTier,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
|
logger.info(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
|
||||||
|
|
||||||
if (quotaResponse.models) {
|
if (quotaResponse.models) {
|
||||||
for (const [name, info] of Object.entries(quotaResponse.models)) {
|
for (const [name, info] of Object.entries(quotaResponse.models)) {
|
||||||
console.log(` - ${name}`);
|
logger.info(` - ${name}`);
|
||||||
if (info.quotaInfo) {
|
if (info.quotaInfo) {
|
||||||
const fraction = info.quotaInfo.remainingFraction ?? 0;
|
const fraction = info.quotaInfo.remainingFraction ?? 0;
|
||||||
const percentage = Math.floor(fraction * 100);
|
const percentage = Math.floor(fraction * 100);
|
||||||
@@ -135,7 +136,7 @@ export class QuotaService {
|
|||||||
|
|
||||||
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
|
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
console.warn(`Account no permission (403 Forbidden), marked as forbidden`);
|
logger.warn(`Account no permission (403 Forbidden), marked as forbidden`);
|
||||||
return {
|
return {
|
||||||
quotaData: {
|
quotaData: {
|
||||||
models: {},
|
models: {},
|
||||||
@@ -146,9 +147,9 @@ export class QuotaService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
logger.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
logger.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Global thought_signature storage shared by all endpoints
|
* Global thought_signature storage shared by all endpoints
|
||||||
* Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.
|
* Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.
|
||||||
*/
|
*/
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
class SignatureStoreImpl {
|
class SignatureStoreImpl {
|
||||||
private static instance: SignatureStoreImpl;
|
private static instance: SignatureStoreImpl;
|
||||||
@@ -28,12 +29,12 @@ class SignatureStoreImpl {
|
|||||||
const newLen = sig.length;
|
const newLen = sig.length;
|
||||||
|
|
||||||
if (newLen > existingLen) {
|
if (newLen > existingLen) {
|
||||||
console.log(
|
logger.info(
|
||||||
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
|
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
|
||||||
);
|
);
|
||||||
this.signature = sig;
|
this.signature = sig;
|
||||||
} else {
|
} else {
|
||||||
console.debug(
|
logger.debug(
|
||||||
`[ThoughtSig] Skipping shorter signature (new length: ${newLen}, existing: ${existingLen})`,
|
`[ThoughtSig] Skipping shorter signature (new length: ${newLen}, existing: ${existingLen})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
|
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
|
||||||
* Solution: Validate and convert invalid blocks to text instead of dropping
|
* Solution: Validate and convert invalid blocks to text instead of dropping
|
||||||
*/
|
*/
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
/** Minimum length for a valid thought signature */
|
/** Minimum length for a valid thought signature */
|
||||||
const MIN_SIGNATURE_LENGTH = 10;
|
const MIN_SIGNATURE_LENGTH = 10;
|
||||||
@@ -82,7 +83,7 @@ export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | nul
|
|||||||
// Invalid signature - check if we have content to preserve
|
// Invalid signature - check if we have content to preserve
|
||||||
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
|
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
|
||||||
// Convert to text block to preserve content
|
// Convert to text block to preserve content
|
||||||
console.info(
|
logger.info(
|
||||||
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
|
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -92,7 +93,7 @@ export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Empty content + invalid signature = drop
|
// Empty content + invalid signature = drop
|
||||||
console.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
|
logger.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+401
-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.',
|
||||||
@@ -208,10 +241,16 @@ i18n
|
|||||||
refresh: 'Refresh Quota',
|
refresh: 'Refresh Quota',
|
||||||
delete: 'Delete Account',
|
delete: 'Delete Account',
|
||||||
noQuota: 'No quota data',
|
noQuota: 'No quota data',
|
||||||
|
resetPrefix: 'reset',
|
||||||
|
resetTime: 'Reset time',
|
||||||
|
resetUnknown: 'Unknown',
|
||||||
},
|
},
|
||||||
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 +266,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 +315,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 +348,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 +401,7 @@ i18n
|
|||||||
description: '选择您的首选语言。',
|
description: '选择您的首选语言。',
|
||||||
english: '英语',
|
english: '英语',
|
||||||
chinese: '中文 (简体)',
|
chinese: '中文 (简体)',
|
||||||
|
russian: '俄语',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -351,6 +410,7 @@ i18n
|
|||||||
version: '版本',
|
version: '版本',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
license: '许可证',
|
license: '许可证',
|
||||||
|
openLogDir: '打开',
|
||||||
account: {
|
account: {
|
||||||
title: '账号设置',
|
title: '账号设置',
|
||||||
description: '配置自动刷新和同步账号。',
|
description: '配置自动刷新和同步账号。',
|
||||||
@@ -359,6 +419,21 @@ 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 的请求配置代理。',
|
||||||
@@ -480,10 +555,16 @@ i18n
|
|||||||
refresh: '刷新配额',
|
refresh: '刷新配额',
|
||||||
delete: '删除账号',
|
delete: '删除账号',
|
||||||
noQuota: '无配额数据',
|
noQuota: '无配额数据',
|
||||||
|
resetPrefix: '重置',
|
||||||
|
resetTime: '重置时间',
|
||||||
|
resetUnknown: '未知',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
noAccounts: '暂无云账号。',
|
noAccounts: '暂无云账号。',
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: '加载云账号失败。',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
syncSuccess: {
|
syncSuccess: {
|
||||||
title: '同步成功',
|
title: '同步成功',
|
||||||
@@ -499,6 +580,7 @@ i18n
|
|||||||
},
|
},
|
||||||
quotaRefreshed: '配额已刷新',
|
quotaRefreshed: '配额已刷新',
|
||||||
refreshFailed: '刷新配额失败',
|
refreshFailed: '刷新配额失败',
|
||||||
|
pollFailed: '轮询所有账号配额失败',
|
||||||
switched: {
|
switched: {
|
||||||
title: '账号已切换!',
|
title: '账号已切换!',
|
||||||
description: '正在重启 Antigravity...',
|
description: '正在重启 Antigravity...',
|
||||||
@@ -564,5 +646,323 @@ i18n
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ru: {
|
||||||
|
translation: {
|
||||||
|
appName: 'Antigravity Manager',
|
||||||
|
status: {
|
||||||
|
checking: 'Проверка статуса...',
|
||||||
|
running: 'Antigravity работает в фоне',
|
||||||
|
stopped: 'Служба Antigravity остановлена',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
stop: 'Стоп',
|
||||||
|
start: 'Старт',
|
||||||
|
switch: 'Переключить',
|
||||||
|
deleteBackup: 'Удалить резервную копию',
|
||||||
|
backupCurrent: 'Создать бэкап текущего',
|
||||||
|
retry: 'Повторить',
|
||||||
|
openLogs: 'Открыть папку логов',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
generic: 'Произошла непредвиденная ошибка.',
|
||||||
|
keychainUnavailable: 'Keychain недоступен.',
|
||||||
|
keychainHint: {
|
||||||
|
translocation:
|
||||||
|
'Обнаружено App Translocation (macOS). Переместите приложение в /Applications и откройте заново.',
|
||||||
|
keychainDenied:
|
||||||
|
'Доступ к Keychain запрещен. Возможно, приложение не подписано. См. README для инструкции.',
|
||||||
|
signNotarize: 'Пожалуйста, используйте подписанную версию приложения.',
|
||||||
|
},
|
||||||
|
dataMigrationFailed: 'Не удалось расшифровать данные старого аккаунта.',
|
||||||
|
dataMigrationHint: {
|
||||||
|
relogin: 'Пожалуйста, перезайдите в аккаунт или добавьте его заново.',
|
||||||
|
clearData: 'Если ошибка сохраняется, очистите локальные данные и войдите снова.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
accounts: 'Аккаунты',
|
||||||
|
proxy: 'API Прокси',
|
||||||
|
settings: 'Настройки',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
current: 'Текущий',
|
||||||
|
lastUsed: 'Последнее использование {{time}}',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
title: 'Аккаунты',
|
||||||
|
description: 'Управление вашими аккаунтами Antigravity Google Gemini.',
|
||||||
|
noBackups: {
|
||||||
|
title: 'Бэкапы не найдены',
|
||||||
|
description:
|
||||||
|
'Создайте резервную копию вашего текущего аккаунта Antigravity, чтобы начать.',
|
||||||
|
action: 'Создать бэкап текущего аккаунта',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Настройки',
|
||||||
|
description: 'Управление настройками приложения.',
|
||||||
|
general: 'Общие',
|
||||||
|
connection: 'Подключение',
|
||||||
|
models: 'Модели',
|
||||||
|
appearance: {
|
||||||
|
title: 'Внешний вид',
|
||||||
|
description: 'Настройте внешний вид Antigravity Manager.',
|
||||||
|
},
|
||||||
|
darkMode: 'Темная тема',
|
||||||
|
darkModeDescription: 'Включить темную тему для комфортной работы ночью.',
|
||||||
|
language: {
|
||||||
|
title: 'Язык',
|
||||||
|
description: 'Выберите язык интерфейса.',
|
||||||
|
english: 'English',
|
||||||
|
chinese: '中文 (简体)',
|
||||||
|
russian: 'Русский',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: 'О программе',
|
||||||
|
description: 'Информация о приложении.',
|
||||||
|
},
|
||||||
|
version: 'Версия',
|
||||||
|
platform: 'Платформа',
|
||||||
|
license: 'Лицензия',
|
||||||
|
openLogDir: 'Открыть',
|
||||||
|
account: {
|
||||||
|
title: 'Настройки аккаунта',
|
||||||
|
description: 'Настройка автоматического обновления и синхронизации.',
|
||||||
|
auto_refresh: 'Автообновление квот',
|
||||||
|
auto_refresh_desc: 'Периодически обновлять информацию о квотах для всех аккаунтов',
|
||||||
|
auto_sync: 'Автосинхронизация текущего',
|
||||||
|
auto_sync_desc: 'Периодически синхронизировать активный аккаунт',
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
title: 'Автозагрузка',
|
||||||
|
description: 'Управление запуском приложения при старте системы.',
|
||||||
|
auto_startup: 'Запускать вместе с системой',
|
||||||
|
auto_startup_desc: 'Запускать при входе и сворачивать в трей',
|
||||||
|
macos_hint:
|
||||||
|
'macOS требует подписанное приложение для работы элементов входа. Если автозапуск не работает, подпишите приложение или включите его вручную в системных настройках.',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Конфиденциальность',
|
||||||
|
description: 'Управление использованием данных для улучшения приложения.',
|
||||||
|
error_reporting: 'Отчеты об ошибках',
|
||||||
|
error_reporting_desc:
|
||||||
|
'Отправляйте анонимные отчеты об ошибках, чтобы помочь нам улучшить приложение. Личные данные не собираются.',
|
||||||
|
restart_note:
|
||||||
|
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'Верхнеуровневый прокси',
|
||||||
|
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||||
|
enable: 'Включить прокси',
|
||||||
|
url: 'URL прокси',
|
||||||
|
timeout: 'Тайм-аут запроса (сек)',
|
||||||
|
},
|
||||||
|
modelMapping: {
|
||||||
|
title: 'Маппинг моделей',
|
||||||
|
description:
|
||||||
|
'Сопоставьте модели Claude Code с моделями Antigravity. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
|
||||||
|
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||||
|
targetGemini: 'Целевая модель Gemini',
|
||||||
|
addPlaceholderKey: 'например, op-3',
|
||||||
|
addPlaceholderValue: 'например, gemini-2.0-flash-exp',
|
||||||
|
noMappings: 'Нет пользовательских сопоставлений.',
|
||||||
|
mapsTo: 'Мапится в',
|
||||||
|
default: 'По умолчанию',
|
||||||
|
restoreDefaults: 'Сбросить по умолчанию',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Примеры использования',
|
||||||
|
description: 'Примеры команд для вызова локального API прокси.',
|
||||||
|
curl: 'cURL',
|
||||||
|
python: 'Python',
|
||||||
|
copy: 'Копировать',
|
||||||
|
copied: 'Скопировано!',
|
||||||
|
openai_protocol: 'Протокол OpenAI',
|
||||||
|
anthropic_protocol: 'Протокол Anthropic',
|
||||||
|
openai_tools: 'Cursor, Windsurf, NextChat',
|
||||||
|
anthropic_tools: 'Claude Code CLI',
|
||||||
|
flash: 'Быстрый (Flash)',
|
||||||
|
pro: 'Про (Pro)',
|
||||||
|
flash_preview: 'Превью (Flash Preview)',
|
||||||
|
pro_high: 'Лучший (Pro High)',
|
||||||
|
sonnet: 'Мыслящий (Sonnet)',
|
||||||
|
opus: 'Opus',
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
title: 'Служба API Прокси',
|
||||||
|
description: 'Управление локальным прокси-сервером API.',
|
||||||
|
status_running: 'Запущено',
|
||||||
|
status_stopped: 'Остановлено',
|
||||||
|
action_start: 'Запустить службу',
|
||||||
|
action_stop: 'Остановить службу',
|
||||||
|
accounts_info: '{{count}} аккаунтов доступно',
|
||||||
|
port: 'Порт прослушивания',
|
||||||
|
port_hint: 'По умолчанию 8045, требуется перезапуск для применения',
|
||||||
|
timeout: 'Тайм-аут запроса',
|
||||||
|
timeout_hint: 'По умолчанию 120с, диапазон 30-600с',
|
||||||
|
api_key: 'API Ключ',
|
||||||
|
regenerate_key: 'Перегенерировать',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Перегенерировать API ключ?',
|
||||||
|
description:
|
||||||
|
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
confirm: 'Перегенерировать',
|
||||||
|
},
|
||||||
|
key_warning: 'Держите ваш API ключ в секрете. Не передавайте его никому.',
|
||||||
|
auto_start: 'Автозапуск с приложением',
|
||||||
|
auto_start_desc: 'Запускать прокси-службу при старте приложения',
|
||||||
|
},
|
||||||
|
proxy_tab: 'Прокси',
|
||||||
|
save: 'Сохранить настройки',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
backupSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Бэкап аккаунта создан.',
|
||||||
|
},
|
||||||
|
backupError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось создать бэкап: {{error}}',
|
||||||
|
},
|
||||||
|
switchSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Аккаунт переключен.',
|
||||||
|
},
|
||||||
|
switchError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось переключить аккаунт: {{error}}',
|
||||||
|
},
|
||||||
|
deleteSuccess: {
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Бэкап удален.',
|
||||||
|
},
|
||||||
|
deleteError: {
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось удалить бэкап: {{error}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
title: 'Аккаунты',
|
||||||
|
description: 'Управление пулом аккаунтов Google Gemini.',
|
||||||
|
autoSwitch: 'Авто-переключение',
|
||||||
|
addAccount: 'Добавить аккаунт',
|
||||||
|
syncFromIDE: 'Синхр. из IDE',
|
||||||
|
checkQuota: 'Проверить квоту',
|
||||||
|
polling: 'Опрос запущен',
|
||||||
|
authDialog: {
|
||||||
|
title: 'Добавить Google Аккаунт',
|
||||||
|
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||||
|
openLogin: 'Открыть страницу входа',
|
||||||
|
authCode: 'Код авторизации',
|
||||||
|
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||||
|
instruction:
|
||||||
|
'Откроется браузер для входа в Google. Скопируйте код со страницы localhost и вставьте сюда.',
|
||||||
|
verify: 'Проверить и добавить',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
active: 'Активен',
|
||||||
|
use: 'Использовать',
|
||||||
|
rateLimited: 'Лимит исчерпан',
|
||||||
|
left: 'ост.',
|
||||||
|
used: 'Исп.',
|
||||||
|
unknown: 'Неизвестный',
|
||||||
|
actions: 'Действия',
|
||||||
|
useAccount: 'Использовать этот аккаунт',
|
||||||
|
refresh: 'Обновить квоту',
|
||||||
|
delete: 'Удалить аккаунт',
|
||||||
|
noQuota: 'Нет данных',
|
||||||
|
resetPrefix: 'Сброс',
|
||||||
|
resetTime: 'Время сброса',
|
||||||
|
resetUnknown: 'Неизвестно',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
noAccounts: 'Нет добавленных облачных аккаунтов.',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: 'Не удалось загрузить аккаунты.',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
syncSuccess: {
|
||||||
|
title: 'Синхронизация успешна',
|
||||||
|
description: 'Импортирован {{email}} из IDE.',
|
||||||
|
},
|
||||||
|
syncFailed: {
|
||||||
|
title: 'Синхронизация не удалась',
|
||||||
|
description: 'В базе данных IDE не найдено активных аккаунтов.',
|
||||||
|
},
|
||||||
|
addSuccess: 'Аккаунт успешно добавлен!',
|
||||||
|
addFailed: {
|
||||||
|
title: 'Не удалось добавить аккаунт',
|
||||||
|
},
|
||||||
|
quotaRefreshed: 'Квота обновлена',
|
||||||
|
refreshFailed: 'Не удалось обновить квоту',
|
||||||
|
pollFailed: 'Не удалось опросить квоты всех аккаунтов',
|
||||||
|
switched: {
|
||||||
|
title: 'Аккаунт переключен!',
|
||||||
|
description: 'Перезапуск Antigravity...',
|
||||||
|
},
|
||||||
|
switchFailed: 'Не удалось переключить аккаунт',
|
||||||
|
deleted: 'Аккаунт удален',
|
||||||
|
deleteFailed: 'Не удалось удалить аккаунт',
|
||||||
|
deleteConfirm: 'Вы уверены, что хотите удалить этот аккаунт?',
|
||||||
|
autoSwitchOn: 'Авто-переключение включено',
|
||||||
|
autoSwitchOff: 'Авто-переключение выключено',
|
||||||
|
updateSettingsFailed: 'Не удалось обновить настройки',
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
selected: 'Выбрано {{count}}',
|
||||||
|
delete: 'Удалить выбранные',
|
||||||
|
refresh: 'Обновить выбранные',
|
||||||
|
selectAll: 'Выбрать все',
|
||||||
|
clear: 'Снять выделение',
|
||||||
|
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'API Прокси',
|
||||||
|
description: 'Управление локальным прокси-сервером API.',
|
||||||
|
save: 'Сохранить',
|
||||||
|
copy: 'Копировать',
|
||||||
|
regenerate: 'Перегенерировать',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Перегенерировать API ключ?',
|
||||||
|
description:
|
||||||
|
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
confirm: 'Перегенерировать',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
title: 'Статус сервиса',
|
||||||
|
description: 'Управление локальным сервером.',
|
||||||
|
running: 'Работает',
|
||||||
|
stopped: 'Остановлен',
|
||||||
|
start: 'Запустить',
|
||||||
|
stop: 'Остановить',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
port: 'Порт',
|
||||||
|
timeout: 'Тайм-аут',
|
||||||
|
api_key: 'API Ключ',
|
||||||
|
auto_start: 'Автозапуск',
|
||||||
|
auto_start_desc: 'Запускать прокси при старте приложения',
|
||||||
|
local_access: 'Доступ из локальной сети:',
|
||||||
|
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
||||||
|
show_key: 'Показать',
|
||||||
|
hide_key: 'Скрыть',
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
title: 'Маппинг моделей',
|
||||||
|
description: 'Настройка маршрутизации моделей.',
|
||||||
|
maps_to: '->',
|
||||||
|
restore: 'Сброс',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Примеры',
|
||||||
|
description: 'Примеры использования локального прокси.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+148
-11
@@ -1,14 +1,23 @@
|
|||||||
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, dialog, shell } from 'electron';
|
||||||
|
import type { MessageBoxOptions } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import squirrelStartup from 'electron-squirrel-startup';
|
||||||
|
|
||||||
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
|
||||||
import { ipcMain } from 'electron/main';
|
import { 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 {
|
||||||
|
getExpectedInstallRoot,
|
||||||
|
getInstallNoticeText,
|
||||||
|
isRunningFromExpectedInstallDir as isRunningFromExpectedInstallDirUtil,
|
||||||
|
resolveInstallNoticeLanguage,
|
||||||
|
} from './utils/installNotice';
|
||||||
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
|
||||||
@@ -17,6 +26,8 @@ import { bootstrapNestServer, stopNestServer } from './server/main';
|
|||||||
import { initTray, setTrayLanguage, destroyTray } 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');
|
||||||
|
|
||||||
@@ -44,7 +55,7 @@ function logPacket(data: any) {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
console.error(e.message);
|
logger.error('Failed to append ORPC packet log', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +66,11 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
|
|||||||
|
|
||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration();
|
||||||
|
|
||||||
|
if (squirrelStartup) {
|
||||||
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
// Keep a global reference of the window object, if you don't, the window will
|
// Keep a global reference of the window object, if you don't, the window will
|
||||||
@@ -62,6 +78,81 @@ 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;
|
||||||
|
let hasShownInstallNotice = false;
|
||||||
|
|
||||||
|
function isRunningFromExpectedInstallDir() {
|
||||||
|
return isRunningFromExpectedInstallDirUtil({
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
localAppData: process.env.LOCALAPPDATA,
|
||||||
|
appName: app.getName(),
|
||||||
|
execPath: process.execPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWindowsInstallNoticeIfNeeded() {
|
||||||
|
if (hasShownInstallNotice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunningFromExpectedInstallDir()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedRoot = getExpectedInstallRoot({
|
||||||
|
platform: process.platform,
|
||||||
|
localAppData: process.env.LOCALAPPDATA,
|
||||||
|
appName: app.getName(),
|
||||||
|
});
|
||||||
|
if (!expectedRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasShownInstallNotice = true;
|
||||||
|
const language = resolveInstallNoticeLanguage({
|
||||||
|
configLanguage: startupConfig?.language,
|
||||||
|
locale: app.getLocale(),
|
||||||
|
});
|
||||||
|
const text = getInstallNoticeText(language);
|
||||||
|
|
||||||
|
const options: MessageBoxOptions = {
|
||||||
|
type: 'info',
|
||||||
|
title: text.title,
|
||||||
|
message: text.message,
|
||||||
|
detail: `${text.detailPrefix}${expectedRoot}`,
|
||||||
|
buttons: [...text.buttons],
|
||||||
|
defaultId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPromise = globalMainWindow
|
||||||
|
? dialog.showMessageBox(globalMainWindow, options)
|
||||||
|
: dialog.showMessageBox(options);
|
||||||
|
|
||||||
|
showPromise.then(({ response }) => {
|
||||||
|
if (response === 0) {
|
||||||
|
shell.openPath(expectedRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
logger.info('Second instance detected, focusing existing window');
|
||||||
|
if (app.isReady()) {
|
||||||
|
createWindow({ startHidden: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow({ startHidden: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
process.on('exit', (code) => {
|
process.on('exit', (code) => {
|
||||||
logger.info(`Process exit event triggered with code: ${code}`);
|
logger.info(`Process exit event triggered with code: ${code}`);
|
||||||
@@ -74,7 +165,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 +189,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 +205,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);
|
||||||
@@ -112,6 +223,7 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Window created');
|
logger.info('Window created');
|
||||||
|
showWindowsInstallNoticeIfNeeded();
|
||||||
|
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!isQuitting) {
|
if (!isQuitting) {
|
||||||
@@ -125,6 +237,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,6 +256,10 @@ 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) => {
|
||||||
@@ -198,7 +315,7 @@ async function setupORPC() {
|
|||||||
|
|
||||||
logPacket(data);
|
logPacket(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[RAW ORPC MSG] (unparseable)', msgEvent.data);
|
logger.debug('[RAW ORPC MSG] (unparseable)', msgEvent.data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,13 +340,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)');
|
||||||
@@ -242,14 +382,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
|
||||||
@@ -291,7 +428,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);
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
|
import * as Sentry from '@sentry/electron/renderer';
|
||||||
|
|
||||||
|
if (window.electron.SENTRY_ENABLED) {
|
||||||
|
// Defer initialization to prioritize initial render
|
||||||
|
setTimeout(() => {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
import '@/App';
|
import '@/App';
|
||||||
+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) --- */}
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ export class ProxyController {
|
|||||||
@Post('chat/completions')
|
@Post('chat/completions')
|
||||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
|
// Claude Code CLI compatibility:
|
||||||
|
// Claude CLI uses OpenAI-style /v1/chat/completions
|
||||||
|
if (body.model?.startsWith('claude-')) {
|
||||||
|
const result = await this.proxyService.handleAnthropicMessages(
|
||||||
|
body as unknown as AnthropicChatRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (body.stream && result instanceof Observable) {
|
||||||
|
res.header('Content-Type', 'text/event-stream');
|
||||||
|
res.header('Cache-Control', 'no-cache');
|
||||||
|
res.header('Connection', 'keep-alive');
|
||||||
|
res.send(result);
|
||||||
|
} else {
|
||||||
|
res.status(HttpStatus.OK).send(result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.proxyService.handleChatCompletions(body);
|
const result = await this.proxyService.handleChatCompletions(body);
|
||||||
|
|
||||||
if (body.stream && result instanceof Observable) {
|
if (body.stream && result instanceof Observable) {
|
||||||
|
|||||||
@@ -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,7 +38,59 @@ export class CloudMonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the application window gains focus.
|
||||||
|
* Triggers an immediate poll if not rate-limited by debounce.
|
||||||
|
*/
|
||||||
|
static async handleAppFocus() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 1. Concurrency Guard: If we are already polling, don't pile up requests
|
||||||
|
if (this.isPolling) {
|
||||||
|
logger.info('Monitor: App focused, but polling is already in progress. Skipping.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Debounce: If we focused recently, don't poll again
|
||||||
|
if (now - this.lastFocusTime < this.DEBOUNCE_TIME) {
|
||||||
|
logger.info('Monitor: App focused, skipping poll (debounce active).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Monitor: App focused, triggering immediate poll...');
|
||||||
|
this.lastFocusTime = now;
|
||||||
|
|
||||||
|
// 3. Trigger Poll
|
||||||
|
await this.poll().catch((e) => {
|
||||||
|
logger.error('Monitor: Focus poll failed', e);
|
||||||
|
});
|
||||||
|
// 4. Reset the background interval so we don't double-poll shortly after
|
||||||
|
this.resetInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static startInterval() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
||||||
|
}, this.POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static resetInterval() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.startInterval(); // Restart the 5-minute timer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async poll() {
|
static async poll() {
|
||||||
|
if (this.isPolling) {
|
||||||
|
return; // Extra safety
|
||||||
|
}
|
||||||
|
this.isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
logger.info('CloudMonitor: Polling quotas...');
|
logger.info('CloudMonitor: Polling quotas...');
|
||||||
const accounts = await CloudAccountRepo.getAccounts();
|
const accounts = await CloudAccountRepo.getAccounts();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -53,6 +116,7 @@ export class CloudMonitorService {
|
|||||||
|
|
||||||
// 3. Update DB
|
// 3. Update DB
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||||
// Could mark status as 'error' or 'rate_limited' if 429
|
// Could mark status as 'error' or 'rate_limited' if 429
|
||||||
@@ -61,5 +125,8 @@ export class CloudMonitorService {
|
|||||||
|
|
||||||
// 4. Check for Auto-Switch
|
// 4. Check for Auto-Switch
|
||||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||||
|
} finally {
|
||||||
|
this.isPolling = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { ConfigManager } from '../ipc/config/manager';
|
import { ConfigManager } from '../ipc/config/manager';
|
||||||
import { ProxyAgent } from 'undici';
|
import { ProxyAgent } from 'undici';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// --- Constants & Config ---
|
// --- Constants & Config ---
|
||||||
|
|
||||||
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 +19,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 +76,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) {
|
||||||
@@ -75,7 +86,7 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback or log if config load fails (shouldn't happen usually)
|
// Fallback or log if config load fails (shouldn't happen usually)
|
||||||
console.warn('[GoogleAPIService] Failed to load proxy config', e);
|
logger.warn('[GoogleAPIService] Failed to load proxy config', e);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -121,7 +132,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 +168,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 +197,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) {
|
||||||
@@ -206,7 +247,7 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, e);
|
logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, e);
|
||||||
await new Promise((r) => setTimeout(r, 500)); // Sleep 500ms
|
await new Promise((r) => setTimeout(r, 500)); // Sleep 500ms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,7 +262,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;
|
||||||
}
|
}
|
||||||
@@ -256,7 +297,7 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMsg = `HTTP ${status} - ${text}`;
|
const errorMsg = `HTTP ${status} - ${text}`;
|
||||||
console.warn(
|
logger.warn(
|
||||||
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`,
|
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -285,11 +326,13 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.warn(
|
if (e instanceof Error) {
|
||||||
|
logger.warn(
|
||||||
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
`[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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { compareVersion, isNewVersion } from '../../utils/antigravityVersion';
|
||||||
|
|
||||||
|
describe('antigravityVersion', () => {
|
||||||
|
it('should compare versions correctly', () => {
|
||||||
|
expect(compareVersion('1.16.5', '1.16.4')).toBe(1);
|
||||||
|
expect(compareVersion('1.16.5', '1.16.5')).toBe(0);
|
||||||
|
expect(compareVersion('1.16.4', '1.16.5')).toBe(-1);
|
||||||
|
expect(compareVersion('1.17.0', '1.16.5')).toBe(1);
|
||||||
|
expect(compareVersion('2.0.0', '1.16.5')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect new version >= 1.16.5', () => {
|
||||||
|
expect(isNewVersion({ shortVersion: '1.16.4', bundleVersion: '1.16.4' })).toBe(false);
|
||||||
|
expect(isNewVersion({ shortVersion: '1.16.5', bundleVersion: '1.16.5' })).toBe(true);
|
||||||
|
expect(isNewVersion({ shortVersion: '1.17.0', bundleVersion: '1.17.0' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to package.json on windows when version detection fails', async () => {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const execSync = vi.fn(() => {
|
||||||
|
throw new Error('ps fail');
|
||||||
|
});
|
||||||
|
const existsSync = vi.fn(() => true);
|
||||||
|
const readFileSync = vi.fn(() => JSON.stringify({ version: '1.16.6' }));
|
||||||
|
|
||||||
|
vi.doMock('child_process', () => ({
|
||||||
|
execSync,
|
||||||
|
default: {
|
||||||
|
execSync,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.doMock('fs', () => ({
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
default: {
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.doMock('../../utils/paths', () => ({
|
||||||
|
getAntigravityExecutablePath: () => 'C:\\Program Files\\Antigravity\\Antigravity.exe',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { getAntigravityVersion } = await import('../../utils/antigravityVersion');
|
||||||
|
const version = getAntigravityVersion();
|
||||||
|
const cached = getAntigravityVersion();
|
||||||
|
|
||||||
|
expect(version.shortVersion).toBe('1.16.6');
|
||||||
|
expect(cached.shortVersion).toBe('1.16.6');
|
||||||
|
expect(execSync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,269 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ProtobufUtils } from '../../utils/protobuf';
|
||||||
|
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||||
|
import type { UserInfo } from '../../services/GoogleAPIService';
|
||||||
|
|
||||||
|
let mockData: Record<string, string>;
|
||||||
|
let busyOnFirstGet = false;
|
||||||
|
let getCallCount = 0;
|
||||||
|
let runCalls: Array<{ sql: string; args: unknown[] }>;
|
||||||
|
interface MockOrm {
|
||||||
|
select: () => {
|
||||||
|
from: () => {
|
||||||
|
where: (condition: { __key?: string }) => { all: () => Array<{ value: string }> };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
insert: () => {
|
||||||
|
values: (values: { key: string; value: string }) => {
|
||||||
|
onConflictDoUpdate: () => { run: () => { changes: number } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update: () => {
|
||||||
|
set: (values: { value?: string }) => {
|
||||||
|
where: (condition: { __key?: string }) => { run: () => { changes: number } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete: () => {
|
||||||
|
where: (condition: { __key?: string }) => { run: () => { changes: number } };
|
||||||
|
};
|
||||||
|
transaction: (fn: (tx: MockOrm) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockOrm: MockOrm;
|
||||||
|
|
||||||
|
function createMockUserInfo(email: string, name: string): UserInfo {
|
||||||
|
return {
|
||||||
|
id: `id-${email}`,
|
||||||
|
email,
|
||||||
|
verified_email: true,
|
||||||
|
name,
|
||||||
|
given_name: name,
|
||||||
|
family_name: 'User',
|
||||||
|
picture: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('drizzle-orm', () => ({
|
||||||
|
eq: (_column: unknown, value: string) => ({ __key: value }),
|
||||||
|
desc: (value: unknown) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../ipc/database/dbConnection', () => ({
|
||||||
|
openDrizzleConnection: () => ({
|
||||||
|
raw: { close: vi.fn() },
|
||||||
|
orm: mockOrm,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/paths', () => ({
|
||||||
|
getAntigravityDbPaths: () => ['mock-db'],
|
||||||
|
getCloudAccountsDbPath: () => 'mock-cloud-db',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/GoogleAPIService', () => ({
|
||||||
|
GoogleAPIService: {
|
||||||
|
getUserInfo: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CloudAccountRepo.syncFromIDE', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockData = {};
|
||||||
|
busyOnFirstGet = false;
|
||||||
|
getCallCount = 0;
|
||||||
|
runCalls = [];
|
||||||
|
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||||
|
mockOrm = {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: (condition: { __key?: string }) => ({
|
||||||
|
all: () => {
|
||||||
|
getCallCount += 1;
|
||||||
|
if (busyOnFirstGet && getCallCount === 1) {
|
||||||
|
const error = new Error('SQLITE_BUSY');
|
||||||
|
(error as { code?: string }).code = 'SQLITE_BUSY';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const key = condition?.__key ?? '';
|
||||||
|
const value = mockData[key];
|
||||||
|
if (value === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ value }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: (values: { key: string; value: string }) => ({
|
||||||
|
onConflictDoUpdate: () => ({
|
||||||
|
run: () => {
|
||||||
|
runCalls.push({ sql: 'insert', args: [values] });
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (values: { value?: string }) => ({
|
||||||
|
where: (condition: { __key?: string }) => ({
|
||||||
|
run: () => {
|
||||||
|
runCalls.push({ sql: 'update', args: [values, condition] });
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete: () => ({
|
||||||
|
where: (condition: { __key?: string }) => ({
|
||||||
|
run: () => {
|
||||||
|
runCalls.push({ sql: 'delete', args: [condition] });
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
transaction: (fn: (tx: typeof mockOrm) => void) => {
|
||||||
|
fn(mockOrm);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer unified oauth token when present', async () => {
|
||||||
|
const accessToken = 'access-new';
|
||||||
|
const refreshToken = 'refresh-new';
|
||||||
|
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(accessToken, refreshToken, 1700000000);
|
||||||
|
|
||||||
|
const oldB64 = Buffer.from(
|
||||||
|
ProtobufUtils.createOAuthTokenInfo('access-old', 'refresh-old', 1700000000),
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64;
|
||||||
|
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
|
||||||
|
|
||||||
|
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||||
|
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||||
|
createMockUserInfo('[email protected]', 'New User'),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]);
|
||||||
|
vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||||
|
|
||||||
|
const account = await CloudAccountRepo.syncFromIDE();
|
||||||
|
|
||||||
|
expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken);
|
||||||
|
expect(account?.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to old oauth token when unified is missing', async () => {
|
||||||
|
const accessToken = 'access-old';
|
||||||
|
const refreshToken = 'refresh-old';
|
||||||
|
const oldB64 = Buffer.from(
|
||||||
|
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
|
||||||
|
|
||||||
|
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||||
|
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||||
|
createMockUserInfo('[email protected]', 'Old User'),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]);
|
||||||
|
vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||||
|
|
||||||
|
const account = await CloudAccountRepo.syncFromIDE();
|
||||||
|
|
||||||
|
expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken);
|
||||||
|
expect(account?.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry when sqlite is busy', async () => {
|
||||||
|
busyOnFirstGet = true;
|
||||||
|
const accessToken = 'access-retry';
|
||||||
|
const refreshToken = 'refresh-retry';
|
||||||
|
const oldB64 = Buffer.from(
|
||||||
|
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
|
||||||
|
|
||||||
|
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||||
|
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||||
|
createMockUserInfo('[email protected]', 'Retry User'),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]);
|
||||||
|
vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||||
|
|
||||||
|
const account = await CloudAccountRepo.syncFromIDE();
|
||||||
|
|
||||||
|
expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken);
|
||||||
|
expect(account?.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer new format when capability detection finds unified key', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('../../utils/antigravityVersion', () => ({
|
||||||
|
getAntigravityVersion: () => {
|
||||||
|
throw new Error('version detection failed');
|
||||||
|
},
|
||||||
|
isNewVersion: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { CloudAccountRepo: RepoWithMock } = await import('../../ipc/database/cloudHandler');
|
||||||
|
const accessToken = 'access-new';
|
||||||
|
const refreshToken = 'refresh-new';
|
||||||
|
|
||||||
|
mockData['antigravityUnifiedStateSync.oauthToken'] = 'exists';
|
||||||
|
mockData['jetskiStateSync.agentManagerInitState'] = 'exists-old';
|
||||||
|
|
||||||
|
RepoWithMock.injectCloudToken({
|
||||||
|
id: 'id',
|
||||||
|
provider: 'google',
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Test',
|
||||||
|
avatar_url: '',
|
||||||
|
token: {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_in: 3600,
|
||||||
|
expiry_timestamp: 1700000000,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
email: '[email protected]',
|
||||||
|
},
|
||||||
|
created_at: 1700000000,
|
||||||
|
last_used: 1700000000,
|
||||||
|
status: 'active',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedOldKey = runCalls.some(
|
||||||
|
(call) =>
|
||||||
|
call.sql === 'update' &&
|
||||||
|
(call.args[1] as { __key?: string } | undefined)?.__key ===
|
||||||
|
'jetskiStateSync.agentManagerInitState',
|
||||||
|
);
|
||||||
|
const wroteUnifiedKey = runCalls.some(
|
||||||
|
(call) =>
|
||||||
|
call.sql === 'insert' &&
|
||||||
|
(call.args[0] as { key?: string })?.key === 'antigravityUnifiedStateSync.oauthToken',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wroteUnifiedKey).toBe(true);
|
||||||
|
expect(updatedOldKey).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as handler from '../../ipc/database/handler';
|
||||||
|
|
||||||
|
let writes: Record<string, string> = {};
|
||||||
|
interface MockOrm {
|
||||||
|
insert: () => {
|
||||||
|
values: (values: { key: string; value: string }) => {
|
||||||
|
onConflictDoUpdate: () => { run: () => { changes: number } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
transaction: (fn: (tx: MockOrm) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockOrm: MockOrm;
|
||||||
|
|
||||||
|
vi.mock('../../ipc/database/dbConnection', () => ({
|
||||||
|
openDrizzleConnection: () => ({
|
||||||
|
raw: { close: vi.fn() },
|
||||||
|
orm: mockOrm,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/paths', () => ({
|
||||||
|
getAntigravityDbPaths: () => ['mock-db'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('database backup keys', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
writes = {};
|
||||||
|
mockOrm = {
|
||||||
|
insert: () => ({
|
||||||
|
values: (values: { key: string; value: string }) => ({
|
||||||
|
onConflictDoUpdate: () => ({
|
||||||
|
run: () => {
|
||||||
|
writes[values.key] = values.value;
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
transaction: (fn: (tx: typeof mockOrm) => void) => {
|
||||||
|
fn(mockOrm);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.spyOn(fs, 'existsSync').mockImplementation((value) => {
|
||||||
|
const pathValue = String(value);
|
||||||
|
return !pathValue.endsWith('.backup');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore unified oauth token key', () => {
|
||||||
|
handler.restoreAccount({
|
||||||
|
version: '1.0',
|
||||||
|
account: {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test',
|
||||||
|
email: '[email protected]',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_used: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
antigravityAuthStatus: '{"email":"[email protected]"}',
|
||||||
|
'jetskiStateSync.agentManagerInitState': 'old',
|
||||||
|
'antigravityUnifiedStateSync.oauthToken': 'unified',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writes['antigravityUnifiedStateSync.oauthToken']).toBe('unified');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,9 +54,9 @@ describe.skip('Database Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should connect to database', () => {
|
it('should connect to database', () => {
|
||||||
const db = getDatabaseConnection();
|
const { raw } = getDatabaseConnection();
|
||||||
expect(db).toBeDefined();
|
expect(raw).toBeDefined();
|
||||||
db.close();
|
raw.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get current account info', () => {
|
it('should get current account info', () => {
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, 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';
|
||||||
@@ -12,40 +12,65 @@ vi.mock('../../utils/paths', async () => {
|
|||||||
|
|
||||||
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 getLatestLogFile = () => {
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(testLogDir)
|
||||||
|
.filter((file) => /^app-\d{4}-\d{2}-\d{2}(\.\d+)?\.log$/.test(file))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(testLogDir, files[files.length - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForLogContains = async (text: string) => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const filePath = getLatestLogFile();
|
||||||
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
if (content.includes(text)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
console.log('Test Log Dir:', testLogDir);
|
if (!fs.existsSync(testLogDir)) {
|
||||||
if (fs.existsSync(testLogDir)) {
|
|
||||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(testLogDir, { recursive: true });
|
fs.mkdirSync(testLogDir, { recursive: true });
|
||||||
console.log('Dir created:', fs.existsSync(testLogDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (fs.existsSync(testLogDir)) {
|
|
||||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create log file if it does not exist', () => {
|
it('should create rotated log file', async () => {
|
||||||
logger.info('Test message');
|
const message = 'Test message';
|
||||||
expect(fs.existsSync(testLogFile)).toBe(true);
|
logger.info(message);
|
||||||
|
const filePath = await waitForLogContains(message);
|
||||||
|
expect(filePath).not.toBeNull();
|
||||||
|
expect(fs.existsSync(filePath as string)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should write formatted message to file', () => {
|
it('should write formatted message to file', async () => {
|
||||||
const message = 'Test info message';
|
const message = 'Test info message';
|
||||||
logger.info(message);
|
logger.info(message);
|
||||||
const content = fs.readFileSync(testLogFile, 'utf-8');
|
const filePath = await waitForLogContains(message);
|
||||||
|
expect(filePath).not.toBeNull();
|
||||||
|
const content = fs.readFileSync(filePath as string, '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', async () => {
|
||||||
const message = 'Test error message';
|
const message = 'Test error message';
|
||||||
logger.error(message);
|
logger.error(message);
|
||||||
const content = fs.readFileSync(testLogFile, 'utf-8');
|
const filePath = await waitForLogContains(message);
|
||||||
|
expect(filePath).not.toBeNull();
|
||||||
|
const content = fs.readFileSync(filePath as string, 'utf-8');
|
||||||
expect(content).toContain('[ERROR]');
|
expect(content).toContain('[ERROR]');
|
||||||
expect(content).toContain(message);
|
expect(content).toContain(message);
|
||||||
});
|
});
|
||||||
|
|||||||
+167
-40
@@ -1,55 +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(),
|
||||||
default: { exec: vi.fn() },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock promisify to return a function that returns a promise
|
|
||||||
vi.mock('util', () => ({
|
|
||||||
promisify: (fn: unknown) => fn,
|
|
||||||
default: { promisify: (fn: unknown) => fn },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
vi.mock('../../utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock paths module to avoid child_process issues
|
||||||
|
vi.mock('../../utils/paths', () => ({
|
||||||
|
getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'),
|
||||||
|
isWsl: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||||
|
import findProcess from 'find-process';
|
||||||
|
|
||||||
describe('Process Handler', () => {
|
describe('Process Handler', () => {
|
||||||
it('should check if process is running', async () => {
|
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
|
||||||
// Mock exec to return stdout
|
|
||||||
const execMock = exec as unknown as ReturnType<typeof vi.fn>;
|
beforeEach(() => {
|
||||||
execMock.mockImplementation((cmd, cb) => {
|
vi.clearAllMocks();
|
||||||
if (cb) cb(null, { stdout: '12345' }, { stderr: '' });
|
|
||||||
return { stdout: '12345' };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait, promisify wraps exec.
|
describe('isProcessRunning', () => {
|
||||||
// If I mock exec, promisify(exec) will use the mock.
|
it('should return true when Antigravity main process is found on macOS', async () => {
|
||||||
// But my mock implementation needs to match what promisify expects or I need to mock the promisified function.
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
// Since I mocked 'util', promisify returns the fn itself.
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
// 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: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip complex mocking tests for now as they are fragile without proper setup.
|
it('should return false when only helper processes are found', async () => {
|
||||||
// I'll write a basic test structure.
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
it('should be defined', () => {
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12346,
|
||||||
|
name: 'Antigravity Helper (Renderer)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (Renderer).app --type=renderer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pid: 12347,
|
||||||
|
name: 'Antigravity Helper (GPU)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (GPU).app --type=gpu-process',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when only manager process is found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12348,
|
||||||
|
name: 'Antigravity Manager',
|
||||||
|
cmd: '/Applications/Antigravity Manager.app/Contents/MacOS/Antigravity Manager',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no processes are found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip self process', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 12345, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345, // Same as current PID
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when Antigravity.exe is found on Windows', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity.exe',
|
||||||
|
cmd: 'C:\\Program Files\\Antigravity\\Antigravity.exe',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when antigravity is found on Linux', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'antigravity',
|
||||||
|
cmd: '/usr/bin/antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle find-process errors gracefully', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockRejectedValue(new Error('Process enumeration failed'));
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude processes with --type= argument', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity --type=utility',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Module exports', () => {
|
||||||
|
it('should export all required functions', () => {
|
||||||
expect(isProcessRunning).toBeDefined();
|
expect(isProcessRunning).toBeDefined();
|
||||||
expect(closeAntigravity).toBeDefined();
|
expect(closeAntigravity).toBeDefined();
|
||||||
expect(startAntigravity).toBeDefined();
|
expect(startAntigravity).toBeDefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ProtobufUtils } from '../../utils/protobuf';
|
||||||
|
|
||||||
|
describe('ProtobufUtils Unified OAuth', () => {
|
||||||
|
it('should round-trip OAuthInfo payload', () => {
|
||||||
|
const accessToken = 'access-token-123';
|
||||||
|
const refreshToken = 'refresh-token-456';
|
||||||
|
const expiry = 1700000000;
|
||||||
|
|
||||||
|
const oauthInfo = ProtobufUtils.createOAuthInfo(accessToken, refreshToken, expiry);
|
||||||
|
const parsed = ProtobufUtils.extractOAuthTokenInfoFromOAuthInfo(oauthInfo);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round-trip unified oauth token', () => {
|
||||||
|
const accessToken = 'access-token-abc';
|
||||||
|
const refreshToken = 'refresh-token-def';
|
||||||
|
const expiry = 1700001234;
|
||||||
|
|
||||||
|
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiry,
|
||||||
|
);
|
||||||
|
const unifiedBytes = new Uint8Array(Buffer.from(unifiedB64, 'base64'));
|
||||||
|
const parsed = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(unifiedBytes);
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AccountBackupData {
|
|||||||
// Key-value pairs from Antigravity database
|
// Key-value pairs from Antigravity database
|
||||||
antigravityAuthStatus?: string;
|
antigravityAuthStatus?: string;
|
||||||
'jetskiStateSync.agentManagerInitState'?: string;
|
'jetskiStateSync.agentManagerInitState'?: string;
|
||||||
|
'antigravityUnifiedStateSync.oauthToken'?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CloudAccountRowSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
provider: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
name: z.string().nullable(),
|
||||||
|
avatar_url: z.string().nullable(),
|
||||||
|
token_json: z.string(),
|
||||||
|
quota_json: z.string().nullable(),
|
||||||
|
created_at: z.number(),
|
||||||
|
last_used: z.number(),
|
||||||
|
status: z.string().nullable(),
|
||||||
|
is_active: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CloudAccountRow = z.infer<typeof CloudAccountRowSchema>;
|
||||||
|
|
||||||
|
export const AccountTokenRowSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
token_json: z.string(),
|
||||||
|
quota_json: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AccountTokenRow = z.infer<typeof AccountTokenRowSchema>;
|
||||||
|
|
||||||
|
export const SettingsRowSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SettingsRow = z.infer<typeof SettingsRowSchema>;
|
||||||
|
|
||||||
|
export const SettingsValueRowSchema = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SettingsValueRow = z.infer<typeof SettingsValueRowSchema>;
|
||||||
|
|
||||||
|
export const ItemTableKeys = [
|
||||||
|
'antigravityAuthStatus',
|
||||||
|
'antigravityOnboarding',
|
||||||
|
'antigravityUnifiedStateSync.oauthToken',
|
||||||
|
'jetskiStateSync.agentManagerInitState',
|
||||||
|
'google.antigravity',
|
||||||
|
'antigravityUserSettings.allUserSettings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ItemTableKeySchema = z.enum(ItemTableKeys);
|
||||||
|
|
||||||
|
export type ItemTableKey = z.infer<typeof ItemTableKeySchema>;
|
||||||
|
|
||||||
|
export const ItemTableRowSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ItemTableRow = z.infer<typeof ItemTableRowSchema>;
|
||||||
|
|
||||||
|
export const ItemTableValueRowSchema = z.object({
|
||||||
|
value: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ItemTableValueRow = z.infer<typeof ItemTableValueRowSchema>;
|
||||||
|
|
||||||
|
export const TableInfoRowSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TableInfoRow = z.infer<typeof TableInfoRowSchema>;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { getAntigravityExecutablePath } from './paths';
|
||||||
|
|
||||||
|
export interface AntigravityVersion {
|
||||||
|
shortVersion: string;
|
||||||
|
bundleVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedVersion: AntigravityVersion | null = null;
|
||||||
|
let cachedError: Error | null = null;
|
||||||
|
|
||||||
|
function cacheAndReturn(version: AntigravityVersion): AntigravityVersion {
|
||||||
|
cachedVersion = version;
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageJsonVersion(execPath: string): AntigravityVersion | null {
|
||||||
|
const parentDir = path.dirname(execPath);
|
||||||
|
const packageJson = path.join(parentDir, 'resources', 'app', 'package.json');
|
||||||
|
if (!fs.existsSync(packageJson)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(packageJson, 'utf-8');
|
||||||
|
const json = JSON.parse(content) as { version?: string };
|
||||||
|
const parsed = parseVersionString(json.version || null);
|
||||||
|
return {
|
||||||
|
shortVersion: parsed,
|
||||||
|
bundleVersion: parsed,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlistValue(content: string, key: string): string | null {
|
||||||
|
const pattern = new RegExp(`<key>${key}<\\/key>\\s*<string>([^<]+)<\\/string>`, 'i');
|
||||||
|
const match = content.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionString(version: string | null): string {
|
||||||
|
if (!version) {
|
||||||
|
throw new Error('Version information not found');
|
||||||
|
}
|
||||||
|
const trimmed = version.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Version information is empty');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAntigravityVersion(): AntigravityVersion {
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
if (cachedError) {
|
||||||
|
throw cachedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const execPath = getAntigravityExecutablePath();
|
||||||
|
if (!execPath) {
|
||||||
|
throw new Error('Unable to locate Antigravity executable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
const escapedPath = execPath.replace(/'/g, "''");
|
||||||
|
const command = `(Get-Item '${escapedPath}').VersionInfo.FileVersion`;
|
||||||
|
const version = execSync(`powershell -NoProfile -Command "${command}"`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const parsed = parseVersionString(version);
|
||||||
|
return cacheAndReturn({
|
||||||
|
shortVersion: parsed,
|
||||||
|
bundleVersion: parsed,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = readPackageJsonVersion(execPath);
|
||||||
|
if (fallback) {
|
||||||
|
return cacheAndReturn(fallback);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const appIndex = execPath.toLowerCase().indexOf('.app');
|
||||||
|
const appPath = appIndex >= 0 ? execPath.slice(0, appIndex + 4) : execPath;
|
||||||
|
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
||||||
|
if (!fs.existsSync(plistPath)) {
|
||||||
|
throw new Error(`Info.plist not found: ${plistPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs.readFileSync(plistPath, 'utf-8');
|
||||||
|
if (content.startsWith('bplist')) {
|
||||||
|
try {
|
||||||
|
content = execSync(`plutil -convert xml1 -o - "${plistPath}"`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('Failed to parse Info.plist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortVersion = parseVersionString(
|
||||||
|
readPlistValue(content, 'CFBundleShortVersionString'),
|
||||||
|
);
|
||||||
|
const bundleVersion = parseVersionString(
|
||||||
|
readPlistValue(content, 'CFBundleVersion') || shortVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
return cacheAndReturn({
|
||||||
|
shortVersion,
|
||||||
|
bundleVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const output = execSync(`"${execPath}" --version`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
const parsed = parseVersionString(output);
|
||||||
|
return cacheAndReturn({
|
||||||
|
shortVersion: parsed,
|
||||||
|
bundleVersion: parsed,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const fallback = readPackageJsonVersion(execPath);
|
||||||
|
if (fallback) {
|
||||||
|
return cacheAndReturn(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unable to determine Antigravity version');
|
||||||
|
} catch (error) {
|
||||||
|
const normalized =
|
||||||
|
error instanceof Error ? error : new Error('Unable to determine Antigravity version');
|
||||||
|
cachedError = normalized;
|
||||||
|
throw normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareVersion(v1: string, v2: string): number {
|
||||||
|
const parts1 = v1
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10))
|
||||||
|
.filter((value) => !Number.isNaN(value));
|
||||||
|
const parts2 = v2
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10))
|
||||||
|
.filter((value) => !Number.isNaN(value));
|
||||||
|
const length = Math.max(parts1.length, parts2.length);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const left = parts1[i] ?? 0;
|
||||||
|
const right = parts2[i] ?? 0;
|
||||||
|
if (left > right) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (left < right) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewVersion(version: AntigravityVersion): boolean {
|
||||||
|
return compareVersion(version.shortVersion, '1.16.5') >= 0;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru';
|
||||||
|
|
||||||
|
const installNoticeText: Record<
|
||||||
|
InstallNoticeLanguage,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
detailPrefix: string;
|
||||||
|
buttons: [string, string];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
'zh-CN': {
|
||||||
|
title: '建议从开始菜单启动',
|
||||||
|
message:
|
||||||
|
'检测到你正在从非安装路径启动应用。为了确保自动更新生效,请从开始菜单或桌面快捷方式启动。如果没有快捷方式,请重新运行安装包。',
|
||||||
|
detailPrefix: '安装目录:',
|
||||||
|
buttons: ['打开安装目录', '知道了'],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Please launch from the Start menu',
|
||||||
|
message:
|
||||||
|
'We detected the app is running from a non-install location. To ensure auto-updates work, launch it from the Start menu or desktop shortcut. If no shortcut exists, run the installer again.',
|
||||||
|
detailPrefix: 'Install location: ',
|
||||||
|
buttons: ['Open install folder', 'OK'],
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
title: 'Запускайте приложение из меню «Пуск»',
|
||||||
|
message:
|
||||||
|
'Обнаружен запуск из неустановленного пути. Чтобы автообновления работали, запускайте приложение из меню «Пуск» или ярлыка. Если ярлыка нет, запустите установщик ещё раз.',
|
||||||
|
detailPrefix: 'Папка установки: ',
|
||||||
|
buttons: ['Открыть папку', 'Понятно'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveInstallNoticeLanguage({
|
||||||
|
configLanguage,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
configLanguage?: string | null;
|
||||||
|
locale?: string | null;
|
||||||
|
}): InstallNoticeLanguage {
|
||||||
|
const rawLanguage = configLanguage || locale;
|
||||||
|
if (!rawLanguage) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawLanguage.toLowerCase();
|
||||||
|
if (normalized.startsWith('zh')) {
|
||||||
|
return 'zh-CN';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('ru')) {
|
||||||
|
return 'ru';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallNoticeText(language: InstallNoticeLanguage) {
|
||||||
|
return installNoticeText[language] || installNoticeText.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathApi(platform: string) {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return path.win32;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWindowsInstallDirName(appName: string) {
|
||||||
|
return appName
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpectedInstallRoot({
|
||||||
|
platform,
|
||||||
|
localAppData,
|
||||||
|
appName,
|
||||||
|
}: {
|
||||||
|
platform: string;
|
||||||
|
localAppData?: string | null;
|
||||||
|
appName: string;
|
||||||
|
}) {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localAppData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathApi = getPathApi(platform);
|
||||||
|
const installDirName = normalizeWindowsInstallDirName(appName);
|
||||||
|
return pathApi.resolve(pathApi.join(localAppData, installDirName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRunningFromExpectedInstallDir({
|
||||||
|
platform,
|
||||||
|
isPackaged,
|
||||||
|
localAppData,
|
||||||
|
appName,
|
||||||
|
execPath,
|
||||||
|
}: {
|
||||||
|
platform: string;
|
||||||
|
isPackaged: boolean;
|
||||||
|
localAppData?: string | null;
|
||||||
|
appName: string;
|
||||||
|
execPath: string;
|
||||||
|
}) {
|
||||||
|
if (platform !== 'win32' || !isPackaged) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedRoot = getExpectedInstallRoot({ platform, localAppData, appName });
|
||||||
|
if (!expectedRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathApi = getPathApi(platform);
|
||||||
|
const normalizedExecPath = pathApi.resolve(execPath);
|
||||||
|
|
||||||
|
return normalizedExecPath.toLowerCase().startsWith(expectedRoot.toLowerCase() + pathApi.sep);
|
||||||
|
}
|
||||||
+125
-30
@@ -1,9 +1,30 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import winston from 'winston';
|
||||||
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||||
import { getAgentDir } from './paths';
|
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;
|
||||||
|
const LOG_RETENTION = '30d';
|
||||||
|
const LOG_MAX_SIZE = '10m';
|
||||||
|
|
||||||
|
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
|
||||||
@@ -32,10 +53,14 @@ function safeStringify(obj: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private logFilePath: string;
|
private winstonLogger: winston.Logger;
|
||||||
|
private recentLogs: LogEntry[] = [];
|
||||||
|
private sentryReporter: SentryReporter | null = null;
|
||||||
|
private sentryEnabled = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
|
|
||||||
if (!fs.existsSync(agentDir)) {
|
if (!fs.existsSync(agentDir)) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(agentDir, { recursive: true });
|
fs.mkdirSync(agentDir, { recursive: true });
|
||||||
@@ -43,42 +68,112 @@ class Logger {
|
|||||||
console.error('Failed to create agent directory for logs', e);
|
console.error('Failed to create agent directory for logs', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logFilePath = path.join(agentDir, 'app.log');
|
|
||||||
|
const fileFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.printf(({ timestamp, level, message }) => {
|
||||||
|
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize({ all: true }),
|
||||||
|
winston.format.printf(({ level, message }) => {
|
||||||
|
return `[${level.toUpperCase()}] ${message}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotateTransport = new DailyRotateFile({
|
||||||
|
filename: path.join(agentDir, 'app-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxSize: LOG_MAX_SIZE,
|
||||||
|
maxFiles: LOG_RETENTION,
|
||||||
|
zippedArchive: false,
|
||||||
|
auditFile: path.join(agentDir, '.app-log-audit.json'),
|
||||||
|
level: 'debug',
|
||||||
|
format: fileFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
rotateTransport.on('error', (error) => {
|
||||||
|
console.error('DailyRotateFile transport error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleTransport = new winston.transports.Console({
|
||||||
|
level: 'debug',
|
||||||
|
format: consoleFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleTransport.on('error', (error) => {
|
||||||
|
console.error('Console transport error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.winstonLogger = winston.createLogger({
|
||||||
|
level: 'debug',
|
||||||
|
transports: [consoleTransport, rotateTransport],
|
||||||
|
exitOnError: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatMessage(level: LogLevel, message: string, ...args: unknown[]): string {
|
private pruneLogs(now: number) {
|
||||||
const timestamp = new Date().toISOString();
|
while (this.recentLogs.length > 0 && now - this.recentLogs[0].timestamp > LOG_WINDOW_MS) {
|
||||||
const formattedArgs = args
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatArgs(args: unknown[]): string {
|
||||||
|
return args
|
||||||
.map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg)))
|
.map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg)))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
return `[${timestamp}] [${level.toUpperCase()}] ${message} ${formattedArgs}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeToFile(formattedMessage: string) {
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(this.logFilePath, formattedMessage + '\n');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to write to log file', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeToConsole(level: LogLevel, message: string, ...args: unknown[]) {
|
|
||||||
const colorMap: Record<LogLevel, string> = {
|
|
||||||
info: '\x1b[36m', // Cyan
|
|
||||||
warn: '\x1b[33m', // Yellow
|
|
||||||
error: '\x1b[31m', // Red
|
|
||||||
debug: '\x1b[90m', // Gray
|
|
||||||
};
|
|
||||||
const reset = '\x1b[0m';
|
|
||||||
const color = colorMap[level];
|
|
||||||
|
|
||||||
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||||
const formattedMessage = this.formatMessage(level, message, ...args);
|
const formattedArgs = this.formatArgs(args);
|
||||||
this.writeToConsole(level, message, ...args);
|
const mergedMessage = formattedArgs ? `${message} ${formattedArgs}` : message;
|
||||||
this.writeToFile(formattedMessage);
|
const now = Date.now();
|
||||||
|
const formattedMessage = `[${new Date(now).toISOString()}] [${level.toUpperCase()}] ${mergedMessage}`;
|
||||||
|
|
||||||
|
this.recentLogs.push({
|
||||||
|
timestamp: now,
|
||||||
|
level,
|
||||||
|
message: mergedMessage,
|
||||||
|
formatted: formattedMessage,
|
||||||
|
});
|
||||||
|
this.pruneLogs(now);
|
||||||
|
|
||||||
|
this.winstonLogger.log({
|
||||||
|
level,
|
||||||
|
message: mergedMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (level === 'error' && this.sentryEnabled && this.sentryReporter) {
|
||||||
|
this.sentryReporter({
|
||||||
|
level,
|
||||||
|
message: mergedMessage,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-1
@@ -40,9 +40,11 @@ export class ProtobufUtils {
|
|||||||
return this.readVarint(data, offset).nextOffset;
|
return this.readVarint(data, offset).nextOffset;
|
||||||
case 1: // 64-bit
|
case 1: // 64-bit
|
||||||
return offset + 8;
|
return offset + 8;
|
||||||
case 2: // Length-delimited
|
case 2: {
|
||||||
|
// Length-delimited
|
||||||
const { value: length, nextOffset } = this.readVarint(data, offset);
|
const { value: length, nextOffset } = this.readVarint(data, offset);
|
||||||
return nextOffset + Number(length);
|
return nextOffset + Number(length);
|
||||||
|
}
|
||||||
case 5: // 32-bit
|
case 5: // 32-bit
|
||||||
return offset + 4;
|
return offset + 4;
|
||||||
default:
|
default:
|
||||||
@@ -91,6 +93,24 @@ export class ProtobufUtils {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static encodeLenDelimField(fieldNum: number, data: Uint8Array): Uint8Array {
|
||||||
|
const tag = (fieldNum << 3) | 2;
|
||||||
|
const tagBytes = this.encodeVarint(tag);
|
||||||
|
const lenBytes = this.encodeVarint(data.length);
|
||||||
|
|
||||||
|
const result = new Uint8Array(tagBytes.length + lenBytes.length + data.length);
|
||||||
|
result.set(tagBytes, 0);
|
||||||
|
result.set(lenBytes, tagBytes.length);
|
||||||
|
result.set(data, tagBytes.length + lenBytes.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static encodeStringField(fieldNum: number, value: string): Uint8Array {
|
||||||
|
const utf8Encode = new TextEncoder();
|
||||||
|
const bytes = utf8Encode.encode(value);
|
||||||
|
return this.encodeLenDelimField(fieldNum, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
// Create timestamp field (Field 4 -> Field 1 varint)
|
// Create timestamp field (Field 4 -> Field 1 varint)
|
||||||
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
||||||
// Timestamp message format: Field 1 (seconds) as varint
|
// Timestamp message format: Field 1 (seconds) as varint
|
||||||
@@ -196,4 +216,92 @@ export class ProtobufUtils {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static createOAuthInfo(accessToken: string, refreshToken: string, expiry: number): Uint8Array {
|
||||||
|
const field1 = this.encodeStringField(1, accessToken);
|
||||||
|
const field2 = this.encodeStringField(2, 'Bearer');
|
||||||
|
const field3 = this.encodeStringField(3, refreshToken);
|
||||||
|
|
||||||
|
const timestampTag = (1 << 3) | 0;
|
||||||
|
const tagBytes = this.encodeVarint(timestampTag);
|
||||||
|
const secondsBytes = this.encodeVarint(expiry);
|
||||||
|
const timestampMsg = new Uint8Array(tagBytes.length + secondsBytes.length);
|
||||||
|
timestampMsg.set(tagBytes, 0);
|
||||||
|
timestampMsg.set(secondsBytes, tagBytes.length);
|
||||||
|
|
||||||
|
const field4 = this.encodeLenDelimField(4, timestampMsg);
|
||||||
|
|
||||||
|
const combined = new Uint8Array(
|
||||||
|
field1.length + field2.length + field3.length + field4.length,
|
||||||
|
);
|
||||||
|
combined.set(field1, 0);
|
||||||
|
combined.set(field2, field1.length);
|
||||||
|
combined.set(field3, field1.length + field2.length);
|
||||||
|
combined.set(field4, field1.length + field2.length + field3.length);
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createUnifiedOAuthToken(
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string,
|
||||||
|
expiry: number,
|
||||||
|
): string {
|
||||||
|
const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry);
|
||||||
|
const oauthInfoB64 = Buffer.from(oauthInfo).toString('base64');
|
||||||
|
|
||||||
|
const inner2 = this.encodeStringField(1, oauthInfoB64);
|
||||||
|
const inner1 = this.encodeStringField(1, 'oauthTokenInfoSentinelKey');
|
||||||
|
const innerField2 = this.encodeLenDelimField(2, inner2);
|
||||||
|
|
||||||
|
const inner = new Uint8Array(inner1.length + innerField2.length);
|
||||||
|
inner.set(inner1, 0);
|
||||||
|
inner.set(innerField2, inner1.length);
|
||||||
|
|
||||||
|
const outer = this.encodeLenDelimField(1, inner);
|
||||||
|
return Buffer.from(outer).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
static extractOAuthTokenInfoFromOAuthInfo(
|
||||||
|
data: Uint8Array,
|
||||||
|
): { accessToken: string; refreshToken: string } | null {
|
||||||
|
const accessTokenBytes = this.getField(data, 1);
|
||||||
|
const refreshTokenBytes = this.getField(data, 3);
|
||||||
|
|
||||||
|
if (accessTokenBytes && refreshTokenBytes) {
|
||||||
|
return {
|
||||||
|
accessToken: this.readString(accessTokenBytes),
|
||||||
|
refreshToken: this.readString(refreshTokenBytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static extractOAuthTokenInfoFromUnifiedState(
|
||||||
|
data: Uint8Array,
|
||||||
|
): { accessToken: string; refreshToken: string } | null {
|
||||||
|
const inner = this.getField(data, 1);
|
||||||
|
if (!inner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner2 = this.getField(inner, 2);
|
||||||
|
if (!inner2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthInfoB64Bytes = this.getField(inner2, 1);
|
||||||
|
if (!oauthInfoB64Bytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthInfoB64 = this.readString(oauthInfoB64Bytes);
|
||||||
|
let oauthInfoBytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
oauthInfoBytes = new Uint8Array(Buffer.from(oauthInfoB64, 'base64'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.extractOAuthTokenInfoFromOAuthInfo(oauthInfoBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+419
-48
@@ -1,40 +1,342 @@
|
|||||||
import keytar from 'keytar';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { safeStorage, app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
const SERVICE_NAME = 'AntigravityManager';
|
const SERVICE_NAME = 'AntigravityManager';
|
||||||
const ACCOUNT_NAME = 'MasterKey';
|
const ACCOUNT_NAME = 'MasterKey';
|
||||||
|
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
|
||||||
|
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
|
||||||
|
const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED';
|
||||||
|
const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE';
|
||||||
|
const DATA_MIGRATION_ERROR_CODE = 'ERR_DATA_MIGRATION_FAILED';
|
||||||
|
const DATA_MIGRATION_HINT_RELOGIN = 'HINT_RELOGIN';
|
||||||
|
|
||||||
|
export type KeySource = 'safeStorage' | 'keytar' | 'file';
|
||||||
|
|
||||||
|
interface MasterKeyState {
|
||||||
|
key: Buffer;
|
||||||
|
source: KeySource;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the key in memory to avoid frequent system calls
|
// Cache the key in memory to avoid frequent system calls
|
||||||
let cachedMasterKey: Buffer | null = null;
|
let cachedMasterKey: Buffer | null = null;
|
||||||
|
let cachedMasterKeySource: KeySource | null = null;
|
||||||
|
|
||||||
async function getOrGenerateMasterKey(): Promise<Buffer> {
|
function cacheMasterKey(key: Buffer, source: KeySource) {
|
||||||
if (cachedMasterKey) return cachedMasterKey;
|
cachedMasterKey = key;
|
||||||
|
cachedMasterKeySource = source;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function getCachedMasterKey() {
|
||||||
let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
if (!cachedMasterKey || !cachedMasterKeySource) {
|
||||||
|
return null;
|
||||||
if (!hexKey) {
|
|
||||||
logger.info('Security: Generating new master key...');
|
|
||||||
// Generate 256-bit key (32 bytes)
|
|
||||||
const buffer = crypto.randomBytes(32);
|
|
||||||
hexKey = buffer.toString('hex');
|
|
||||||
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedMasterKey = Buffer.from(hexKey, 'hex');
|
return { key: cachedMasterKey, source: cachedMasterKeySource };
|
||||||
return cachedMasterKey;
|
}
|
||||||
|
|
||||||
|
function buildKeychainAccessHint(error: unknown): string | null {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let appPath = '';
|
||||||
|
try {
|
||||||
|
appPath = app.getAppPath();
|
||||||
|
} catch {
|
||||||
|
appPath = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTranslocated = appPath.includes('/AppTranslocation/');
|
||||||
|
if (isTranslocated) {
|
||||||
|
return KEYCHAIN_HINT_TRANSLOCATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (errorMessage.toLowerCase().includes('keychain')) {
|
||||||
|
return KEYCHAIN_HINT_KEYCHAIN_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KEYCHAIN_HINT_SIGN_NOTARIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock to prevent concurrent key generation
|
||||||
|
let keyGenerationInProgress: Promise<MasterKeyState> | null = null;
|
||||||
|
|
||||||
|
// Fallback key file path (used when keytar and safeStorage both fail)
|
||||||
|
function getFallbackKeyPath(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, '.mk');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to load keytar dynamically to avoid hard failure if it's not available
|
||||||
|
*/
|
||||||
|
async function tryKeytar(): Promise<typeof import('keytar') | null> {
|
||||||
|
try {
|
||||||
|
// Native modules may fail to load in production builds
|
||||||
|
const keytar = await import('keytar');
|
||||||
|
// Test if keytar is actually working by calling a method
|
||||||
|
await keytar.default.findCredentials(SERVICE_NAME);
|
||||||
|
return keytar.default;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.warn('Security: keytar not available, using fallback', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSafeStorageKey(keyPath: string): Promise<Buffer | null> {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptedKey = await fs.readFile(keyPath);
|
||||||
|
const hexKey = safeStorage.decryptString(encryptedKey);
|
||||||
|
|
||||||
|
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(hexKey, 'hex');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Security: Failed to read safeStorage key file', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateSafeStorageKey(
|
||||||
|
keyPath: string,
|
||||||
|
): Promise<{ key: Buffer; created: boolean }> {
|
||||||
|
const existingKey = await readSafeStorageKey(keyPath);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: existingKey, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
const encrypted = safeStorage.encryptString(hexKey);
|
||||||
|
await atomicWriteFile(keyPath, encrypted, { mode: 0o600 });
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readKeytarKey(): Promise<Buffer | null> {
|
||||||
|
const keytar = await tryKeytar();
|
||||||
|
if (!keytar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
||||||
|
if (!existingKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-f0-9]+$/i.test(existingKey) || existingKey.length !== 64) {
|
||||||
|
logger.warn('Security: keytar key has unexpected format');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(existingKey, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateKeytarKey(): Promise<{ key: Buffer; created: boolean } | null> {
|
||||||
|
const keytar = await tryKeytar();
|
||||||
|
if (!keytar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: Buffer.from(existingKey, 'hex'), created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey);
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileFallbackKey(keyPath: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(keyPath, 'utf8');
|
||||||
|
if (content.length === 64 && /^[a-f0-9]+$/i.test(content)) {
|
||||||
|
return Buffer.from(content, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Security: Error reading fallback key file', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateFileFallbackKey(
|
||||||
|
keyPath: string,
|
||||||
|
): Promise<{ key: Buffer; created: boolean }> {
|
||||||
|
const existingKey = await readFileFallbackKey(keyPath);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: existingKey, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
await atomicWriteFile(keyPath, hexKey, { mode: 0o600 });
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFallbackMasterKeys(
|
||||||
|
keyPath: string,
|
||||||
|
primarySource: KeySource,
|
||||||
|
): Promise<MasterKeyState[]> {
|
||||||
|
const fallbackKeys: MasterKeyState[] = [];
|
||||||
|
|
||||||
|
if (primarySource !== 'keytar') {
|
||||||
|
const keytarKey = await readKeytarKey();
|
||||||
|
if (keytarKey) {
|
||||||
|
fallbackKeys.push({ key: keytarKey, source: 'keytar' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primarySource !== 'file') {
|
||||||
|
const fileKey = await readFileFallbackKey(keyPath);
|
||||||
|
if (fileKey) {
|
||||||
|
fallbackKeys.push({ key: fileKey, source: 'file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primarySource !== 'safeStorage') {
|
||||||
|
const safeStorageKey = await readSafeStorageKey(keyPath);
|
||||||
|
if (safeStorageKey) {
|
||||||
|
fallbackKeys.push({ key: safeStorageKey, source: 'safeStorage' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic file write to prevent data races
|
||||||
|
* Writes to a temp file first, then renames atomically
|
||||||
|
*/
|
||||||
|
async function atomicWriteFile(
|
||||||
|
filePath: string,
|
||||||
|
data: Buffer | string,
|
||||||
|
options?: { mode?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tempPath, data, { mode: options?.mode ?? 0o600 });
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file if rename failed
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate master encryption key using multiple fallback strategies:
|
||||||
|
* 1. safeStorage (Electron's built-in secure storage) - preferred
|
||||||
|
* 2. keytar (system keychain) - fallback
|
||||||
|
* 3. File-based with safeStorage encryption - last resort (with security warning)
|
||||||
|
*
|
||||||
|
* Uses a lock to prevent concurrent key generation (data race prevention)
|
||||||
|
*/
|
||||||
|
async function getPrimaryMasterKey(): Promise<MasterKeyState> {
|
||||||
|
const cached = getCachedMasterKey();
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyGenerationInProgress) {
|
||||||
|
return keyGenerationInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyGenerationInProgress = generatePrimaryMasterKey();
|
||||||
|
try {
|
||||||
|
return await keyGenerationInProgress;
|
||||||
|
} finally {
|
||||||
|
keyGenerationInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
|
||||||
|
const cached = getCachedMasterKey();
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPath = getFallbackKeyPath();
|
||||||
|
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateSafeStorageKey(keyPath);
|
||||||
|
cacheMasterKey(result.key, 'safeStorage');
|
||||||
|
if (result.created) {
|
||||||
|
logger.info('Security: Generated new master key via safeStorage');
|
||||||
|
} else {
|
||||||
|
logger.info('Security: Loaded master key via safeStorage');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'safeStorage' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Security: safeStorage failed, trying keytar', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateKeytarKey();
|
||||||
|
if (result) {
|
||||||
|
cacheMasterKey(result.key, 'keytar');
|
||||||
|
if (result.created) {
|
||||||
|
logger.info('Security: Generated new master key via keytar');
|
||||||
|
} else {
|
||||||
|
logger.info('Security: Loaded master key via keytar');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'keytar' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Security: keytar failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Security: WARNING - Using file-based key storage. ' +
|
||||||
|
'This is less secure than system keychain. ' +
|
||||||
|
'Ensure the app data directory has restricted permissions.',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateFileFallbackKey(keyPath);
|
||||||
|
cacheMasterKey(result.key, 'file');
|
||||||
|
if (result.created) {
|
||||||
|
logger.warn('Security: Generating file-based fallback key (less secure)');
|
||||||
|
} else {
|
||||||
|
logger.warn('Security: Using file-based fallback key (less secure)');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'file' };
|
||||||
|
} catch (error) {
|
||||||
|
const hint = buildKeychainAccessHint(error);
|
||||||
logger.error('Security: Failed to access keychain/credential manager', error);
|
logger.error('Security: Failed to access keychain/credential manager', error);
|
||||||
// Fallback? If we can't store the key, we can't persistently encrypt.
|
const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE;
|
||||||
// For now, throw to prevent data loss (better not to write than to write something we can't decrypt later or write plain text when promised encrypted)
|
throw new Error(message);
|
||||||
throw new Error('Key management system unavailable');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a string using AES-256-GCM.
|
* Encrypts a string using AES-256-GCM.
|
||||||
@@ -42,7 +344,15 @@ const AUTH_TAG_LENGTH = 16;
|
|||||||
*/
|
*/
|
||||||
export async function encrypt(text: string): Promise<string> {
|
export async function encrypt(text: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const key = await getOrGenerateMasterKey();
|
const { key } = await getPrimaryMasterKey();
|
||||||
|
return encryptWithKey(key, text);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Security: Encryption failed', error);
|
||||||
|
throw new Error('Encryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptWithKey(key: Buffer, text: string): string {
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
@@ -52,36 +362,15 @@ export async function encrypt(text: string): Promise<string> {
|
|||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Format: iv:authTag:encrypted
|
|
||||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
} catch (error) {
|
|
||||||
logger.error('Security: Encryption failed', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function decryptWithKey(
|
||||||
* Decrypts a string using AES-256-GCM.
|
key: Buffer,
|
||||||
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
|
ivHex: string,
|
||||||
*/
|
authTagHex: string,
|
||||||
export async function decrypt(text: string): Promise<string> {
|
encryptedHex: string,
|
||||||
// Check if it's plain text (JSON) for backward compatibility
|
): string {
|
||||||
// Very rough check: starts with { or [
|
|
||||||
if (text.startsWith('{') || text.startsWith('[')) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also checking if it follows our pattern
|
|
||||||
const parts = text.split(':');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
// Treat as plain text if it doesn't look like our encrypted format
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = await getOrGenerateMasterKey();
|
|
||||||
|
|
||||||
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
const authTag = Buffer.from(authTagHex, 'hex');
|
const authTag = Buffer.from(authTagHex, 'hex');
|
||||||
|
|
||||||
@@ -92,9 +381,91 @@ export async function decrypt(text: string): Promise<string> {
|
|||||||
decrypted += decipher.final('utf8');
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthTagMismatchError(errorMessage: string): boolean {
|
||||||
|
const normalized = errorMessage.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes('unable to authenticate data') ||
|
||||||
|
normalized.includes('auth tag') ||
|
||||||
|
normalized.includes('bad decrypt')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptWithMigration(
|
||||||
|
text: string,
|
||||||
|
): Promise<{ value: string; reencrypted?: string; usedFallback?: KeySource }> {
|
||||||
|
if (text.startsWith('{') || text.startsWith('[')) {
|
||||||
|
return { value: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = text.split(':');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return { value: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ivHex, authTagHex, encryptedHex] = parts;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!/^[a-f0-9]+$/i.test(ivHex) ||
|
||||||
|
!/^[a-f0-9]+$/i.test(authTagHex) ||
|
||||||
|
!/^[a-f0-9]+$/i.test(encryptedHex)
|
||||||
|
) {
|
||||||
|
logger.warn('Security: Invalid encrypted format - not valid hex');
|
||||||
|
throw new Error('Invalid encrypted data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = await getPrimaryMasterKey();
|
||||||
|
try {
|
||||||
|
return { value: decryptWithKey(primary.key, ivHex, authTagHex, encryptedHex) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
if (errorMessage.includes('Invalid key length') || errorMessage.includes('Invalid IV length')) {
|
||||||
|
logger.error('Security: Decryption failed - corrupted encrypted data');
|
||||||
|
throw new Error('Decryption failed: Corrupted encrypted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthTagMismatchError(errorMessage)) {
|
||||||
logger.error('Security: Decryption failed', error);
|
logger.error('Security: Decryption failed', error);
|
||||||
// If decryption fails, it might be corrupted or using a different key.
|
throw new Error('Decryption failed');
|
||||||
throw error;
|
}
|
||||||
|
|
||||||
|
const keyPath = getFallbackKeyPath();
|
||||||
|
const fallbackKeys = await getFallbackMasterKeys(keyPath, primary.source);
|
||||||
|
for (const fallback of fallbackKeys) {
|
||||||
|
try {
|
||||||
|
const value = decryptWithKey(fallback.key, ivHex, authTagHex, encryptedHex);
|
||||||
|
let reencrypted: string | undefined;
|
||||||
|
try {
|
||||||
|
reencrypted = encryptWithKey(primary.key, value);
|
||||||
|
} catch (reencryptError) {
|
||||||
|
logger.warn(
|
||||||
|
`Security: Failed to re-encrypt data from ${fallback.source} to ${primary.source}`,
|
||||||
|
reencryptError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reencrypted) {
|
||||||
|
logger.info(`Security: Re-encrypted data from ${fallback.source} to ${primary.source}`);
|
||||||
|
}
|
||||||
|
return { value, reencrypted, usedFallback: fallback.source };
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
'Security: Decryption failed - authentication tag mismatch (wrong key or corrupted data)',
|
||||||
|
);
|
||||||
|
throw new Error(`${DATA_MIGRATION_ERROR_CODE}|${DATA_MIGRATION_HINT_RELOGIN}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a string using AES-256-GCM.
|
||||||
|
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
|
||||||
|
*/
|
||||||
|
export async function decrypt(text: string): Promise<string> {
|
||||||
|
const result = await decryptWithMigration(text);
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
interface StatementGet {
|
||||||
|
get: (...args: unknown[]) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatementAll {
|
||||||
|
all: (...args: unknown[]) => unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSchemaIssues(context: string, issues: z.core.$ZodIssue[]): void {
|
||||||
|
logger.warn('SQLite row failed schema validation', {
|
||||||
|
context,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRow<T extends z.ZodTypeAny>(
|
||||||
|
schema: T,
|
||||||
|
row: unknown,
|
||||||
|
context: string,
|
||||||
|
): z.infer<T> | null {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(row);
|
||||||
|
if (!parsed.success) {
|
||||||
|
logSchemaIssues(context, parsed.error.issues);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRows<T extends z.ZodTypeAny>(
|
||||||
|
schema: T,
|
||||||
|
rows: unknown[],
|
||||||
|
context: string,
|
||||||
|
): z.infer<T>[] {
|
||||||
|
const parsed: z.infer<T>[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsedRow = parseRow(schema, row, context);
|
||||||
|
if (parsedRow) {
|
||||||
|
parsed.push(parsedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRowFromStatement<T extends z.ZodTypeAny>(
|
||||||
|
stmt: StatementGet,
|
||||||
|
schema: T,
|
||||||
|
context: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): z.infer<T> | null {
|
||||||
|
const row: unknown = stmt.get(...params);
|
||||||
|
return parseRow(schema, row, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRowsFromStatement<T extends z.ZodTypeAny>(
|
||||||
|
stmt: StatementAll,
|
||||||
|
schema: T,
|
||||||
|
context: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): z.infer<T>[] {
|
||||||
|
const rows: unknown[] = stmt.all(...params);
|
||||||
|
return parseRows(schema, rows, context);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
|
"types/**/*.d.ts",
|
||||||
"./package.json",
|
"./package.json",
|
||||||
"./forge.config.ts",
|
"./forge.config.ts",
|
||||||
"*.mts",
|
"*.mts",
|
||||||
|
|||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
declare module 'electron-packager-languages' {
|
||||||
|
type AfterCopyHook = (
|
||||||
|
buildPath: string,
|
||||||
|
electronVersion: string,
|
||||||
|
platform: string,
|
||||||
|
arch: string,
|
||||||
|
callback: () => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface SetLanguagesOptions {
|
||||||
|
allowRemovingAll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLanguages(languages: string[], options?: SetLanguagesOptions): AfterCopyHook;
|
||||||
|
|
||||||
|
export = setLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@pengx17/electron-forge-maker-appimage' {
|
||||||
|
import type MakerBase from '@electron-forge/maker-base';
|
||||||
|
import type { ForgePlatform } from '@electron-forge/shared-types';
|
||||||
|
|
||||||
|
export interface AppImageForgeConfig {
|
||||||
|
template?: string;
|
||||||
|
chmodChromeSandbox?: string;
|
||||||
|
icons?: { file: string; size: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakerAppImageConfig {
|
||||||
|
config?: AppImageForgeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class MakerAppImage extends MakerBase<MakerAppImageConfig> {
|
||||||
|
name: string;
|
||||||
|
defaultPlatforms: ForgePlatform[];
|
||||||
|
isSupportedOnCurrentPlatform(): boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-2
@@ -1,8 +1,25 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
|
||||||
// https://vitejs.dev/config
|
// https://vitejs.dev/config
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: {
|
||||||
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(process.cwd(), './src'),
|
'@': path.resolve(process.cwd(), './src'),
|
||||||
@@ -14,6 +31,8 @@ export default defineConfig({
|
|||||||
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||||
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||||
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||||
|
'@nestjs/microservices': path.resolve(process.cwd(), './src/mocks/nestjs-microservices'),
|
||||||
|
'@nestjs/websockets': path.resolve(process.cwd(), './src/mocks/nestjs-websockets'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -22,4 +41,5 @@ export default defineConfig({
|
|||||||
external: ['better-sqlite3', 'keytar'],
|
external: ['better-sqlite3', 'keytar'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
+30
-3
@@ -1,4 +1,31 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// https://vitejs.dev/config
|
export default defineConfig(({ mode }) => {
|
||||||
export default defineConfig({});
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: {
|
||||||
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(process.cwd(), './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['better-sqlite3', 'keytar'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user