Compare commits

..
35 Commits
Author SHA1 Message Date
semantic-release-bot 00bf4dea0b chore(release): 0.3.4 [skip ci]
## [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))
2026-01-26 19:06:28 +00:00
vin 02080588b7 fix(security): add safeStorage fallback for production builds (#38) (#43)
Summary
  - Replace hard keytar dependency with a multi-strategy key storage:
    1) safeStorage (Electron built-in) — preferred
    2) keytar (system keychain) — fallback
    3) file-based encryption — last resort
  - Dynamically import keytar to avoid asar load failures
  - Refactor cloudHandler to use const (clean code)
  - Fix “Failed to load cloud accounts” in packaged app

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

  Additional changes
  - Replace sync file ops with async (fs/promises)
  - Add atomic file writes to prevent data races
  - Implement concurrency lock for key generation
  - Add security error codes for better diagnostics
  - Add warnings for file-based fallback mode
  - Align error handling after merge
  - Migrate legacy encrypted account data

  References
  - Issue: #38
  - Commit: 92dc2f6
2026-01-27 03:05:41 +08:00
Draculabo d5ee08d5a0 docs: update beta release link 2026-01-26 02:10:10 +08:00
semantic-release-bot e844aa9545 chore(release): 0.3.3 [skip ci]
## [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))
2026-01-25 17:39:31 +00:00
Draculabo 0d4e2ab21f fix: accept lowercase antigravity in process detection 2026-01-26 01:37:58 +08:00
Draculabo f15bb48fdd docs: add beta download link to readme 2026-01-26 01:17:59 +08:00
Draculabo 515648d64d chore(release): add changelog link, PR list, and contributor links to release notes 2026-01-25 22:10:14 +08:00
Draculabo 22265e153c docs: clean up changelog duplicate 2026-01-25 19:08:53 +08:00
semantic-release-bot ad39a735b5 chore(release): 0.3.2 [skip ci]
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)

### 🐛 Bug Fixes

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

### 📝 Documentation

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

### 🔧 Continuous Integration

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

### 🐛 Bug Fixes

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

### 📝 Documentation

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

### 🔧 Continuous Integration

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

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

Fixes #23

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

* Fix: Address code review feedback

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

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

---------

Co-authored-by: Jirka <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-19 16:11:48 +08:00
74 changed files with 8195 additions and 743 deletions

No files matched your search

