Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995d565912 | ||
|
|
92dc2f6f21 | ||
|
|
e844aa9545 | ||
|
|
0d4e2ab21f | ||
|
|
f15bb48fdd | ||
|
|
515648d64d | ||
|
|
22265e153c | ||
|
|
ad39a735b5 | ||
|
|
6a07bc0a10 | ||
|
|
e91457eceb | ||
|
|
01e3f8f8fd | ||
|
|
bd3d41aed1 | ||
|
|
d2945a6e8a | ||
|
|
89f39710c7 | ||
|
|
1bce5d331d | ||
|
|
47be175e27 | ||
|
|
f0718db374 | ||
|
|
4b12f1220d | ||
|
|
47c14763de | ||
|
|
37a9750df2 | ||
|
|
f708de24c0 |
No files matched your search
@@ -1,9 +1,8 @@
|
|||||||
name: Publish Release
|
name: Publish Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags:
|
types: [published]
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -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
|
||||||
+46
-10
@@ -1,27 +1,63 @@
|
|||||||
|
<a name="readme-top"></a>
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## [0.3.0-rc.2] - 2026-01-21
|
* **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
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
|
- 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-rc.1] - 2026-01-21
|
## [0.3.0] - 2026-01-23
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
||||||
- Verify Google OAuth code automatically after receipt ([e927ac9]
|
- Verify Google OAuth code automatically after receipt
|
||||||
- Add button to open logs folder ([a438070]
|
- Add button to open logs folder
|
||||||
- Add expiration warning for Google OAuth authentication ([f224fea]
|
- 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
|
### Maintenance
|
||||||
|
|
||||||
- Add VS Code settings for auto-formatting and ESLint ([013f329])
|
- Add VS Code settings for auto-formatting and ESLint
|
||||||
|
|
||||||
## [0.2.2] - 2026-01-19
|
## [0.2.2] - 2026-01-19
|
||||||
|
|
||||||
|
|||||||
@@ -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.3-beta.1">
|
||||||
|
Download pre-release v0.3.3-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>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>🧪 内测版:</strong>
|
||||||
|
<a href="https://github.com/Draculabo/AntigravityManager/releases/tag/v0.3.3-beta.1">
|
||||||
|
下载 pre-release v0.3.3-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 @@ 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
@@ -78,7 +78,7 @@ const config: ForgeConfig = {
|
|||||||
name: 'AntigravityManager',
|
name: 'AntigravityManager',
|
||||||
},
|
},
|
||||||
draft: true,
|
draft: true,
|
||||||
prerelease: true,
|
prerelease: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ src/
|
|||||||
**数据库位置:**
|
**数据库位置:**
|
||||||
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
||||||
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
|
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
|
||||||
* Linux: `~/.config/Antigravity/state.vscdb`
|
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
|
||||||
|
|
||||||
**关键数据库键:**
|
**关键数据库键:**
|
||||||
* `antigravityAuthStatus`: 认证状态和用户信息。
|
* `antigravityAuthStatus`: 认证状态和用户信息。
|
||||||
|
|||||||
Generated
+5940
-5
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.3.0-rc.2",
|
"version": "0.3.4-beta.1",
|
||||||
"description": "Antigravity Manager - Electron App",
|
"description": "Antigravity Manager - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -38,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",
|
||||||
@@ -53,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",
|
||||||
@@ -67,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",
|
||||||
@@ -116,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -78,7 +79,7 @@ export function CloudAccountList() {
|
|||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.addFailed.title'),
|
title: t('cloud.toast.addFailed.title'),
|
||||||
description: err.message,
|
description: getLocalizedErrorMessage(err, t),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -133,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',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -199,7 +200,7 @@ 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',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -272,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 (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
import { CloudAccount } from '../../types/cloudAccount';
|
||||||
import { encrypt, decrypt } from '../../utils/security';
|
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||||
import { ProtobufUtils } from '../../utils/protobuf';
|
import { ProtobufUtils } from '../../utils/protobuf';
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
let db: Database.Database | null = null;
|
let db: Database.Database | null = null;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Create accounts table
|
// Create accounts table
|
||||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
// Storing complex objects (token, quota) as JSON strings for simplicity
|
||||||
@@ -73,9 +74,70 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
function getDb(): Database.Database {
|
function getDb(): Database.Database {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
ensureDatabaseInitialized(dbPath);
|
ensureDatabaseInitialized(dbPath);
|
||||||
return new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MigrationStats {
|
||||||
|
totalFields: number;
|
||||||
|
fallbackUsedFields: number;
|
||||||
|
migratedFields: number;
|
||||||
|
migratedBySource: Record<KeySource, number>;
|
||||||
|
failedFields: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMigrationStats(): MigrationStats {
|
||||||
|
return {
|
||||||
|
totalFields: 0,
|
||||||
|
fallbackUsedFields: 0,
|
||||||
|
migratedFields: 0,
|
||||||
|
migratedBySource: {
|
||||||
|
safeStorage: 0,
|
||||||
|
keytar: 0,
|
||||||
|
file: 0,
|
||||||
|
},
|
||||||
|
failedFields: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptAndMigrateField(
|
||||||
|
db: Database.Database,
|
||||||
|
accountId: string,
|
||||||
|
field: 'token_json' | 'quota_json',
|
||||||
|
value: string | null,
|
||||||
|
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
|
||||||
|
if (!value) {
|
||||||
|
return { value: null, migrated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await decryptWithMigration(value);
|
||||||
|
if (result.reencrypted) {
|
||||||
|
if (field === 'token_json') {
|
||||||
|
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
|
||||||
|
result.reencrypted,
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
|
||||||
|
result.reencrypted,
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: result.value,
|
||||||
|
migrated: Boolean(result.reencrypted),
|
||||||
|
usedFallback: result.usedFallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
|
||||||
|
|
||||||
export class CloudAccountRepo {
|
export class CloudAccountRepo {
|
||||||
static async init(): Promise<void> {
|
static async init(): Promise<void> {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
@@ -168,6 +230,7 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
static async getAccounts(): Promise<CloudAccount[]> {
|
static async getAccounts(): Promise<CloudAccount[]> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const migrationStats = createMigrationStats();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
||||||
@@ -180,24 +243,91 @@ export class CloudAccountRepo {
|
|||||||
);
|
);
|
||||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts: CloudAccount[] = [];
|
||||||
rows.map(async (row) => ({
|
for (const row of rows) {
|
||||||
|
let tokenResult: DecryptFieldResult;
|
||||||
|
try {
|
||||||
|
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
||||||
|
} catch (error) {
|
||||||
|
migrationStats.failedFields += 1;
|
||||||
|
logger.error(`Failed to decrypt token for account ${row.id}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let quotaResult: DecryptFieldResult;
|
||||||
|
try {
|
||||||
|
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
||||||
|
} catch (error) {
|
||||||
|
migrationStats.failedFields += 1;
|
||||||
|
logger.error(`Failed to decrypt quota for account ${row.id}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenResult.value) {
|
||||||
|
throw new Error(`Missing token data for account ${row.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenResult.value) {
|
||||||
|
migrationStats.totalFields += 1;
|
||||||
|
}
|
||||||
|
if (tokenResult.usedFallback) {
|
||||||
|
migrationStats.fallbackUsedFields += 1;
|
||||||
|
}
|
||||||
|
if (tokenResult.migrated) {
|
||||||
|
migrationStats.migratedFields += 1;
|
||||||
|
if (tokenResult.usedFallback) {
|
||||||
|
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotaResult.value) {
|
||||||
|
migrationStats.totalFields += 1;
|
||||||
|
}
|
||||||
|
if (quotaResult.usedFallback) {
|
||||||
|
migrationStats.fallbackUsedFields += 1;
|
||||||
|
}
|
||||||
|
if (quotaResult.migrated) {
|
||||||
|
migrationStats.migratedFields += 1;
|
||||||
|
if (quotaResult.usedFallback) {
|
||||||
|
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.push({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
token: JSON.parse(await decrypt(row.token_json)),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
last_used: row.last_used,
|
last_used: row.last_used,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
is_active: Boolean(row.is_active),
|
is_active: Boolean(row.is_active),
|
||||||
})),
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
return accounts;
|
return accounts;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (
|
||||||
|
migrationStats.migratedFields > 0 ||
|
||||||
|
migrationStats.fallbackUsedFields > 0 ||
|
||||||
|
migrationStats.failedFields > 0
|
||||||
|
) {
|
||||||
|
const summary = {
|
||||||
|
totalFields: migrationStats.totalFields,
|
||||||
|
fallbackUsedFields: migrationStats.fallbackUsedFields,
|
||||||
|
migratedFields: migrationStats.migratedFields,
|
||||||
|
migratedBySource: migrationStats.migratedBySource,
|
||||||
|
failedFields: migrationStats.failedFields,
|
||||||
|
};
|
||||||
|
if (migrationStats.failedFields > 0) {
|
||||||
|
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
|
||||||
|
} else {
|
||||||
|
logger.info('CloudAccountRepo migration summary', summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,14 +341,21 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
|
|
||||||
|
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
||||||
|
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
||||||
|
|
||||||
|
if (!tokenResult.value) {
|
||||||
|
throw new Error(`Missing token data for account ${row.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
token: JSON.parse(await decrypt(row.token_json)),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
last_used: row.last_used,
|
last_used: row.last_used,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -293,20 +430,14 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
static injectCloudToken(account: CloudAccount): void {
|
static injectCloudToken(account: CloudAccount): void {
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
let dbPath: string | null = null;
|
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
||||||
|
|
||||||
for (const p of dbPaths) {
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
dbPath = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
try {
|
try {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||||
@@ -437,14 +568,11 @@ export class CloudAccountRepo {
|
|||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||||
|
|
||||||
let dbPath: string | null = null;
|
const dbPath =
|
||||||
for (const p of dbPaths) {
|
dbPaths.find((p) => {
|
||||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||||
if (fs.existsSync(p)) {
|
return fs.existsSync(p);
|
||||||
dbPath = p;
|
}) ?? null;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||||
@@ -454,6 +582,7 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||||
const ideDb = new Database(dbPath, { readonly: true });
|
const ideDb = new Database(dbPath, { readonly: true });
|
||||||
|
ideDb.pragma('journal_mode = WAL');
|
||||||
try {
|
try {
|
||||||
// 1. Read Raw Token Data
|
// 1. Read Raw Token Data
|
||||||
const row = ideDb
|
const row = ideDb
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ import path from 'path';
|
|||||||
|
|
||||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
||||||
|
|
||||||
|
function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database {
|
||||||
|
const db = new Database(dbPath, options);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database and ensures WAL mode is enabled.
|
||||||
|
* Should be called on application startup.
|
||||||
|
*/
|
||||||
|
export function initDatabase(): void {
|
||||||
|
try {
|
||||||
|
const dbPaths = getAntigravityDbPaths();
|
||||||
|
if (dbPaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabaseConnection();
|
||||||
|
db.close();
|
||||||
|
logger.info('Database initialized and verified (WAL mode)');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize database on startup', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures that the database file exists.
|
* Ensures that the database file exists.
|
||||||
* @param dbPath {string} The path to the database file.
|
* @param dbPath {string} The path to the database file.
|
||||||
@@ -59,7 +84,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
ensureDatabaseExists(targetPath);
|
ensureDatabaseExists(targetPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { code?: string };
|
const err = error as { code?: string };
|
||||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||||
@@ -69,6 +94,12 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a database connection and enables WAL mode.
|
||||||
|
* @param dbPath {string} The path to the database file.
|
||||||
|
* @returns {Database.Database} The database connection.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current account info.
|
* Gets the current account info.
|
||||||
* @returns {AccountInfo} The current account info.
|
* @returns {AccountInfo} The current account info.
|
||||||
|
|||||||
+145
-38
@@ -1,61 +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 -xi Antigravity';
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Checking process with command: ${command}`);
|
|
||||||
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(command, { timeout: 4000 });
|
const matches = await findProcess('name', searchName, true);
|
||||||
if (platform === 'win32' || isWsl()) {
|
for (const proc of matches) {
|
||||||
return stdout.includes('Antigravity.exe');
|
if (typeof proc.pid === 'number') {
|
||||||
} else {
|
processMap.set(proc.pid, proc);
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ i18n
|
|||||||
},
|
},
|
||||||
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',
|
||||||
@@ -214,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',
|
||||||
@@ -310,6 +326,17 @@ i18n
|
|||||||
},
|
},
|
||||||
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: '账号',
|
||||||
@@ -488,6 +515,9 @@ i18n
|
|||||||
list: {
|
list: {
|
||||||
noAccounts: '暂无云账号。',
|
noAccounts: '暂无云账号。',
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: '加载云账号失败。',
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
syncSuccess: {
|
syncSuccess: {
|
||||||
title: '同步成功',
|
title: '同步成功',
|
||||||
|
|||||||
+15
-4
@@ -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,6 +9,7 @@ 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
|
||||||
@@ -223,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();
|
||||||
@@ -242,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();
|
||||||
|
|
||||||
|
|||||||
+167
-40
@@ -1,55 +1,182 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
|
|
||||||
vi.mock('child_process', () => {
|
// Mock find-process module
|
||||||
return {
|
vi.mock('find-process', () => ({
|
||||||
exec: vi.fn(),
|
default: vi.fn(),
|
||||||
default: { exec: vi.fn() },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock promisify to return a function that returns a promise
|
|
||||||
vi.mock('util', () => ({
|
|
||||||
promisify: (fn: unknown) => fn,
|
|
||||||
default: { promisify: (fn: unknown) => fn },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
vi.mock('../../utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock paths module to avoid child_process issues
|
||||||
|
vi.mock('../../utils/paths', () => ({
|
||||||
|
getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'),
|
||||||
|
isWsl: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||||
|
import findProcess from 'find-process';
|
||||||
|
|
||||||
describe('Process Handler', () => {
|
describe('Process Handler', () => {
|
||||||
it('should check if process is running', async () => {
|
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
|
||||||
// Mock exec to return stdout
|
|
||||||
const execMock = exec as unknown as ReturnType<typeof vi.fn>;
|
beforeEach(() => {
|
||||||
execMock.mockImplementation((cmd, cb) => {
|
vi.clearAllMocks();
|
||||||
if (cb) cb(null, { stdout: '12345' }, { stderr: '' });
|
|
||||||
return { stdout: '12345' };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait, promisify wraps exec.
|
describe('isProcessRunning', () => {
|
||||||
// If I mock exec, promisify(exec) will use the mock.
|
it('should return true when Antigravity main process is found on macOS', async () => {
|
||||||
// But my mock implementation needs to match what promisify expects or I need to mock the promisified function.
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
// Since I mocked 'util', promisify returns the fn itself.
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
// So execAsync IS exec.
|
|
||||||
// But exec signature is (cmd, options, callback) or (cmd, callback).
|
|
||||||
// My implementation calls execAsync(command).
|
|
||||||
// So I should mock exec to return a Promise if I mocked promisify to return fn?
|
|
||||||
// No, if promisify returns fn, then `await execAsync(cmd)` means `await exec(cmd)`.
|
|
||||||
// `exec` returns a ChildProcess, not a Promise.
|
|
||||||
// So my mock of `util` is wrong if I want `await execAsync` to work.
|
|
||||||
// Better to mock the module that exports execAsync if possible, or mock `util` correctly.
|
|
||||||
// Or just mock `child_process` and use `vi.mocked(exec)`.
|
|
||||||
// But `promisify` is the key.
|
|
||||||
|
|
||||||
// Let's rely on vitest mocking capabilities.
|
mockFindProcess.mockResolvedValue([
|
||||||
// If I don't mock util, promisify will wrap the mocked exec.
|
{
|
||||||
// The mocked exec should behave like real exec (taking a callback).
|
pid: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip complex mocking tests for now as they are fragile without proper setup.
|
it('should return false when only helper processes are found', async () => {
|
||||||
// I'll write a basic test structure.
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
it('should be defined', () => {
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12346,
|
||||||
|
name: 'Antigravity Helper (Renderer)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (Renderer).app --type=renderer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pid: 12347,
|
||||||
|
name: 'Antigravity Helper (GPU)',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (GPU).app --type=gpu-process',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when only manager process is found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12348,
|
||||||
|
name: 'Antigravity Manager',
|
||||||
|
cmd: '/Applications/Antigravity Manager.app/Contents/MacOS/Antigravity Manager',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no processes are found', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip self process', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 12345, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345, // Same as current PID
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when Antigravity.exe is found on Windows', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity.exe',
|
||||||
|
cmd: 'C:\\Program Files\\Antigravity\\Antigravity.exe',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when antigravity is found on Linux', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'antigravity',
|
||||||
|
cmd: '/usr/bin/antigravity',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle find-process errors gracefully', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockRejectedValue(new Error('Process enumeration failed'));
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude processes with --type= argument', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||||
|
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||||
|
|
||||||
|
mockFindProcess.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 12345,
|
||||||
|
name: 'Antigravity',
|
||||||
|
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity --type=utility',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await isProcessRunning();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Module exports', () => {
|
||||||
|
it('should export all required functions', () => {
|
||||||
expect(isProcessRunning).toBeDefined();
|
expect(isProcessRunning).toBeDefined();
|
||||||
expect(closeAntigravity).toBeDefined();
|
expect(closeAntigravity).toBeDefined();
|
||||||
expect(startAntigravity).toBeDefined();
|
expect(startAntigravity).toBeDefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+421
-48
@@ -1,40 +1,342 @@
|
|||||||
import keytar from 'keytar';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { safeStorage, app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
const SERVICE_NAME = 'AntigravityManager';
|
const SERVICE_NAME = 'AntigravityManager';
|
||||||
const ACCOUNT_NAME = 'MasterKey';
|
const ACCOUNT_NAME = 'MasterKey';
|
||||||
|
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
|
||||||
|
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
|
||||||
|
const KEYCHAIN_HINT_KEYCHAIN_DENIED = 'HINT_KEYCHAIN_DENIED';
|
||||||
|
const KEYCHAIN_HINT_SIGN_NOTARIZE = 'HINT_SIGN_NOTARIZE';
|
||||||
|
const DATA_MIGRATION_ERROR_CODE = 'ERR_DATA_MIGRATION_FAILED';
|
||||||
|
const DATA_MIGRATION_HINT_RELOGIN = 'HINT_RELOGIN';
|
||||||
|
|
||||||
|
export type KeySource = 'safeStorage' | 'keytar' | 'file';
|
||||||
|
|
||||||
|
interface MasterKeyState {
|
||||||
|
key: Buffer;
|
||||||
|
source: KeySource;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the key in memory to avoid frequent system calls
|
// Cache the key in memory to avoid frequent system calls
|
||||||
let cachedMasterKey: Buffer | null = null;
|
let cachedMasterKey: Buffer | null = null;
|
||||||
|
let cachedMasterKeySource: KeySource | null = null;
|
||||||
|
|
||||||
async function getOrGenerateMasterKey(): Promise<Buffer> {
|
function cacheMasterKey(key: Buffer, source: KeySource) {
|
||||||
if (cachedMasterKey) return cachedMasterKey;
|
cachedMasterKey = key;
|
||||||
|
cachedMasterKeySource = source;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function getCachedMasterKey() {
|
||||||
let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
if (!cachedMasterKey || !cachedMasterKeySource) {
|
||||||
|
return null;
|
||||||
if (!hexKey) {
|
|
||||||
logger.info('Security: Generating new master key...');
|
|
||||||
// Generate 256-bit key (32 bytes)
|
|
||||||
const buffer = crypto.randomBytes(32);
|
|
||||||
hexKey = buffer.toString('hex');
|
|
||||||
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedMasterKey = Buffer.from(hexKey, 'hex');
|
return { key: cachedMasterKey, source: cachedMasterKeySource };
|
||||||
return cachedMasterKey;
|
}
|
||||||
|
|
||||||
|
function buildKeychainAccessHint(error: unknown): string | null {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let appPath = '';
|
||||||
|
try {
|
||||||
|
appPath = app.getAppPath();
|
||||||
|
} catch {
|
||||||
|
appPath = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTranslocated = appPath.includes('/AppTranslocation/');
|
||||||
|
if (isTranslocated) {
|
||||||
|
return KEYCHAIN_HINT_TRANSLOCATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (errorMessage.toLowerCase().includes('keychain')) {
|
||||||
|
return KEYCHAIN_HINT_KEYCHAIN_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KEYCHAIN_HINT_SIGN_NOTARIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock to prevent concurrent key generation
|
||||||
|
let keyGenerationInProgress: Promise<MasterKeyState> | null = null;
|
||||||
|
|
||||||
|
// Fallback key file path (used when keytar and safeStorage both fail)
|
||||||
|
function getFallbackKeyPath(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, '.mk');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to load keytar dynamically to avoid hard failure if it's not available
|
||||||
|
*/
|
||||||
|
async function tryKeytar(): Promise<typeof import('keytar') | null> {
|
||||||
|
try {
|
||||||
|
// Native modules may fail to load in production builds
|
||||||
|
const keytar = await import('keytar');
|
||||||
|
// Test if keytar is actually working by calling a method
|
||||||
|
await keytar.default.findCredentials(SERVICE_NAME);
|
||||||
|
return keytar.default;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.warn('Security: keytar not available, using fallback', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSafeStorageKey(keyPath: string): Promise<Buffer | null> {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encryptedKey = await fs.readFile(keyPath);
|
||||||
|
const hexKey = safeStorage.decryptString(encryptedKey);
|
||||||
|
|
||||||
|
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(hexKey, 'hex');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Security: Failed to read safeStorage key file', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateSafeStorageKey(
|
||||||
|
keyPath: string,
|
||||||
|
): Promise<{ key: Buffer; created: boolean }> {
|
||||||
|
const existingKey = await readSafeStorageKey(keyPath);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: existingKey, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
const encrypted = safeStorage.encryptString(hexKey);
|
||||||
|
await atomicWriteFile(keyPath, encrypted, { mode: 0o600 });
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readKeytarKey(): Promise<Buffer | null> {
|
||||||
|
const keytar = await tryKeytar();
|
||||||
|
if (!keytar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
||||||
|
if (!existingKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-f0-9]+$/i.test(existingKey) || existingKey.length !== 64) {
|
||||||
|
logger.warn('Security: keytar key has unexpected format');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(existingKey, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateKeytarKey(): Promise<{ key: Buffer; created: boolean } | null> {
|
||||||
|
const keytar = await tryKeytar();
|
||||||
|
if (!keytar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: Buffer.from(existingKey, 'hex'), created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, hexKey);
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileFallbackKey(keyPath: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(keyPath, 'utf8');
|
||||||
|
if (content.length === 64 && /^[a-f0-9]+$/i.test(content)) {
|
||||||
|
return Buffer.from(content, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Security: Error reading fallback key file', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateFileFallbackKey(
|
||||||
|
keyPath: string,
|
||||||
|
): Promise<{ key: Buffer; created: boolean }> {
|
||||||
|
const existingKey = await readFileFallbackKey(keyPath);
|
||||||
|
if (existingKey) {
|
||||||
|
return { key: existingKey, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
const hexKey = buffer.toString('hex');
|
||||||
|
await atomicWriteFile(keyPath, hexKey, { mode: 0o600 });
|
||||||
|
return { key: buffer, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFallbackMasterKeys(
|
||||||
|
keyPath: string,
|
||||||
|
primarySource: KeySource,
|
||||||
|
): Promise<MasterKeyState[]> {
|
||||||
|
const fallbackKeys: MasterKeyState[] = [];
|
||||||
|
|
||||||
|
if (primarySource !== 'keytar') {
|
||||||
|
const keytarKey = await readKeytarKey();
|
||||||
|
if (keytarKey) {
|
||||||
|
fallbackKeys.push({ key: keytarKey, source: 'keytar' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primarySource !== 'file') {
|
||||||
|
const fileKey = await readFileFallbackKey(keyPath);
|
||||||
|
if (fileKey) {
|
||||||
|
fallbackKeys.push({ key: fileKey, source: 'file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primarySource !== 'safeStorage') {
|
||||||
|
const safeStorageKey = await readSafeStorageKey(keyPath);
|
||||||
|
if (safeStorageKey) {
|
||||||
|
fallbackKeys.push({ key: safeStorageKey, source: 'safeStorage' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic file write to prevent data races
|
||||||
|
* Writes to a temp file first, then renames atomically
|
||||||
|
*/
|
||||||
|
async function atomicWriteFile(
|
||||||
|
filePath: string,
|
||||||
|
data: Buffer | string,
|
||||||
|
options?: { mode?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tempPath, data, { mode: options?.mode ?? 0o600 });
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file if rename failed
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate master encryption key using multiple fallback strategies:
|
||||||
|
* 1. safeStorage (Electron's built-in secure storage) - preferred
|
||||||
|
* 2. keytar (system keychain) - fallback
|
||||||
|
* 3. File-based with safeStorage encryption - last resort (with security warning)
|
||||||
|
*
|
||||||
|
* Uses a lock to prevent concurrent key generation (data race prevention)
|
||||||
|
*/
|
||||||
|
async function getPrimaryMasterKey(): Promise<MasterKeyState> {
|
||||||
|
const cached = getCachedMasterKey();
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyGenerationInProgress) {
|
||||||
|
return keyGenerationInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyGenerationInProgress = generatePrimaryMasterKey();
|
||||||
|
try {
|
||||||
|
return await keyGenerationInProgress;
|
||||||
|
} finally {
|
||||||
|
keyGenerationInProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
|
||||||
|
const cached = getCachedMasterKey();
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPath = getFallbackKeyPath();
|
||||||
|
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateSafeStorageKey(keyPath);
|
||||||
|
cacheMasterKey(result.key, 'safeStorage');
|
||||||
|
if (result.created) {
|
||||||
|
logger.info('Security: Generated new master key via safeStorage');
|
||||||
|
} else {
|
||||||
|
logger.info('Security: Loaded master key via safeStorage');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'safeStorage' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Security: safeStorage failed, trying keytar', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateKeytarKey();
|
||||||
|
if (result) {
|
||||||
|
cacheMasterKey(result.key, 'keytar');
|
||||||
|
if (result.created) {
|
||||||
|
logger.info('Security: Generated new master key via keytar');
|
||||||
|
} else {
|
||||||
|
logger.info('Security: Loaded master key via keytar');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'keytar' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Security: keytar failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Security: WARNING - Using file-based key storage. ' +
|
||||||
|
'This is less secure than system keychain. ' +
|
||||||
|
'Ensure the app data directory has restricted permissions.',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrCreateFileFallbackKey(keyPath);
|
||||||
|
cacheMasterKey(result.key, 'file');
|
||||||
|
if (result.created) {
|
||||||
|
logger.warn('Security: Generating file-based fallback key (less secure)');
|
||||||
|
} else {
|
||||||
|
logger.warn('Security: Using file-based fallback key (less secure)');
|
||||||
|
}
|
||||||
|
return { key: result.key, source: 'file' };
|
||||||
|
} catch (error) {
|
||||||
|
const hint = buildKeychainAccessHint(error);
|
||||||
logger.error('Security: Failed to access keychain/credential manager', error);
|
logger.error('Security: Failed to access keychain/credential manager', error);
|
||||||
// Fallback? If we can't store the key, we can't persistently encrypt.
|
const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE;
|
||||||
// For now, throw to prevent data loss (better not to write than to write something we can't decrypt later or write plain text when promised encrypted)
|
throw new Error(message);
|
||||||
throw new Error('Key management system unavailable');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a string using AES-256-GCM.
|
* Encrypts a string using AES-256-GCM.
|
||||||
@@ -42,7 +344,15 @@ const AUTH_TAG_LENGTH = 16;
|
|||||||
*/
|
*/
|
||||||
export async function encrypt(text: string): Promise<string> {
|
export async function encrypt(text: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const key = await getOrGenerateMasterKey();
|
const { key } = await getPrimaryMasterKey();
|
||||||
|
return encryptWithKey(key, text);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Security: Encryption failed', error);
|
||||||
|
throw new Error('Encryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptWithKey(key: Buffer, text: string): string {
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
@@ -52,36 +362,15 @@ export async function encrypt(text: string): Promise<string> {
|
|||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Format: iv:authTag:encrypted
|
|
||||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
} catch (error) {
|
|
||||||
logger.error('Security: Encryption failed', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function decryptWithKey(
|
||||||
* Decrypts a string using AES-256-GCM.
|
key: Buffer,
|
||||||
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
|
ivHex: string,
|
||||||
*/
|
authTagHex: string,
|
||||||
export async function decrypt(text: string): Promise<string> {
|
encryptedHex: string,
|
||||||
// Check if it's plain text (JSON) for backward compatibility
|
): string {
|
||||||
// Very rough check: starts with { or [
|
|
||||||
if (text.startsWith('{') || text.startsWith('[')) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also checking if it follows our pattern
|
|
||||||
const parts = text.split(':');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
// Treat as plain text if it doesn't look like our encrypted format
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = await getOrGenerateMasterKey();
|
|
||||||
|
|
||||||
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
const authTag = Buffer.from(authTagHex, 'hex');
|
const authTag = Buffer.from(authTagHex, 'hex');
|
||||||
|
|
||||||
@@ -92,9 +381,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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user