+3 -4
View File
@@ -1,9 +1,8 @@
name: Publish Release name: Publish Release
on: on:
push: release:
tags: types: [published]
- 'v*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -11,7 +10,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [windows-latest, macos-latest, ubuntu-latest] os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
+37
View File
@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
- beta
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for semantic-release to analyze commits
persist-credentials: false # We use a token in the next steps
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
run: npx semantic-release
+2
View File
@@ -4,3 +4,5 @@
/.vite /.vite
README.md README.md
/src/routeTree.gen.ts /src/routeTree.gen.ts
.agents/
openspec/
-1
View File
@@ -8,5 +8,4 @@
"tabWidth": 2, "tabWidth": 2,
"proseWrap": "preserve", "proseWrap": "preserve",
"tailwindFunctions": ["clsx", "tw"] "tailwindFunctions": ["clsx", "tw"]
} }
+7
View File
@@ -0,0 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}
+14 -6
View File
@@ -1,4 +1,3 @@
<!-- OPENSPEC:START --> <!-- OPENSPEC:START -->
# OpenSpec Instructions # OpenSpec Instructions
@@ -107,8 +106,6 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- `npm run test:e2e` - 运行 Playwright 端到端测试 - `npm run test:e2e` - 运行 Playwright 端到端测试
- `npm run test:all` - 运行所有测试 - `npm run test:all` - 运行所有测试
- **数据库 (DB)**:
- `npm run test-db` - 运行数据库测试脚本
### 运行单个测试 (Running Single Tests) ### 运行单个测试 (Running Single Tests)
@@ -117,6 +114,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- 类型检查: `npm run type-check` - 类型检查: `npm run type-check`
## 🧪 开发注意事项(Development Notes) ## 🧪 开发注意事项(Development Notes)
- **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。 - **Build**: 构建阶段忽略 TS/ESLint 错误(如有特定配置或 CI 要求,请以实际为准)。
- **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。 - **DevTools**: 项目集成了 `code-inspector-plugin`,开发时支持从页面元素直接跳转代码(Shift+Click)。
- **React**: React Strict Mode 关闭。 - **React**: React Strict Mode 关闭。
@@ -141,6 +139,7 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。 - **API 通信**: 前端调用后端优先使用 ORPC 客户端或 IPC,确保类型推导。
### 命名约定 (Naming Specifics) ### 命名约定 (Naming Specifics)
- **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`) - **Functions/Variables**: camelCase (e.g., `handleClick`, `isCurrent`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`) - **Constants**: UPPER_SNAKE_CASE (e.g., `LOCAL_STORAGE_KEYS`)
- **Files**: - **Files**:
@@ -221,6 +220,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
- 标题后保留一行空行,便于渲染与阅读。 - 标题后保留一行空行,便于渲染与阅读。
## 换行规范 ## 换行规范
return 等语句不要与其他语句在同一行,而是单独保持一行 return 等语句不要与其他语句在同一行,而是单独保持一行
## 💭 注释规范 ## 💭 注释规范
@@ -278,7 +278,9 @@ try {
} }
// 使用正确的错误类型 // 使用正确的错误类型
if (error instanceof Error) { /* 处理 Error 实例 */ } if (error instanceof Error) {
/* 处理 Error 实例 */
}
``` ```
### 新功能实现 ### 新功能实现
@@ -301,6 +303,7 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
- \[ ] 形成 TODO 列表 - \[ ] 形成 TODO 列表
实现/重构/修复: 实现/重构/修复:
- [ ] 阅读相关模板与周边代码,沿用既有模式 - [ ] 阅读相关模板与周边代码,沿用既有模式
- [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题 - [ ] Fail fast:对非法输入/状态抛错,尽早暴露问题
- [ ] 在约束内尽力提升前端交互与体验 - [ ] 在约束内尽力提升前端交互与体验
@@ -335,9 +338,11 @@ if (error instanceof Error) { /* 处理 Error 实例 */ }
## 🔧 工具偏好与命令 ## 🔧 工具偏好与命令
包与脚本: 包与脚本:
- `npm install` (或简写 `npm i`) - `npm install` (或简写 `npm i`)
Shell: Shell:
- 在工作区根目录执行命令。 - 在工作区根目录执行命令。
- 路径建议使用引号包裹。 - 路径建议使用引号包裹。
@@ -350,6 +355,7 @@ Web 搜索:
- `context7` 获取依赖的最新用法 - `context7` 获取依赖的最新用法
VSCode MCP (如可用): VSCode MCP (如可用):
- `mcp__vscode-mcp__get_references` 辅助重构范围判定 - `mcp__vscode-mcp__get_references` 辅助重构范围判定
- `mcp__vscode-mcp__rename_symbol` 安全重命名 - `mcp__vscode-mcp__rename_symbol` 安全重命名
- `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息 - `mcp__vscode-mcp__get_symbol_lsp_info` 获取类型/签名/定义等信息
@@ -384,15 +390,17 @@ change description 示例:
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills: How to use skills:
- Invoke: Bash("openskills read <skill-name>") - Invoke: Bash("openskills read <skill-name>")
- The skill content will load with detailed instructions on how to complete the task - The skill content will load with detailed instructions on how to complete the task
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/) - Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
Usage notes: Usage notes:
- Only use skills listed in <available_skills> below - Only use skills listed in <available_skills> below
- Do not invoke a skill that is already loaded in your context - Do not invoke a skill that is already loaded in your context
- Each skill invocation is stateless - Each skill invocation is stateless
</usage> </usage>
<available_skills> <available_skills>
@@ -493,7 +501,7 @@ Usage notes:
</skill> </skill>
</available_skills> </available_skills>
<!-- SKILLS_TABLE_END --> <!-- SKILLS_TABLE_END -->
</skills_system> </skills_system>
+96 -4
View File
@@ -1,46 +1,138 @@
<a name="readme-top"></a>
# Changelog # Changelog
All notable changes to this project will be documented in this file. ## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 🐛 Bug Fixes
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
### 📝 Documentation
* update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
### 🐛 Bug Fixes
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
### 🐛 Bug Fixes
* accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
### 📝 Documentation
* add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
* clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
### 🐛 Bug Fixes
* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
### 📝 Documentation
* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
### 🔧 Continuous Integration
* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
* trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
## [0.3.1] - 2026-01-25
### Bug Fixes
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
## [0.3.0] - 2026-01-23
### New Features
- Verify Google OAuth code automatically after receipt
- Add button to open logs folder
- Add expiration warning for Google OAuth authentication
### Bug Fixes
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
### Maintenance
- Add VS Code settings for auto-formatting and ESLint
## [0.2.2] - 2026-01-19
### Bug Fixes
- Fixed tray icon not appearing in production builds on Windows
- Used `extraResource` config to properly copy assets outside of ASAR package
- Added debug logging for tray icon path resolution
## [0.2.1] - 2026-01-19
### Bug Fixes
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
### Maintenance
- Applied Prettier formatting to entire codebase (68 files)
- Added node globals to ESLint configuration
## [0.2.0] - 2026-01-16 ## [0.2.0] - 2026-01-16
### New Features ### New Features
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability. - Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
- Implemented stability fixes and enhanced error handling across the application. - Implemented stability fixes and enhanced error handling across the application.
### Improvements ### Improvements
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security. - Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility. - Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
### Bug Fixes ### Bug Fixes
- Fixed "Converting circular structure to JSON" error. - Fixed "Converting circular structure to JSON" error.
### Documentation ### Documentation
- Added curly brace constraints for conditional statements. - Added curly brace constraints for conditional statements.
- Fixed incorrect reference documentation name. - Fixed incorrect reference documentation name.
## [0.1.1] - 2026-01-11 ## [0.1.1] - 2026-01-11
### Bug Fixes ### Bug Fixes
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19)) - Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
## [0.1.0] - 2026-01-10 ## [0.1.0] - 2026-01-10
### New Features ### New Features
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support. - LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility. - Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server. - Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
### Bug Fixes ### Bug Fixes
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
## [0.0.1] - 2025-12-22 ## [0.0.1] - 2025-12-22
### Added ### Added
- Initial release of Antigravity Manager - Initial release of Antigravity Manager
- Multi-account management for Google Gemini and Claude - Multi-account management for Google Gemini and Claude
- Real-time quota monitoring - Real-time quota monitoring
+11 -10
View File
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include: Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language - Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences - Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism - Gracefully accepting constructive criticism
* Focusing on what is best for the community - Focusing on what is best for the community
* Showing empathy towards other community members - Showing empathy towards other community members
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances - The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks - Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission - Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting - Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities ## Our Responsibilities
@@ -45,4 +45,5 @@ This Code of Conduct is adapted from the [Contributor Covenant][homepage], versi
[homepage]: https://www.contributor-covenant.org [homepage]: https://www.contributor-covenant.org
--- ---
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports. **Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
+5 -1
View File
@@ -53,7 +53,7 @@ npm start
### Available Scripts ### Available Scripts
| Command | Description | | Command | Description |
|---------|-------------| | ---------------------- | ----------------------------------- |
| `npm start` | Start the app in development mode | | `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues | | `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier | | `npm run format:write` | Format code with Prettier |
@@ -66,6 +66,7 @@ npm start
## ✏️ Making Changes ## ✏️ Making Changes
1. **Create a new branch** from `main`: 1. **Create a new branch** from `main`:
```bash ```bash
git checkout -b feature/your-feature-name git checkout -b feature/your-feature-name
# or # or
@@ -73,6 +74,7 @@ npm start
``` ```
2. **Make your changes** and commit them: 2. **Make your changes** and commit them:
```bash ```bash
git add . git add .
git commit -m "feat: add amazing new feature" git commit -m "feat: add amazing new feature"
@@ -97,6 +99,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
``` ```
**Types:** **Types:**
- `feat`: A new feature - `feat`: A new feature
- `fix`: A bug fix - `fix`: A bug fix
- `docs`: Documentation only changes - `docs`: Documentation only changes
@@ -107,6 +110,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
- `chore`: Changes to build process or auxiliary tools - `chore`: Changes to build process or auxiliary tools
**Examples:** **Examples:**
``` ```
feat(auth): add Google OAuth support feat(auth): add Google OAuth support
fix(quota): resolve quota refresh timeout issue fix(quota): resolve quota refresh timeout issue
+25
View File
@@ -33,6 +33,13 @@
</a> </a>
</p> </p>
<p align="center">
<strong>🧪 Beta:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.4-beta.1">
Download pre-release v0.3.4-beta.1
</a>
</p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" /> <img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
@@ -280,6 +287,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>
+29 -4
View File
@@ -33,6 +33,13 @@
</a> </a>
</p> </p>
<p align="center">
<strong>🧪 内测版:</strong>
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.4-beta.1">
下载 pre-release v0.3.4-beta.1
</a>
</p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" /> <img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
@@ -174,7 +181,6 @@
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" /> <img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
</p> </p>
--- ---
## 🚀 快速开始 ## 🚀 快速开始
@@ -184,7 +190,7 @@
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。 从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
| 平台 | 下载链接 | | 平台 | 下载链接 |
|------|----------| | ------- | ------------------------------------------------------------------------------ |
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) | | Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) | | macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) | | Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
@@ -218,7 +224,7 @@ npm run make
## 🛠️ 技术栈 ## 🛠️ 技术栈
| 类别 | 技术 | | 类别 | 技术 |
|------|------| | ------------ | ------------------------------------------------------------------------------------------------------------------- |
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) | | **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) | | **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) | | **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
@@ -233,7 +239,7 @@ npm run make
### 可用脚本 ### 可用脚本
| 命令 | 描述 | | 命令 | 描述 |
|------|------| | ---------------------- | ------------------------ |
| `npm start` | 启动开发模式 | | `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 | | `npm run lint` | 运行 ESLint 检查 |
| `npm run format:write` | 使用 Prettier 格式化代码 | | `npm run format:write` | 使用 Prettier 格式化代码 |
@@ -265,6 +271,7 @@ AntigravityManager/
<summary><b>Q: 程序无法启动怎么办?</b></summary> <summary><b>Q: 程序无法启动怎么办?</b></summary>
请检查: 请检查:
1. 确保已安装所有依赖:`npm install` 1. 确保已安装所有依赖:`npm install`
2. 检查 Node.js 版本是否 >= 18 2. 检查 Node.js 版本是否 >= 18
3. 尝试删除 `node_modules` 后重新安装 3. 尝试删除 `node_modules` 后重新安装
@@ -280,6 +287,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>
+1 -1
View File
@@ -27,7 +27,7 @@ Master keys are stored in the OS native credential manager via `keytar`, never i
## 📋 Supported Versions ## 📋 Supported Versions
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | -------- | ------------------ |
| Latest | :white_check_mark: | | Latest | :white_check_mark: |
| < Latest | :x: | | < Latest | :x: |
+10
View File
@@ -5,17 +5,20 @@
## 1. 核心功能 ## 1. 核心功能
### 1.1 账号池管理 ### 1.1 账号池管理
- **添加账号**:支持通过 Google OAuth 授权码添加账号。 - **添加账号**:支持通过 Google OAuth 授权码添加账号。
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。 - **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。 - **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
- **删除账号**:支持从本地数据库中移除账号。 - **删除账号**:支持从本地数据库中移除账号。
### 1.2 配额实时监控 ### 1.2 配额实时监控
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。 - **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。 - **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。 - **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
### 1.4 智能自动切换 (Intelligent Auto-Switching) ### 1.4 智能自动切换 (Intelligent Auto-Switching)
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。 - **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。 - **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
- **全局开关**:用户可在界面上一键开启或关闭此功能。 - **全局开关**:用户可在界面上一键开启或关闭此功能。
@@ -23,6 +26,7 @@
## 2. 技术实现 ## 2. 技术实现
### 2.1 数据库设计 (`cloud_accounts.db`) ### 2.1 数据库设计 (`cloud_accounts.db`)
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。 使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
```sql ```sql
@@ -48,6 +52,7 @@ CREATE TABLE settings (
``` ```
### 2.2 IPC 接口设计 ### 2.2 IPC 接口设计
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口: 后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。 - `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
@@ -59,15 +64,18 @@ CREATE TABLE settings (
- `forcePollCloudMonitor()`: 手动触发后台轮询。 - `forcePollCloudMonitor()`: 手动触发后台轮询。
### 2.3 Token 注入原理 (`ProtobufUtils`) ### 2.3 Token 注入原理 (`ProtobufUtils`)
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。 IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
value 是一个 Base64 编码的 Protobuf 二进制数据。 value 是一个 Base64 编码的 Protobuf 二进制数据。
我们实现了 `src/utils/protobuf.ts` 工具类,能够: 我们实现了 `src/utils/protobuf.ts` 工具类,能够:
1. **解码**:解析 Varint 和 Length-Delimited 字段。 1. **解码**:解析 Varint 和 Length-Delimited 字段。
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。 2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。 3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
### 2.4 切换流程 ### 2.4 切换流程
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。 1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
2. **停止进程**:优雅关闭 Antigravity 进程。 2. **停止进程**:优雅关闭 Antigravity 进程。
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。 3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
@@ -75,12 +83,14 @@ value 是一个 Base64 编码的 Protobuf 二进制数据。
5. **重启进程**:重新启动 IDE,使其加载新的凭证。 5. **重启进程**:重新启动 IDE,使其加载新的凭证。
### 2.5 自动切换逻辑 (`AutoSwitchService`) ### 2.5 自动切换逻辑 (`AutoSwitchService`)
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。 1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。 2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。 3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。 4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
### 2.6 安全加固 (Security Hardening) ### 2.6 安全加固 (Security Hardening)
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。 - **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。 - **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。 - **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
+14 -14
View File
@@ -1,27 +1,27 @@
import globals from "globals"; import globals from 'globals';
import pluginJs from "@eslint/js"; import pluginJs from '@eslint/js';
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint';
import pluginReact from "eslint-plugin-react"; import pluginReact from 'eslint-plugin-react';
import eslintPluginPrettierRecommended from "eslint-config-prettier"; import eslintPluginPrettierRecommended from 'eslint-config-prettier';
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks';
import path from "node:path"; import path from 'node:path';
import { includeIgnoreFile } from "@eslint/compat"; import { includeIgnoreFile } from '@eslint/compat';
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url';
import { defineConfig } from "eslint/config"; import { defineConfig } from 'eslint/config';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore"); const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
export default defineConfig([ export default defineConfig([
includeIgnoreFile(prettierIgnorePath), includeIgnoreFile(prettierIgnorePath),
{ {
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
}, },
{ languageOptions: { globals: globals.browser } }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended, pluginJs.configs.recommended,
pluginReact.configs.flat["jsx-runtime"], pluginReact.configs.flat['jsx-runtime'],
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
+1
View File
@@ -22,6 +22,7 @@ const config: ForgeConfig = {
name: 'Antigravity Manager', name: 'Antigravity Manager',
executableName: 'antigravity-manager', executableName: 'antigravity-manager',
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
extraResource: ['src/assets'], // Copy assets folder to resources/assets
}, },
rebuildConfig: {}, rebuildConfig: {},
hooks: { hooks: {
+1 -1
View File
@@ -93,7 +93,7 @@ src/
**数据库位置:** **数据库位置:**
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb` * macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb` * Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
* Linux: `~/.config/Antigravity/state.vscdb` * Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
**关键数据库键:** **关键数据库键:**
* `antigravityAuthStatus`: 认证状态和用户信息。 * `antigravityAuthStatus`: 认证状态和用户信息。
+5940 -5
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "antigravity-manager", "name": "antigravity-manager",
"productName": "Antigravity Manager", "productName": "Antigravity Manager",
"version": "0.2.0", "version": "0.3.4",
"description": "Antigravity Manager - Electron App", "description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
@@ -18,7 +18,6 @@
"test:unit": "vitest", "test:unit": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:all": "vitest run && playwright test", "test:all": "vitest run && playwright test",
"test-db": "electron test-db.js",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"author": "Draculabo", "author": "Draculabo",
@@ -39,6 +38,9 @@
"@eslint/compat": "^1.4.1", "@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.0",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.2",
"@swc/core": "^1.15.8", "@swc/core": "^1.15.8",
"@tanstack/router-plugin": "^1.135.0", "@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -54,6 +56,7 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "^1.3.3", "code-inspector-plugin": "^1.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0",
"electron": "^37.3.1", "electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2", "electron-playwright-helpers": "^1.8.2",
@@ -68,6 +71,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",
@@ -117,6 +122,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"find-process": "^2.0.0",
"happy-dom": "^20.0.10", "happy-dom": "^20.0.10",
"i18next": "^25.6.3", "i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
+6 -6
View File
@@ -1,23 +1,23 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from '@playwright/test';
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
testDir: "./src/tests/e2e", testDir: './src/tests/e2e',
fullyParallel: false, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: "html", reporter: 'html',
use: { use: {
trace: "on-first-retry", trace: 'on-first-retry',
}, },
projects: [ projects: [
{ {
name: "chromium", name: 'chromium',
use: { ...devices["Desktop Chrome"] }, use: { ...devices['Desktop Chrome'] },
}, },
], ],
}); });
+259
View File
@@ -0,0 +1,259 @@
/**
* Semantic Release Configuration
*
* This configuration uses the specific release rules requested by the user,
* but adapts the plugin configuration to work with standard Conventional Commits
* instead of Gitmoji (since the project history doesn't use emojis).
*/
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
const GITHUB_BASE_URL = 'https://github.com';
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
const releaseRules = [
{
release: 'minor',
type: 'feat',
},
{
release: 'patch',
type: 'fix',
},
{
release: 'patch',
type: 'perf',
},
{
release: 'patch',
type: 'style',
},
{
release: 'patch',
type: 'refactor',
},
{
release: 'patch',
type: 'build',
},
{ release: 'patch', scope: 'README', type: 'docs' },
{ release: 'patch', scope: 'README.md', type: 'docs' },
{ release: false, type: 'docs' },
{
release: false,
type: 'test',
},
{
release: false,
type: 'ci',
},
{
release: false,
type: 'chore',
},
{
release: false,
type: 'wip',
},
{
release: 'major',
type: 'BREAKING CHANGE',
},
{
release: 'major',
scope: 'BREAKING CHANGE',
},
{
release: 'major',
subject: '*BREAKING CHANGE*',
},
{ release: 'patch', subject: '*force release*' },
{ release: 'patch', subject: '*force patch*' },
{ release: 'minor', subject: '*force minor*' },
{ release: 'major', subject: '*force major*' },
{ release: false, subject: '*skip release*' },
];
const getGithubUsernameFromEmail = (email) => {
if (!email) {
return undefined;
}
const match = email.match(/^(?:\d+\+)?(?<username>[a-z0-9-]+)@users\.noreply\.github\.com$/i);
return match?.groups?.username;
};
const isBotIdentity = (value) => {
return typeof value === 'string' && /bot/i.test(value);
};
const buildReleaseFooter = (commits) => {
const mergedPullRequests = new Map();
const contributors = new Map();
(commits || []).forEach((commit) => {
const message = [commit?.message, commit?.subject, commit?.header].filter(Boolean).join('\n');
if (message) {
const mergeMatches = message.match(/pull request #(?<number>\d+)/gi);
if (mergeMatches) {
mergeMatches.forEach((match) => {
const number = match.replace(/\D/g, '');
if (number) {
mergedPullRequests.set(number, {
number,
url: `${REPO_URL}/pull/${number}`,
});
}
});
}
const squashMatches = message.match(/\(#(?<number>\d+)\)/g);
if (squashMatches) {
squashMatches.forEach((match) => {
const number = match.replace(/\D/g, '');
if (number) {
mergedPullRequests.set(number, {
number,
url: `${REPO_URL}/pull/${number}`,
});
}
});
}
}
const author = commit?.author || commit?.committer;
const username = getGithubUsernameFromEmail(author?.email);
const displayName = username ? `@${username}` : author?.name;
if (!displayName || isBotIdentity(displayName)) {
return;
}
if (username && isBotIdentity(username)) {
return;
}
contributors.set(username || displayName, {
name: displayName,
url: username ? `${GITHUB_BASE_URL}/${username}` : undefined,
});
});
const footerLines = [];
const sortedPullRequests = Array.from(mergedPullRequests.values()).sort(
(left, right) => Number(left.number) - Number(right.number),
);
if (sortedPullRequests.length > 0) {
footerLines.push('### 🔀 Merged Pull Requests');
sortedPullRequests.forEach((pullRequest) => {
footerLines.push(`- [#${pullRequest.number}](${pullRequest.url})`);
});
footerLines.push('');
}
const sortedContributors = Array.from(contributors.values()).sort((left, right) =>
left.name.localeCompare(right.name),
);
if (sortedContributors.length > 0) {
footerLines.push('### 🙌 Contributors');
sortedContributors.forEach((contributor) => {
if (contributor.url) {
footerLines.push(`- [${contributor.name}](${contributor.url})`);
return;
}
footerLines.push(`- ${contributor.name}`);
});
footerLines.push('');
}
footerLines.push(`Full Changelog: ${FULL_CHANGELOG_URL}`);
return footerLines.join('\n');
};
const appendReleaseNotes = (notes, commits) => {
const trimmedNotes = (notes || '').trimEnd();
const footer = buildReleaseFooter(commits);
if (!footer) {
return trimmedNotes;
}
return `${trimmedNotes}\n\n${footer}`;
};
const appendReleaseNotesPlugin = {
publish: async (pluginConfig, context) => {
if (!context?.nextRelease?.notes) {
return;
}
context.nextRelease.notes = appendReleaseNotes(context.nextRelease.notes, context.commits);
},
};
const releaseConfig = {
branches: ['main', { name: 'beta', prerelease: true }],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: releaseRules,
},
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: [
{ type: 'feat', section: '✨ Features' },
{ type: 'fix', section: '🐛 Bug Fixes' },
{ type: 'perf', section: '⚡ Performance Improvements' },
{ type: 'revert', section: '⏪ Reverts' },
{ type: 'docs', section: '📝 Documentation' },
{ type: 'style', section: '💄 Styles' },
{ type: 'refactor', section: '♻️ Code Refactoring' },
{ type: 'test', section: '✅ Tests' },
{ type: 'build', section: '👷 Build System' },
{ type: 'ci', section: '🔧 Continuous Integration' },
],
},
},
],
[
'@semantic-release/changelog',
{
changelogFile: 'CHANGELOG.md',
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
},
],
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
appendReleaseNotesPlugin,
[
'@semantic-release/github',
{
successComment: false,
failComment: false,
labels: false,
releaseName: 'v${nextRelease.version}',
},
],
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
],
};
releaseConfig.__internal = {
appendReleaseNotes,
buildReleaseFooter,
};
module.exports = releaseConfig;
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager"; import { ipc } from '@/ipc/manager';
export function listAccounts() { export function listAccounts() {
return ipc.client.account.listAccounts(); return ipc.client.account.listAccounts();
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "@/ipc/manager"; import { ipc } from '@/ipc/manager';
export function getPlatform() { export function getPlatform() {
return ipc.client.app.currentPlatfom(); return ipc.client.app.currentPlatfom();
+2 -2
View File
@@ -1,5 +1,5 @@
import { ipc } from "@/ipc/manager"; import { ipc } from '@/ipc/manager';
import { AccountBackupData, Account } from "@/types/account"; import { AccountBackupData, Account } from '@/types/account';
export function backupAccount(account: Account) { export function backupAccount(account: Account) {
return ipc.client.database.backupAccount(account); return ipc.client.database.backupAccount(account);
+5
View File
@@ -0,0 +1,5 @@
import { ipc } from '@/ipc/manager';
export function openLogDirectory() {
return ipc.client.system.openLogDirectory();
}
+9 -11
View File
@@ -1,6 +1,6 @@
import { ThemeMode } from "@/types/theme-mode"; import { ThemeMode } from '@/types/theme-mode';
import { ipc } from "@/ipc/manager"; import { ipc } from '@/ipc/manager';
import { LOCAL_STORAGE_KEYS } from "@/constants"; import { LOCAL_STORAGE_KEYS } from '@/constants';
export interface ThemePreferences { export interface ThemePreferences {
system: ThemeMode; system: ThemeMode;
@@ -9,9 +9,7 @@ export interface ThemePreferences {
export async function getCurrentTheme(): Promise<ThemePreferences> { export async function getCurrentTheme(): Promise<ThemePreferences> {
const currentTheme = await ipc.client.theme.getCurrentThemeMode(); const currentTheme = await ipc.client.theme.getCurrentThemeMode();
const localTheme = localStorage.getItem( const localTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as ThemeMode | null;
LOCAL_STORAGE_KEYS.THEME,
) as ThemeMode | null;
return { return {
system: currentTheme, system: currentTheme,
@@ -20,7 +18,7 @@ export async function getCurrentTheme(): Promise<ThemePreferences> {
} }
export async function setTheme(newTheme: ThemeMode) { export async function setTheme(newTheme: ThemeMode) {
const isDarkMode = newTheme === "dark"; const isDarkMode = newTheme === 'dark';
await ipc.client.theme.setThemeMode(newTheme); await ipc.client.theme.setThemeMode(newTheme);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme); localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
updateDocumentTheme(isDarkMode); updateDocumentTheme(isDarkMode);
@@ -28,7 +26,7 @@ export async function setTheme(newTheme: ThemeMode) {
export async function toggleTheme() { export async function toggleTheme() {
const isDarkMode = await ipc.client.theme.toggleThemeMode(); const isDarkMode = await ipc.client.theme.toggleThemeMode();
const newTheme = isDarkMode ? "dark" : "light"; const newTheme = isDarkMode ? 'dark' : 'light';
updateDocumentTheme(isDarkMode); updateDocumentTheme(isDarkMode);
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme); localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
@@ -37,7 +35,7 @@ export async function toggleTheme() {
export async function syncWithLocalTheme() { export async function syncWithLocalTheme() {
const { local } = await getCurrentTheme(); const { local } = await getCurrentTheme();
if (!local) { if (!local) {
setTheme("system"); setTheme('system');
return; return;
} }
@@ -46,8 +44,8 @@ export async function syncWithLocalTheme() {
function updateDocumentTheme(isDarkMode: boolean) { function updateDocumentTheme(isDarkMode: boolean) {
if (isDarkMode) { if (isDarkMode) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add('dark');
} else { } else {
document.documentElement.classList.remove("dark"); document.documentElement.classList.remove('dark');
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { ipc } from "../ipc/manager"; import { ipc } from '../ipc/manager';
export async function minimizeWindow() { export async function minimizeWindow() {
await ipc.client.window.minimizeWindow(); await ipc.client.window.minimizeWindow();
+46 -31
View File
@@ -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',
}), }),
}, },
@@ -169,35 +200,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 +273,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 (
+17 -25
View File
@@ -1,21 +1,17 @@
import React from "react"; import React from 'react';
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
isProcessRunning, import { Button } from '@/components/ui/button';
startAntigravity, import { cn } from '@/lib/utils';
closeAntigravity, import { Play, Square, Loader2 } from 'lucide-react';
} from "@/actions/process"; import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Play, Square, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
export const StatusBar: React.FC = () => { export const StatusBar: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: isRunning, isLoading } = useQuery({ const { data: isRunning, isLoading } = useQuery({
queryKey: ["process", "status"], queryKey: ['process', 'status'],
queryFn: isProcessRunning, queryFn: isProcessRunning,
refetchInterval: 2000, refetchInterval: 2000,
}); });
@@ -23,14 +19,14 @@ export const StatusBar: React.FC = () => {
const startMutation = useMutation({ const startMutation = useMutation({
mutationFn: startAntigravity, mutationFn: startAntigravity,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] }); queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
}, },
}); });
const stopMutation = useMutation({ const stopMutation = useMutation({
mutationFn: closeAntigravity, mutationFn: closeAntigravity,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["process", "status"] }); queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
}, },
}); });
@@ -47,10 +43,10 @@ export const StatusBar: React.FC = () => {
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 px-4 py-2 text-sm font-medium transition-colors',
isRunning isRunning
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100" ? 'bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100'
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100", : 'bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100',
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -62,11 +58,7 @@ export const StatusBar: React.FC = () => {
<div className="h-3 w-3 rounded-full bg-red-500" /> <div className="h-3 w-3 rounded-full bg-red-500" />
)} )}
<span> <span>
{isLoading {isLoading ? t('status.checking') : isRunning ? t('status.running') : t('status.stopped')}
? t("status.checking")
: isRunning
? t("status.running")
: t("status.stopped")}
</span> </span>
</div> </div>
<Button <Button
@@ -75,8 +67,8 @@ export const StatusBar: React.FC = () => {
onClick={handleToggle} onClick={handleToggle}
disabled={isLoading || isPending} disabled={isLoading || isPending}
className={cn( className={cn(
"h-8 px-3 hover:bg-white/20", 'h-8 px-3 hover:bg-white/20',
isRunning ? "hover:text-green-900" : "hover:text-red-900", isRunning ? 'hover:text-green-900' : 'hover:text-red-900',
)} )}
> >
{isPending ? ( {isPending ? (
@@ -86,7 +78,7 @@ export const StatusBar: React.FC = () => {
) : ( ) : (
<Play className="mr-2 h-4 w-4 fill-current" /> <Play className="mr-2 h-4 w-4 fill-current" />
)} )}
{isRunning ? t("action.stop") : t("action.start")} {isRunning ? t('action.stop') : t('action.start')}
</Button> </Button>
</div> </div>
); );
+4 -4
View File
@@ -1,12 +1,12 @@
import { Link } from "@tanstack/react-router"; import { Link } from '@tanstack/react-router';
import { import {
NavigationMenu as NavigationMenuBase, NavigationMenu as NavigationMenuBase,
NavigationMenuItem, NavigationMenuItem,
NavigationMenuLink, NavigationMenuLink,
NavigationMenuList, NavigationMenuList,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"; } from '@/components/ui/navigation-menu';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
export default function NavigationMenu() { export default function NavigationMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,7 +16,7 @@ export default function NavigationMenu() {
<NavigationMenuList> <NavigationMenuList>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">{t("titleHomePage")}</Link> <Link to="/">{t('titleHomePage')}</Link>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
+3 -3
View File
@@ -1,6 +1,6 @@
import { Moon } from "lucide-react"; import { Moon } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { toggleTheme } from "@/actions/theme"; import { toggleTheme } from '@/actions/theme';
export default function ToggleTheme() { export default function ToggleTheme() {
return ( return (
+6 -9
View File
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from 'react';
import * as AvatarPrimitive from "@radix-ui/react-avatar"; import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -8,10 +8,7 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Root <AvatarPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props} {...props}
/> />
)); ));
@@ -23,7 +20,7 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Image <AvatarPrimitive.Image
ref={ref} ref={ref}
className={cn("aspect-square h-full w-full", className)} className={cn('aspect-square h-full w-full', className)}
{...props} {...props}
/> />
)); ));
@@ -36,7 +33,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"bg-muted flex h-full w-full items-center justify-center rounded-full", 'bg-muted flex h-full w-full items-center justify-center rounded-full',
className, className,
)} )}
{...props} {...props}
+11 -15
View File
@@ -1,35 +1,31 @@
import * as React from "react"; import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{ {
variants: { variants: {
variant: { variant: {
default: default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: "text-foreground", outline: 'text-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
}, },
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };
+35 -62
View File
@@ -1,82 +1,55 @@
import * as React from "react"; import * as React from 'react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const Card = React.forwardRef< const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
"bg-card text-card-foreground rounded-xl border shadow",
className,
)}
{...props} {...props}
/> />
)); ),
Card.displayName = "Card"; );
Card.displayName = 'Card';
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div );
ref={ref} CardHeader.displayName = 'CardHeader';
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
className={cn("leading-none font-semibold tracking-tight", className)} className={cn('leading-none font-semibold tracking-tight', className)}
{...props} {...props}
/> />
)); ),
CardTitle.displayName = "CardTitle"; );
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)); ));
CardDescription.displayName = "CardDescription"; CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef< const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> );
)); CardContent.displayName = 'CardContent';
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div );
ref={ref} CardFooter.displayName = 'CardFooter';
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+21 -34
View File
@@ -1,7 +1,7 @@
import * as React from "react"; import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from "lucide-react"; import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -19,8 +19,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none", 'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && "pl-8", inset && 'pl-8',
className, className,
)} )}
{...props} {...props}
@@ -29,8 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -39,14 +38,13 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className, className,
)} )}
{...props} {...props}
/> />
)); ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -57,8 +55,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", 'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
{...props} {...props}
@@ -76,8 +74,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && "pl-8", inset && 'pl-8',
className, className,
)} )}
{...props} {...props}
@@ -92,7 +90,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
checked={checked} checked={checked}
@@ -106,8 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)); ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -116,7 +113,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -139,11 +136,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props} {...props}
/> />
)); ));
@@ -155,24 +148,18 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)} className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
)); ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
); );
}; };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export { export {
DropdownMenu, DropdownMenu,
+7 -12
View File
@@ -1,22 +1,17 @@
import * as React from "react"; import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
); );
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)); ));
Label.displayName = LabelPrimitive.Root.displayName; Label.displayName = LabelPrimitive.Root.displayName;
+19 -26
View File
@@ -1,10 +1,10 @@
"use client"; 'use client';
import * as React from "react"; import * as React from 'react';
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root; const Select = SelectPrimitive.Root;
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className, className,
)} )}
{...props} {...props}
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
className={cn( className={cn('flex cursor-default items-center justify-center py-1', className)}
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
className={cn( className={cn('flex cursor-default items-center justify-center py-1', className)}
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)); ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => ( >(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === "popper" && position === 'popper' &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
)} )}
position={position} position={position}
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", 'p-1',
position === "popper" && position === 'popper' &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)} )}
> >
{children} {children}
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)} className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
{...props} {...props}
/> />
)); ));
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)} className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
)); ));
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from 'react';
import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>, React.ElementRef<typeof SwitchPrimitive.Root>,
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitive.Root <SwitchPrimitive.Root
className={cn( className={cn(
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", 'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
className={cn( className={cn(
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", 'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
+15 -16
View File
@@ -1,8 +1,8 @@
import * as React from "react"; import * as React from 'react';
import * as ToastPrimitives from "@radix-ui/react-toast"; import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from 'class-variance-authority';
import { X } from "lucide-react"; import { X } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider; const ToastProvider = ToastPrimitives.Provider;
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]", 'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]',
className, className,
)} )}
{...props} {...props}
@@ -22,25 +22,24 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: 'border bg-background text-foreground',
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", 'destructive group border-destructive bg-destructive text-destructive-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
}, },
); );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
@@ -59,7 +58,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50", 'hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -74,7 +73,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", 'text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className, className,
)} )}
toast-close="" toast-close=""
@@ -91,7 +90,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className={cn('text-sm font-semibold [&+div]:text-xs', className)}
{...props} {...props}
/> />
)); ));
@@ -103,7 +102,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn('text-sm opacity-90', className)}
{...props} {...props}
/> />
)); ));
+23 -25
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from 'react';
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast"; import { Toast, ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1; const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000; const TOAST_REMOVE_DELAY = 1000000;
@@ -12,10 +12,10 @@ type ToasterToast = ToastProps & {
}; };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: 'REMOVE_TOAST',
} as const; } as const;
let count = 0; let count = 0;
@@ -29,20 +29,20 @@ type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"]; type: ActionType['ADD_TOAST'];
toast: ToasterToast; toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"]; type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>; toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"]; type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast["id"]; toastId?: ToasterToast['id'];
} }
| { | {
type: ActionType["REMOVE_TOAST"]; type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast["id"]; toastId?: ToasterToast['id'];
}; };
interface State { interface State {
@@ -59,7 +59,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId); toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: 'REMOVE_TOAST',
toastId, toastId,
}); });
}, TOAST_REMOVE_DELAY); }, TOAST_REMOVE_DELAY);
@@ -69,21 +69,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case 'ADD_TOAST':
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}; };
case "UPDATE_TOAST": case 'UPDATE_TOAST':
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}; };
case "DISMISS_TOAST": { case 'DISMISS_TOAST': {
const { toastId } = action; const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -108,7 +106,7 @@ export const reducer = (state: State, action: Action): State => {
), ),
}; };
} }
case "REMOVE_TOAST": case 'REMOVE_TOAST':
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
@@ -133,20 +131,20 @@ function dispatch(action: Action) {
}); });
} }
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId(); const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: 'UPDATE_TOAST',
toast: { ...props, id }, toast: { ...props, id },
}); });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: 'ADD_TOAST',
toast: { toast: {
...props, ...props,
id, id,
@@ -180,7 +178,7 @@ function useToast() {
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}; };
} }
+4 -9
View File
@@ -1,12 +1,7 @@
import { z } from "zod"; import { z } from 'zod';
import { os } from "@orpc/server"; import { os } from '@orpc/server';
import { import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
listAccountsData, import { AccountSchema } from '../../types/account';
addAccountSnapshot,
switchAccount,
deleteAccount,
} from "./handler";
import { AccountSchema } from "../../types/account";
export const accountRouter = os.router({ export const accountRouter = os.router({
listAccounts: os.output(z.array(AccountSchema)).handler(async () => { listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { os } from "@orpc/server"; import { os } from '@orpc/server';
import { app } from "electron"; import { app } from 'electron';
export const currentPlatfom = os.handler(() => { export const currentPlatfom = os.handler(() => {
return process.platform; return process.platform;
+3 -1
View File
@@ -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');
} }
+154 -25
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths'; import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { CloudAccount } from '../../types/cloudAccount'; import { CloudAccount } from '../../types/cloudAccount';
import { encrypt, decrypt } from '../../utils/security'; import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
import { ProtobufUtils } from '../../utils/protobuf'; import { ProtobufUtils } from '../../utils/protobuf';
import { GoogleAPIService } from '../../services/GoogleAPIService'; import { GoogleAPIService } from '../../services/GoogleAPIService';
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null; let db: Database.Database | null = null;
try { try {
db = new Database(dbPath); db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Create accounts table // Create accounts table
// Storing complex objects (token, quota) as JSON strings for simplicity // Storing complex objects (token, quota) as JSON strings for simplicity
@@ -73,9 +74,70 @@ function ensureDatabaseInitialized(dbPath: string): void {
function getDb(): Database.Database { function getDb(): Database.Database {
const dbPath = getCloudAccountsDbPath(); const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath); ensureDatabaseInitialized(dbPath);
return new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
return db;
} }
interface MigrationStats {
totalFields: number;
fallbackUsedFields: number;
migratedFields: number;
migratedBySource: Record<KeySource, number>;
failedFields: number;
}
function createMigrationStats(): MigrationStats {
return {
totalFields: 0,
fallbackUsedFields: 0,
migratedFields: 0,
migratedBySource: {
safeStorage: 0,
keytar: 0,
file: 0,
},
failedFields: 0,
};
}
async function decryptAndMigrateField(
db: Database.Database,
accountId: string,
field: 'token_json' | 'quota_json',
value: string | null,
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
if (!value) {
return { value: null, migrated: false };
}
const result = await decryptWithMigration(value);
if (result.reencrypted) {
if (field === 'token_json') {
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
} else {
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
}
logger.info(
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
);
}
return {
value: result.value,
migrated: Boolean(result.reencrypted),
usedFallback: result.usedFallback,
};
}
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
export class CloudAccountRepo { export class CloudAccountRepo {
static async init(): Promise<void> { static async init(): Promise<void> {
const dbPath = getCloudAccountsDbPath(); const dbPath = getCloudAccountsDbPath();
@@ -168,6 +230,7 @@ export class CloudAccountRepo {
static async getAccounts(): Promise<CloudAccount[]> { static async getAccounts(): Promise<CloudAccount[]> {
const db = getDb(); const db = getDb();
const migrationStats = createMigrationStats();
try { try {
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC'); const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
@@ -180,24 +243,91 @@ export class CloudAccountRepo {
); );
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`)); activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
const accounts = await Promise.all( const accounts: CloudAccount[] = [];
rows.map(async (row) => ({ for (const row of rows) {
let tokenResult: DecryptFieldResult;
try {
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt token for account ${row.id}`, error);
throw error;
}
let quotaResult: DecryptFieldResult;
try {
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt quota for account ${row.id}`, error);
throw error;
}
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
if (tokenResult.value) {
migrationStats.totalFields += 1;
}
if (tokenResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (tokenResult.migrated) {
migrationStats.migratedFields += 1;
if (tokenResult.usedFallback) {
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
}
}
if (quotaResult.value) {
migrationStats.totalFields += 1;
}
if (quotaResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (quotaResult.migrated) {
migrationStats.migratedFields += 1;
if (quotaResult.usedFallback) {
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
}
}
accounts.push({
id: row.id, id: row.id,
provider: row.provider, provider: row.provider,
email: row.email, email: row.email,
name: row.name, name: row.name,
avatar_url: row.avatar_url, avatar_url: row.avatar_url,
token: JSON.parse(await decrypt(row.token_json)), token: JSON.parse(tokenResult.value),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined, quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
created_at: row.created_at, created_at: row.created_at,
last_used: row.last_used, last_used: row.last_used,
status: row.status, status: row.status,
is_active: Boolean(row.is_active), is_active: Boolean(row.is_active),
})), });
); }
return accounts; return accounts;
} finally { } finally {
if (
migrationStats.migratedFields > 0 ||
migrationStats.fallbackUsedFields > 0 ||
migrationStats.failedFields > 0
) {
const summary = {
totalFields: migrationStats.totalFields,
fallbackUsedFields: migrationStats.fallbackUsedFields,
migratedFields: migrationStats.migratedFields,
migratedBySource: migrationStats.migratedBySource,
failedFields: migrationStats.failedFields,
};
if (migrationStats.failedFields > 0) {
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
} else {
logger.info('CloudAccountRepo migration summary', summary);
}
}
db.close(); db.close();
} }
} }
@@ -211,14 +341,21 @@ export class CloudAccountRepo {
if (!row) return undefined; if (!row) return undefined;
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
return { return {
id: row.id, id: row.id,
provider: row.provider, provider: row.provider,
email: row.email, email: row.email,
name: row.name, name: row.name,
avatar_url: row.avatar_url, avatar_url: row.avatar_url,
token: JSON.parse(await decrypt(row.token_json)), token: JSON.parse(tokenResult.value),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined, quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
created_at: row.created_at, created_at: row.created_at,
last_used: row.last_used, last_used: row.last_used,
status: row.status, status: row.status,
@@ -293,20 +430,14 @@ export class CloudAccountRepo {
static injectCloudToken(account: CloudAccount): void { static injectCloudToken(account: CloudAccount): void {
const dbPaths = getAntigravityDbPaths(); const dbPaths = getAntigravityDbPaths();
let dbPath: string | null = null; const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
for (const p of dbPaths) {
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
if (!dbPath) { if (!dbPath) {
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`); throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
} }
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
try { try {
const row = db const row = db
.prepare('SELECT value FROM ItemTable WHERE key = ?') .prepare('SELECT value FROM ItemTable WHERE key = ?')
@@ -437,14 +568,11 @@ export class CloudAccountRepo {
const dbPaths = getAntigravityDbPaths(); const dbPaths = getAntigravityDbPaths();
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`); logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
let dbPath: string | null = null; const dbPath =
for (const p of dbPaths) { dbPaths.find((p) => {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`); logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
if (fs.existsSync(p)) { return fs.existsSync(p);
dbPath = p; }) ?? null;
break;
}
}
if (!dbPath) { if (!dbPath) {
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`; const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
@@ -454,6 +582,7 @@ export class CloudAccountRepo {
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`); logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
const ideDb = new Database(dbPath, { readonly: true }); const ideDb = new Database(dbPath, { readonly: true });
ideDb.pragma('journal_mode = WAL');
try { try {
// 1. Read Raw Token Data // 1. Read Raw Token Data
const row = ideDb const row = ideDb
+32 -1
View File
@@ -8,6 +8,31 @@ import path from 'path';
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState']; const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database {
const db = new Database(dbPath, options);
db.pragma('journal_mode = WAL');
return db;
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getAntigravityDbPaths();
if (dbPaths.length === 0) {
return;
}
const db = getDatabaseConnection();
db.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/** /**
* Ensures that the database file exists. * Ensures that the database file exists.
* @param dbPath {string} The path to the database file. * @param dbPath {string} The path to the database file.
@@ -59,7 +84,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
ensureDatabaseExists(targetPath); ensureDatabaseExists(targetPath);
try { try {
return new Database(targetPath, { readonly: false, fileMustExist: false }); return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { code?: string }; const err = error as { code?: string };
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') { if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
@@ -69,6 +94,12 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
} }
} }
/**
* Gets a database connection and enables WAL mode.
* @param dbPath {string} The path to the database file.
* @returns {Database.Database} The database connection.
*/
/** /**
* Gets the current account info. * Gets the current account info.
* @returns {AccountInfo} The current account info. * @returns {AccountInfo} The current account info.
+4 -12
View File
@@ -1,15 +1,7 @@
import { z } from "zod"; import { z } from 'zod';
import { os } from "@orpc/server"; import { os } from '@orpc/server';
import { import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
backupAccount, import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
restoreAccount,
getCurrentAccountInfo,
} from "./handler";
import {
AccountBackupDataSchema,
AccountInfoSchema,
AccountSchema,
} from "../../types/account";
export const databaseRouter = os.router({ export const databaseRouter = os.router({
backupAccount: os backupAccount: os
+153 -44
View File
@@ -1,63 +1,168 @@
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 -x 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;
} }
} }
@@ -189,18 +294,22 @@ export async function closeAntigravity(): Promise<void> {
if (p.pid === currentPid) { if (p.pid === currentPid) {
return false; return false;
} }
// Exclude this electron app (if named Antigravity Manager) // Exclude this electron app (if named Antigravity Manager or antigravity-manager)
if (p.cmd.includes('Antigravity Manager')) { if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
return false; return false;
} }
// Match Antigravity // Match Antigravity (but not manager)
if (platform === 'win32') { if (platform === 'win32') {
return ( return (
p.cmd.includes('Antigravity.exe') || p.cmd.includes('Antigravity.exe') ||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager')) (p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
); );
} else { } else {
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity'); // Explicit !manager check for Linux/macOS to be defensive
return (
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
!p.cmd.includes('manager')
);
} }
}); });
+8
View File
@@ -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);
}),
}); });
+13 -15
View File
@@ -1,6 +1,6 @@
import { os } from "@orpc/server"; import { os } from '@orpc/server';
import { nativeTheme } from "electron"; import { nativeTheme } from 'electron';
import { setThemeModeInputSchema } from "./schemas"; import { setThemeModeInputSchema } from './schemas';
export const getCurrentThemeMode = os.handler(() => { export const getCurrentThemeMode = os.handler(() => {
return nativeTheme.themeSource; return nativeTheme.themeSource;
@@ -8,28 +8,26 @@ export const getCurrentThemeMode = os.handler(() => {
export const toggleThemeMode = os.handler(() => { export const toggleThemeMode = os.handler(() => {
if (nativeTheme.shouldUseDarkColors) { if (nativeTheme.shouldUseDarkColors) {
nativeTheme.themeSource = "light"; nativeTheme.themeSource = 'light';
} else { } else {
nativeTheme.themeSource = "dark"; nativeTheme.themeSource = 'dark';
} }
return nativeTheme.shouldUseDarkColors; return nativeTheme.shouldUseDarkColors;
}); });
export const setThemeMode = os export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
.input(setThemeModeInputSchema)
.handler(({ input: mode }) => {
switch (mode) { switch (mode) {
case "light": case 'light':
nativeTheme.themeSource = "light"; nativeTheme.themeSource = 'light';
break; break;
case "dark": case 'dark':
nativeTheme.themeSource = "dark"; nativeTheme.themeSource = 'dark';
break; break;
case "system": case 'system':
nativeTheme.themeSource = "system"; nativeTheme.themeSource = 'system';
break; break;
} }
return nativeTheme.themeSource; return nativeTheme.themeSource;
}); });
+2 -2
View File
@@ -1,3 +1,3 @@
import { z } from "zod"; import { z } from 'zod';
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]); export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
+37 -1
View File
@@ -38,12 +38,36 @@ function getQuotaText(account: CloudAccount | null, texts: any): string[] {
export function initTray(mainWindow: BrowserWindow) { export function initTray(mainWindow: BrowserWindow) {
globalMainWindow = mainWindow; globalMainWindow = mainWindow;
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy existing tray', e);
}
tray = null;
logger.info('Destroyed existing tray before creating new one');
}
const inDevelopment = process.env.NODE_ENV === 'development'; const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png') ? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets/tray.png'); : path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
const icon = nativeImage.createFromPath(iconPath); const icon = nativeImage.createFromPath(iconPath);
// Verify icon is valid before creating tray
if (icon.isEmpty()) {
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
return;
}
tray = new Tray(icon); tray = new Tray(icon);
tray.setToolTip('Antigravity Manager'); tray.setToolTip('Antigravity Manager');
@@ -158,3 +182,15 @@ export function updateTrayMenu(account: CloudAccount | null, language?: string)
export function setTrayLanguage(lang: string) { export function setTrayLanguage(lang: string) {
updateTrayMenu(lastAccount, lang); updateTrayMenu(lastAccount, lang);
} }
export function destroyTray() {
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy tray', e);
}
tray = null;
logger.info('Tray destroyed');
}
}
+9 -16
View File
@@ -1,18 +1,14 @@
import { os } from "@orpc/server"; import { os } from '@orpc/server';
import { ipcContext } from "../context"; import { ipcContext } from '../context';
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger';
export const minimizeWindow = os export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context; const { window } = context;
window.minimize(); window.minimize();
}); });
export const maximizeWindow = os export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
const { window } = context; const { window } = context;
if (window.isMaximized()) { if (window.isMaximized()) {
@@ -20,14 +16,11 @@ export const maximizeWindow = os
} else { } else {
window.maximize(); window.maximize();
} }
}); });
export const closeWindow = os export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
.use(ipcContext.mainWindowContext)
.handler(({ context }) => {
logger.info('IPC: closeWindow called'); logger.info('IPC: closeWindow called');
const { window } = context; const { window } = context;
window.close(); window.close();
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
+34
View File
@@ -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',
@@ -77,6 +91,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.',
@@ -212,6 +227,9 @@ i18n
list: { list: {
noAccounts: 'No cloud accounts added yet.', noAccounts: 'No cloud accounts added yet.',
}, },
error: {
loadFailed: 'Failed to load cloud accounts.',
},
toast: { toast: {
syncSuccess: { syncSuccess: {
title: 'Sync Successful', title: 'Sync Successful',
@@ -304,9 +322,21 @@ 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: '账号',
@@ -351,6 +381,7 @@ i18n
version: '版本', version: '版本',
platform: '平台', platform: '平台',
license: '许可证', license: '许可证',
openLogDir: '打开',
account: { account: {
title: '账号设置', title: '账号设置',
description: '配置自动刷新和同步账号。', description: '配置自动刷新和同步账号。',
@@ -484,6 +515,9 @@ i18n
list: { list: {
noAccounts: '暂无云账号。', noAccounts: '暂无云账号。',
}, },
error: {
loadFailed: '加载云账号失败。',
},
toast: { toast: {
syncSuccess: { syncSuccess: {
title: '同步成功', title: '同步成功',
+26 -5
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'; import { app, BrowserWindow } from 'electron';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -9,12 +9,13 @@ import { IPC_CHANNELS } from './constants';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app'; import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { CloudAccountRepo } from './ipc/database/cloudHandler'; import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService'; import { CloudMonitorService } from './services/CloudMonitorService';
// Static Imports to fix Bundle Resolution Errors // Static Imports to fix Bundle Resolution Errors
import { AuthServer } from './ipc/cloud/authServer'; import { AuthServer } from './ipc/cloud/authServer';
import { bootstrapNestServer, stopNestServer } from './server/main'; import { bootstrapNestServer, stopNestServer } from './server/main';
import { initTray, setTrayLanguage } from './ipc/tray/handler'; import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
import { rpcHandler } from './ipc/handler'; import { rpcHandler } from './ipc/handler';
import { ConfigManager } from './ipc/config/manager'; import { ConfigManager } from './ipc/config/manager';
@@ -149,8 +150,18 @@ app.on('child-process-gone', (event, details) => {
logger.error('Child process gone:', details); logger.error('Child process gone:', details);
}); });
app.on('before-quit', () => {
isQuitting = true;
logger.info('App before-quit event triggered - isQuitting set to true');
});
app.on('will-quit', (event) => { app.on('will-quit', (event) => {
logger.info('App will quit event triggered'); logger.info('App will quit event triggered');
try {
destroyTray();
} catch (err) {
logger.error('Failed to destroy tray during will-quit', err);
}
}); });
app.on('quit', (event, exitCode) => { app.on('quit', (event, exitCode) => {
@@ -213,6 +224,19 @@ 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(() => { .then(() => {
logger.info('Step: setupORPC'); logger.info('Step: setupORPC');
return setupORPC(); return setupORPC();
@@ -232,9 +256,6 @@ 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();
+1 -1
View File
@@ -1 +1 @@
import "@/App"; import '@/App';
+2 -2
View File
@@ -1,5 +1,5 @@
import { MainLayout } from "@/layouts/MainLayout"; import { MainLayout } from '@/layouts/MainLayout';
import { createRootRoute } from "@tanstack/react-router"; import { createRootRoute } from '@tanstack/react-router';
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */ /* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
/* /*
+11 -1
View File
@@ -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();
@@ -193,6 +194,15 @@ 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>
+48 -6
View File
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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'; const CLIENT_SECRET = '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';
declare const fetch: any;
const URLS = { const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token', TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo', USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
@@ -20,6 +18,18 @@ const URLS = {
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
const REDIRECT_URI = 'http://localhost:8888/oauth-callback'; const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
// Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000;
/**
* Creates an AbortSignal that times out after the specified duration.
*/
function createTimeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
// --- Types --- // --- Types ---
export interface TokenResponse { export interface TokenResponse {
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
// --- Service Implementation --- // --- Service Implementation ---
export class GoogleAPIService { export class GoogleAPIService {
private static getFetchOptions(): any { private static getFetchOptions() {
try { try {
const config = ConfigManager.loadConfig(); const config = ConfigManager.loadConfig();
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) { if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
@@ -121,7 +131,17 @@ export class GoogleAPIService {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params, body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(), ...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
}
throw err;
}); });
if (!response.ok) { if (!response.ok) {
@@ -147,7 +167,17 @@ export class GoogleAPIService {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params, body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(), ...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'Token refresh timed out. Please check your network connection and try again.',
);
}
}
throw err;
}); });
if (!response.ok) { if (!response.ok) {
@@ -166,7 +196,17 @@ export class GoogleAPIService {
static async getUserInfo(accessToken: string): Promise<UserInfo> { static async getUserInfo(accessToken: string): Promise<UserInfo> {
const response = await fetch(URLS.USER_INFO, { const response = await fetch(URLS.USER_INFO, {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(), ...this.getFetchOptions(),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
'User info request timed out. Please check your network connection and try again.',
);
}
}
throw err;
}); });
if (!response.ok) { if (!response.ok) {
@@ -221,7 +261,7 @@ export class GoogleAPIService {
const projectId = await this.fetchProjectId(accessToken); const projectId = await this.fetchProjectId(accessToken);
// 2. Build Payload // 2. Build Payload
const payload: Record<string, any> = {}; const payload: Record<string, unknown> = {};
if (projectId) { if (projectId) {
payload['project'] = projectId; payload['project'] = projectId;
} }
@@ -285,11 +325,13 @@ export class GoogleAPIService {
} }
return result; return result;
} catch (e: any) { } catch (e: unknown) {
if (e instanceof Error) {
console.warn( console.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));
} }
+15 -16
View File
@@ -1,6 +1,6 @@
@import "tailwindcss"; @import 'tailwindcss';
@plugin "tailwindcss-animate"; @plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -32,7 +32,7 @@
--color-card: hsl(var(--card)); --color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground)); --color-card-foreground: hsl(var(--card-foreground));
--font-sans: Geist, "Microsoft YaHei", "Heiti SC", "WenQuanYi Micro Hei", sans-serif; --font-sans: Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
--font-mono: Geist Mono, monospace; --font-mono: Geist Mono, monospace;
--font-tomorrow: Tomorrow, sans-serif; --font-tomorrow: Tomorrow, sans-serif;
@@ -74,45 +74,44 @@
@layer base { @layer base {
@font-face { @font-face {
font-family: "Geist"; font-family: 'Geist';
src: url("../assets/fonts/geist/geist.ttf") format("truetype"); src: url('../assets/fonts/geist/geist.ttf') format('truetype');
} }
@font-face { @font-face {
font-family: "Geist Mono"; font-family: 'Geist Mono';
font-display: swap; font-display: swap;
src: url("../assets/fonts/geist-mono/geist-mono.ttf") format("truetype"); src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
} }
@font-face { @font-face {
font-family: "Tomorrow"; font-family: 'Tomorrow';
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-regular.ttf") format("truetype"); src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
} }
@font-face { @font-face {
font-family: "Tomorrow"; font-family: 'Tomorrow';
font-weight: 400; font-weight: 400;
font-style: italic; font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-italic.ttf") format("truetype"); src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
} }
@font-face { @font-face {
font-family: "Tomorrow"; font-family: 'Tomorrow';
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-bold.ttf") format("truetype"); src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
} }
@font-face { @font-face {
font-family: "Tomorrow"; font-family: 'Tomorrow';
font-weight: 700; font-weight: 700;
font-style: italic; font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-bold-italic.ttf") src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
format("truetype");
} }
} }
+23 -23
View File
@@ -1,45 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { import {
listAccountsData, listAccountsData,
addAccountSnapshot, addAccountSnapshot,
switchAccount, switchAccount,
deleteAccount, deleteAccount,
} from "../../ipc/account/handler"; } from '../../ipc/account/handler';
import fs from "fs"; import fs from 'fs';
import path from "path"; import path from 'path';
// Mock dependencies // Mock dependencies
vi.mock("../../utils/paths", async () => { vi.mock('../../utils/paths', async () => {
const path = await import("path"); const path = await import('path');
const agentDir = path.join(process.cwd(), "temp_test_agent"); const agentDir = path.join(process.cwd(), 'temp_test_agent');
return { return {
getAgentDir: vi.fn(() => agentDir), getAgentDir: vi.fn(() => agentDir),
getAccountsFilePath: vi.fn(() => path.join(agentDir, "accounts.json")), getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
getBackupsDir: vi.fn(() => path.join(agentDir, "backups")), getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
getAntigravityDbPath: vi.fn(() => path.join(agentDir, "state.vscdb")), getAntigravityDbPath: vi.fn(() => path.join(agentDir, 'state.vscdb')),
getAntigravityExecutablePath: vi.fn(() => "mock_exec_path"), getAntigravityExecutablePath: vi.fn(() => 'mock_exec_path'),
}; };
}); });
vi.mock("../../ipc/database/handler", () => ({ vi.mock('../../ipc/database/handler', () => ({
getCurrentAccountInfo: vi.fn(() => ({ getCurrentAccountInfo: vi.fn(() => ({
email: "[email protected]", email: '[email protected]',
name: "Test User", name: 'Test User',
isAuthenticated: true, isAuthenticated: true,
})), })),
backupAccount: vi.fn((account) => ({ version: "1.0", account, data: {} })), backupAccount: vi.fn((account) => ({ version: '1.0', account, data: {} })),
restoreAccount: vi.fn(), restoreAccount: vi.fn(),
getDatabaseConnection: vi.fn(), getDatabaseConnection: vi.fn(),
})); }));
vi.mock("../../ipc/process/handler", () => ({ vi.mock('../../ipc/process/handler', () => ({
closeAntigravity: vi.fn(), closeAntigravity: vi.fn(),
startAntigravity: vi.fn(), startAntigravity: vi.fn(),
isProcessRunning: vi.fn(() => Promise.resolve(false)), isProcessRunning: vi.fn(() => Promise.resolve(false)),
})); }));
describe("Account Handler", () => { describe('Account Handler', () => {
const testAgentDir = path.join(process.cwd(), "temp_test_agent"); const testAgentDir = path.join(process.cwd(), 'temp_test_agent');
beforeEach(() => { beforeEach(() => {
if (fs.existsSync(testAgentDir)) { if (fs.existsSync(testAgentDir)) {
@@ -54,22 +54,22 @@ describe("Account Handler", () => {
} }
}); });
it("should add account snapshot", async () => { it('should add account snapshot', async () => {
const account = await addAccountSnapshot(); const account = await addAccountSnapshot();
expect(account.email).toBe("[email protected]"); expect(account.email).toBe('[email protected]');
const accounts = await listAccountsData(); const accounts = await listAccountsData();
expect(accounts).toHaveLength(1); expect(accounts).toHaveLength(1);
expect(accounts[0].email).toBe("[email protected]"); expect(accounts[0].email).toBe('[email protected]');
}); });
it("should switch account", async () => { it('should switch account', async () => {
const account = await addAccountSnapshot(); const account = await addAccountSnapshot();
await switchAccount(account.id); await switchAccount(account.id);
// Mocks should be called, verified by implementation logic not throwing // Mocks should be called, verified by implementation logic not throwing
}); });
it("should delete account", async () => { it('should delete account', async () => {
const account = await addAccountSnapshot(); const account = await addAccountSnapshot();
await deleteAccount(account.id); await deleteAccount(account.id);
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock module to test timeout behavior
describe('GoogleAPIService Timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should abort request after timeout', async () => {
const TIMEOUT_MS = 100;
// Recreate the same logic used in GoogleAPIService
const createTimeoutSignal = (ms: number): AbortSignal => {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
};
const signal = createTimeoutSignal(TIMEOUT_MS);
// Verify signal is not aborted initially
expect(signal.aborted).toBe(false);
// Fast-forward time
vi.advanceTimersByTime(TIMEOUT_MS + 10);
// Signal should now be aborted
expect(signal.aborted).toBe(true);
});
it('should set aborted state when controller.abort() is called', () => {
const controller = new AbortController();
// Initially not aborted
expect(controller.signal.aborted).toBe(false);
// Abort the controller
controller.abort();
// Signal should now be aborted
expect(controller.signal.aborted).toBe(true);
});
it('should transform AbortError to user-friendly message', () => {
// This tests our error transformation logic from GoogleAPIService
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
expect(() => handleAbortError(abortError)).toThrow(
'Token exchange timed out. Please check your network connection and try again.',
);
});
it('should not transform non-AbortError', () => {
const handleAbortError = (err: Error) => {
if (err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
};
const networkError = new Error('Network failure');
networkError.name = 'NetworkError';
expect(() => handleAbortError(networkError)).toThrow('Network failure');
});
});
+22 -22
View File
@@ -1,26 +1,26 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger';
import fs from "fs"; import fs from 'fs';
import path from "path"; import path from 'path';
vi.mock("../../utils/paths", async () => { vi.mock('../../utils/paths', async () => {
const path = await import("path"); const path = await import('path');
return { return {
getAgentDir: vi.fn(() => path.join(process.cwd(), "temp_test_logs")), getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
}; };
}); });
describe("Logger Utilities", () => { describe('Logger Utilities', () => {
const testLogDir = path.join(process.cwd(), "temp_test_logs"); const testLogDir = path.join(process.cwd(), 'temp_test_logs');
const testLogFile = path.join(testLogDir, "app.log"); const testLogFile = path.join(testLogDir, 'app.log');
beforeEach(() => { beforeEach(() => {
console.log("Test Log Dir:", testLogDir); console.log('Test Log Dir:', testLogDir);
if (fs.existsSync(testLogDir)) { if (fs.existsSync(testLogDir)) {
fs.rmSync(testLogDir, { recursive: true, force: true }); fs.rmSync(testLogDir, { recursive: true, force: true });
} }
fs.mkdirSync(testLogDir, { recursive: true }); fs.mkdirSync(testLogDir, { recursive: true });
console.log("Dir created:", fs.existsSync(testLogDir)); console.log('Dir created:', fs.existsSync(testLogDir));
}); });
afterEach(() => { afterEach(() => {
@@ -29,24 +29,24 @@ describe("Logger Utilities", () => {
} }
}); });
it("should create log file if it does not exist", () => { it('should create log file if it does not exist', () => {
logger.info("Test message"); logger.info('Test message');
expect(fs.existsSync(testLogFile)).toBe(true); expect(fs.existsSync(testLogFile)).toBe(true);
}); });
it("should write formatted message to file", () => { it('should write formatted message to file', () => {
const message = "Test info message"; const message = 'Test info message';
logger.info(message); logger.info(message);
const content = fs.readFileSync(testLogFile, "utf-8"); const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain("[INFO]"); expect(content).toContain('[INFO]');
expect(content).toContain(message); expect(content).toContain(message);
}); });
it("should log error messages", () => { it('should log error messages', () => {
const message = "Test error message"; const message = 'Test error message';
logger.error(message); logger.error(message);
const content = fs.readFileSync(testLogFile, "utf-8"); const content = fs.readFileSync(testLogFile, 'utf-8');
expect(content).toContain("[ERROR]"); expect(content).toContain('[ERROR]');
expect(content).toContain(message); expect(content).toContain(message);
}); });
}); });
+168 -43
View File
@@ -1,57 +1,182 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
isProcessRunning,
closeAntigravity,
startAntigravity,
} from "../../ipc/process/handler";
import { exec } from "child_process";
vi.mock("child_process", () => { // Mock find-process module
return { vi.mock('find-process', () => ({
exec: vi.fn(), default: vi.fn(),
};
});
// Mock promisify to return a function that returns a promise
vi.mock("util", () => ({
promisify: (fn: unknown) => fn,
})); }));
describe("Process Handler", () => { // Mock logger to avoid console output during tests
it("should check if process is running", async () => { vi.mock('../../utils/logger', () => ({
// Mock exec to return stdout logger: {
const execMock = exec as unknown as ReturnType<typeof vi.fn>; debug: vi.fn(),
execMock.mockImplementation((cmd, cb) => { info: vi.fn(),
if (cb) cb(null, { stdout: "12345" }, { stderr: "" }); warn: vi.fn(),
return { stdout: "12345" }; error: vi.fn(),
},
}));
// Mock paths module to avoid child_process issues
vi.mock('../../utils/paths', () => ({
getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'),
isWsl: vi.fn(() => false),
}));
// Import after mocks are set up
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
import findProcess from 'find-process';
describe('Process Handler', () => {
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
}); });
// 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();
}); });
});
}); });
+122
View File
@@ -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');
});
});
+11 -11
View File
@@ -1,27 +1,27 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { render } from "@testing-library/react"; import { render } from '@testing-library/react';
import { test, expect } from "vitest"; import { test, expect } from 'vitest';
import ToggleTheme from "@/components/toggle-theme"; import ToggleTheme from '@/components/toggle-theme';
test("renders ToggleTheme", () => { test('renders ToggleTheme', () => {
const { getByRole } = render(<ToggleTheme />); const { getByRole } = render(<ToggleTheme />);
const isButton = getByRole("button"); const isButton = getByRole('button');
expect(isButton).toBeInTheDocument(); expect(isButton).toBeInTheDocument();
}); });
test("has icon", () => { test('has icon', () => {
const { getByRole } = render(<ToggleTheme />); const { getByRole } = render(<ToggleTheme />);
const button = getByRole("button"); const button = getByRole('button');
const icon = button.querySelector("svg"); const icon = button.querySelector('svg');
expect(icon).toBeInTheDocument(); expect(icon).toBeInTheDocument();
}); });
test("is moon icon", () => { test('is moon icon', () => {
const svgIconClassName: string = "lucide-moon"; const svgIconClassName: string = 'lucide-moon';
const { getByRole } = render(<ToggleTheme />); const { getByRole } = render(<ToggleTheme />);
const svg = getByRole("button").querySelector("svg"); const svg = getByRole('button').querySelector('svg');
expect(svg?.classList).toContain(svgIconClassName); expect(svg?.classList).toContain(svgIconClassName);
}); });
+1 -1
View File
@@ -1 +1 @@
export type ThemeMode = "dark" | "light" | "system"; export type ThemeMode = 'dark' | 'light' | 'system';
+78
View File
@@ -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);
}
+2
View File
@@ -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;
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import { routeTree } from "@/routeTree.gen"; import { routeTree } from '@/routeTree.gen';
import { createMemoryHistory, createRouter } from "@tanstack/react-router"; import { createMemoryHistory, createRouter } from '@tanstack/react-router';
declare module "@tanstack/react-router" { declare module '@tanstack/react-router' {
interface Register { interface Register {
router: typeof router; router: typeof router;
} }
@@ -11,6 +11,6 @@ export const router = createRouter({
defaultPendingMinMs: 0, defaultPendingMinMs: 0,
routeTree, routeTree,
history: createMemoryHistory({ history: createMemoryHistory({
initialEntries: ["/"], initialEntries: ['/'],
}), }),
}); });
+421 -48
View File
@@ -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,93 @@ 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;
}
-36
View File
@@ -1,36 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
console.log('Starting DB test...');
try {
const dbPath = path.join(__dirname, 'test.db');
console.log(`DB Path: ${dbPath}`);
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
const db = new Database(dbPath);
console.log('DB created');
db.exec('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, value TEXT)');
console.log('Table created');
const stmt = db.prepare('INSERT INTO test (value) VALUES (?)');
stmt.run('hello');
console.log('Data inserted');
const row = db.prepare('SELECT * FROM test').get();
console.log('Data retrieved:', row);
db.close();
console.log('DB closed');
} catch (error) {
console.error('DB Error:', error);
}
console.log('Finished DB test');
-50
View File
@@ -1,50 +0,0 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const execAsync = promisify(exec);
function isWsl() {
if (process.platform !== 'linux') return false;
try {
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return version.includes('microsoft') && version.includes('wsl');
} catch {
return false;
}
}
async function isProcessRunning() {
try {
const platform = process.platform;
let command = '';
console.log('Platform:', platform);
console.log('Is WSL:', isWsl());
if (platform === 'win32' || isWsl()) {
const cmd = isWsl() ? '/mnt/c/Windows/System32/tasklist.exe' : 'tasklist';
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
} else {
command = 'pgrep -x Antigravity';
}
console.log('Command:', command);
const { stdout } = await execAsync(command, { timeout: 2000 });
console.log('Stdout:', stdout);
if (platform === 'win32' || isWsl()) {
return stdout.includes('Antigravity.exe');
} else {
return stdout.trim().length > 0;
}
} catch (error) {
console.error('Error:', error);
return false;
}
}
isProcessRunning().then((result) => {
console.log('Result:', result);
});
-1
View File
@@ -1,4 +1,3 @@
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import fs from 'fs'; import fs from 'fs';
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
// https://vitejs.dev/config // https://vitejs.dev/config
export default defineConfig({}); export default defineConfig({});