Compare commits

..
1 Commits
Author SHA1 Message Date
Draculabo 8b47c04f77 chore(release): bump version to 0.2.1
Publish Release / publish (ubuntu-latest) (push) Failing after 6m55s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-19 20:08:36 +08:00
29 changed files with 235 additions and 7783 deletions

No files matched your search

+4 -3
View File
@@ -1,8 +1,9 @@
name: Publish Release
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
@@ -10,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, macos-14, ubuntu-latest]
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
-37
View File
@@ -1,37 +0,0 @@
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
-7
View File
@@ -1,7 +0,0 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}
+6 -78
View File
@@ -1,89 +1,17 @@
<a name="readme-top"></a>
# Changelog
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
All notable changes to this project will be documented in this file.
### 🐛 Bug Fixes
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
### 📝 Documentation
* update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
### 🐛 Bug Fixes
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
### 🐛 Bug Fixes
* accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
### 📝 Documentation
* add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
* clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
### 🐛 Bug Fixes
* handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
### 📝 Documentation
* add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
### 🔧 Continuous Integration
* setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
* trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
## [0.3.1] - 2026-01-25
### Bug Fixes
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
## [0.3.0] - 2026-01-23
### New Features
- Verify Google OAuth code automatically after receipt
- Add button to open logs folder
- Add expiration warning for Google OAuth authentication
### Bug Fixes
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
### Maintenance
- Add VS Code settings for auto-formatting and ESLint
## [0.2.2] - 2026-01-19
### Bug Fixes
- Fixed tray icon not appearing in production builds on Windows
- Used `extraResource` config to properly copy assets outside of ASAR package
- Added debug logging for tray icon path resolution
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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))
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`)
- Fixed manager exclusion logic to prevent accidental self-termination
- Fixed zombie tray icons on application restart/hot reload
### Maintenance
-25
View File
@@ -33,13 +33,6 @@
</a>
</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">
<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" />
@@ -287,24 +280,6 @@ Please check:
</details>
<details>
<summary><b>Q: macOS shows Keychain/Credential error and OAuth cannot be saved?</b></summary>
This is a common macOS security behavior, usually when the app is unsigned or run directly from Downloads.
This is a **temporary workaround** for personal use:
1. Move the app to `/Applications`
2. Run the following commands in Terminal (repeat after every update)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
Reopen the app and allow Keychain access if prompted.
</details>
<details>
<summary><b>Q: How to report issues or suggestions?</b></summary>
-25
View File
@@ -33,13 +33,6 @@
</a>
</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">
<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" />
@@ -287,24 +280,6 @@ AntigravityManager/
</details>
<details>
<summary><b>Q: macOS 上提示 Keychain/凭据管理不可用,OAuth 无法保存?</b></summary>
这是 macOS 安全机制导致的常见问题,通常出现在未签名的应用或从 Downloads 直接运行的场景。
这是**临时方案**,仅适合个人设备:
1. 将应用移动到 `/Applications`
2. 打开终端执行以下命令(每次更新后都需要重新执行)
```plaintext
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
```
重新打开应用后,Keychain 应会提示授权(可选“始终允许”)。
</details>
<details>
<summary><b>Q: 如何反馈问题或建议?</b></summary>
-1
View File
@@ -22,7 +22,6 @@ const config: ForgeConfig = {
name: 'Antigravity Manager',
executableName: 'antigravity-manager',
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
extraResource: ['src/assets'], // Copy assets folder to resources/assets
},
rebuildConfig: {},
hooks: {
+1 -1
View File
@@ -93,7 +93,7 @@ src/
**数据库位置:**
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
* Linux: `~/.config/Antigravity/state.vscdb`
**关键数据库键:**
* `antigravityAuthStatus`: 认证状态和用户信息。
+5 -5940
View File
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.3.4",
"version": "0.2.1",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -38,9 +38,6 @@
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0",
"@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.2",
"@swc/core": "^1.15.8",
"@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1",
@@ -56,7 +53,6 @@
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "^1.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0",
"electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
@@ -71,8 +67,6 @@
"node-addon-api": "^8.3.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"semantic-release": "^25.0.2",
"semantic-release-config-gitmoji": "^1.5.3",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.16",
"ts-node": "^10.9.2",
@@ -122,7 +116,6 @@
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2",
"find-process": "^2.0.0",
"happy-dom": "^20.0.10",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
-259
View File
@@ -1,259 +0,0 @@
/**
* 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;
-5
View File
@@ -1,5 +0,0 @@
import { ipc } from '@/ipc/manager';
export function openLogDirectory() {
return ipc.client.system.openLogDirectory();
}
+31 -46
View File
@@ -41,13 +41,12 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
// ... (existing code: imports and comments)
export function CloudAccountList() {
const { t } = useTranslation();
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
const { data: accounts, isLoading, isError } = useCloudAccounts();
const refreshMutation = useRefreshQuota();
const deleteMutation = useDeleteCloudAccount();
const addMutation = useAddGoogleAccount();
@@ -63,49 +62,19 @@ export function CloudAccountList() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [authCode, setAuthCode] = useState('');
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) {
return;
}
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
});
},
},
);
};
// Listen for Google Auth Code
useEffect(() => {
if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
setAuthCode(code);
// Note: Auto-submit will be triggered by the authCode change effect below
// Auto submit if dialog is open
if (isAddDialogOpen) {
handleAddAccount(code);
}
});
return cleanup;
}
}, []);
// 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]);
}, [isAddDialogOpen]);
// Batch Operations State
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -134,7 +103,7 @@ export function CloudAccountList() {
onError: (err) =>
toast({
title: t('cloud.toast.switchFailed'),
description: getLocalizedErrorMessage(err, t),
description: err.message,
variant: 'destructive',
}),
},
@@ -200,13 +169,35 @@ export function CloudAccountList() {
onError: (err) => {
toast({
title: t('cloud.toast.syncFailed.title'),
description: getLocalizedErrorMessage(err, t),
description: err.message,
variant: 'destructive',
});
},
});
};
const handleAddAccount = (codeVal?: string) => {
const codeToUse = codeVal || authCode;
if (!codeToUse) return;
addMutation.mutate(
{ authCode: codeToUse },
{
onSuccess: () => {
setIsAddDialogOpen(false);
setAuthCode('');
toast({ title: t('cloud.toast.addSuccess') });
},
onError: (err) => {
toast({
title: t('cloud.toast.addFailed.title'),
description: err.message,
variant: 'destructive',
});
},
},
);
};
const openAuthUrl = async () => {
try {
await startAuthFlow();
@@ -273,13 +264,7 @@ export function CloudAccountList() {
}
if (isError) {
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 <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
}
return (
+1 -3
View File
@@ -20,13 +20,11 @@ export class AuthServer {
const error = url.searchParams.get('error');
if (code) {
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
logger.info('AuthServer: Received authorization code');
// Send code to renderer
if (ipcContext.mainWindow) {
logger.info('AuthServer: Sending code to renderer via IPC');
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
logger.info('AuthServer: Code sent successfully');
} else {
logger.error('AuthServer: Main window not found, cannot send code');
}
+26 -155
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
import { logger } from '../../utils/logger';
import { CloudAccount } from '../../types/cloudAccount';
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
import { encrypt, decrypt } from '../../utils/security';
import { ProtobufUtils } from '../../utils/protobuf';
import { GoogleAPIService } from '../../services/GoogleAPIService';
@@ -22,7 +22,6 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null;
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// Create accounts table
// Storing complex objects (token, quota) as JSON strings for simplicity
@@ -74,70 +73,9 @@ function ensureDatabaseInitialized(dbPath: string): void {
function getDb(): Database.Database {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
return db;
return new Database(dbPath);
}
interface MigrationStats {
totalFields: number;
fallbackUsedFields: number;
migratedFields: number;
migratedBySource: Record<KeySource, number>;
failedFields: number;
}
function createMigrationStats(): MigrationStats {
return {
totalFields: 0,
fallbackUsedFields: 0,
migratedFields: 0,
migratedBySource: {
safeStorage: 0,
keytar: 0,
file: 0,
},
failedFields: 0,
};
}
async function decryptAndMigrateField(
db: Database.Database,
accountId: string,
field: 'token_json' | 'quota_json',
value: string | null,
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
if (!value) {
return { value: null, migrated: false };
}
const result = await decryptWithMigration(value);
if (result.reencrypted) {
if (field === 'token_json') {
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
} else {
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
result.reencrypted,
accountId,
);
}
logger.info(
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
);
}
return {
value: result.value,
migrated: Boolean(result.reencrypted),
usedFallback: result.usedFallback,
};
}
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
export class CloudAccountRepo {
static async init(): Promise<void> {
const dbPath = getCloudAccountsDbPath();
@@ -230,7 +168,6 @@ export class CloudAccountRepo {
static async getAccounts(): Promise<CloudAccount[]> {
const db = getDb();
const migrationStats = createMigrationStats();
try {
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
@@ -243,91 +180,24 @@ export class CloudAccountRepo {
);
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
const accounts: CloudAccount[] = [];
for (const row of rows) {
let tokenResult: DecryptFieldResult;
try {
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt token for account ${row.id}`, error);
throw error;
}
let quotaResult: DecryptFieldResult;
try {
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
} catch (error) {
migrationStats.failedFields += 1;
logger.error(`Failed to decrypt quota for account ${row.id}`, error);
throw error;
}
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
if (tokenResult.value) {
migrationStats.totalFields += 1;
}
if (tokenResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (tokenResult.migrated) {
migrationStats.migratedFields += 1;
if (tokenResult.usedFallback) {
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
}
}
if (quotaResult.value) {
migrationStats.totalFields += 1;
}
if (quotaResult.usedFallback) {
migrationStats.fallbackUsedFields += 1;
}
if (quotaResult.migrated) {
migrationStats.migratedFields += 1;
if (quotaResult.usedFallback) {
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
}
}
accounts.push({
const accounts = await Promise.all(
rows.map(async (row) => ({
id: row.id,
provider: row.provider,
email: row.email,
name: row.name,
avatar_url: row.avatar_url,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
token: JSON.parse(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
created_at: row.created_at,
last_used: row.last_used,
status: row.status,
is_active: Boolean(row.is_active),
});
}
})),
);
return accounts;
} finally {
if (
migrationStats.migratedFields > 0 ||
migrationStats.fallbackUsedFields > 0 ||
migrationStats.failedFields > 0
) {
const summary = {
totalFields: migrationStats.totalFields,
fallbackUsedFields: migrationStats.fallbackUsedFields,
migratedFields: migrationStats.migratedFields,
migratedBySource: migrationStats.migratedBySource,
failedFields: migrationStats.failedFields,
};
if (migrationStats.failedFields > 0) {
logger.warn('CloudAccountRepo migration summary (with failures)', summary);
} else {
logger.info('CloudAccountRepo migration summary', summary);
}
}
db.close();
}
}
@@ -341,21 +211,14 @@ export class CloudAccountRepo {
if (!row) return undefined;
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
if (!tokenResult.value) {
throw new Error(`Missing token data for account ${row.id}`);
}
return {
id: row.id,
provider: row.provider,
email: row.email,
name: row.name,
avatar_url: row.avatar_url,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
token: JSON.parse(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
created_at: row.created_at,
last_used: row.last_used,
status: row.status,
@@ -430,14 +293,20 @@ export class CloudAccountRepo {
static injectCloudToken(account: CloudAccount): void {
const dbPaths = getAntigravityDbPaths();
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
let dbPath: string | null = null;
for (const p of dbPaths) {
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
if (!dbPath) {
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
try {
const row = db
.prepare('SELECT value FROM ItemTable WHERE key = ?')
@@ -568,11 +437,14 @@ export class CloudAccountRepo {
const dbPaths = getAntigravityDbPaths();
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
const dbPath =
dbPaths.find((p) => {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
return fs.existsSync(p);
}) ?? null;
let dbPath: string | null = null;
for (const p of dbPaths) {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
if (!dbPath) {
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
@@ -582,7 +454,6 @@ export class CloudAccountRepo {
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
const ideDb = new Database(dbPath, { readonly: true });
ideDb.pragma('journal_mode = WAL');
try {
// 1. Read Raw Token Data
const row = ideDb
+1 -32
View File
@@ -8,31 +8,6 @@ import path from 'path';
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database {
const db = new Database(dbPath, options);
db.pragma('journal_mode = WAL');
return db;
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getAntigravityDbPaths();
if (dbPaths.length === 0) {
return;
}
const db = getDatabaseConnection();
db.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/**
* Ensures that the database file exists.
* @param dbPath {string} The path to the database file.
@@ -84,7 +59,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
ensureDatabaseExists(targetPath);
try {
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
return new Database(targetPath, { readonly: false, fileMustExist: false });
} catch (error: unknown) {
const err = error as { code?: string };
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
@@ -94,12 +69,6 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
}
}
/**
* Gets a database connection and enables WAL mode.
* @param dbPath {string} The path to the database file.
* @returns {Database.Database} The database connection.
*/
/**
* Gets the current account info.
* @returns {AccountInfo} The current account info.
+36 -141
View File
@@ -1,168 +1,63 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import findProcess, { ProcessInfo } from 'find-process';
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
import { logger } from '../../utils/logger';
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.
* Uses find-process package for robust cross-platform process detection.
* @returns {boolean} True if the Antigravity process is running, false otherwise.
*/
export async function isProcessRunning(): Promise<boolean> {
try {
const platform = process.platform;
const currentPid = process.pid;
let command = '';
// Use find-process to search for Antigravity processes
// 'name' search type matches process name
const processMap = new Map<number, ProcessInfo>();
const searchNames = ['Antigravity', 'antigravity'];
let sawNoMatch = false;
if (platform === 'win32' || isWsl()) {
// Use absolute path for tasklist to be safe on Windows too
const cmd = isWsl()
? '/mnt/c/Windows/System32/tasklist.exe'
: process.env.SystemRoot // Usually C:\Windows
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
: 'tasklist';
for (const searchName of searchNames) {
try {
const matches = await findProcess('name', searchName, true);
for (const proc of matches) {
if (typeof proc.pid === 'number') {
processMap.set(proc.pid, proc);
}
}
} catch (error) {
if (isPgrepNoMatchError(error)) {
sawNoMatch = true;
continue;
}
throw error;
}
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
} else {
// macOS and Linux (native)
command = 'pgrep -xi Antigravity';
}
const processes = Array.from(processMap.values());
if (processes.length === 0 && sawNoMatch) {
logger.debug('No Antigravity process found (pgrep returned 1)');
}
logger.debug(`Checking process with command: ${command}`);
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
try {
const { stdout } = await execAsync(command, { timeout: 4000 });
logger.debug(`Process check stdout: ${stdout.trim()}`);
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;
}
if (platform === 'win32' || isWsl()) {
return stdout.includes('Antigravity.exe');
} 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;
// 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;
}
return false;
} catch (error) {
logger.error('Error checking process status with find-process:', error);
logger.error('Error checking process status:', error);
// pgrep returns exit code 1 if no process found
return false;
}
}
-8
View File
@@ -1,8 +1,6 @@
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
import { shell } from 'electron';
import { getAgentDir } from '../../utils/paths';
// Schema for IP info
const IpInfoSchema = z.object({
@@ -63,10 +61,4 @@ export const systemHandler = os.router({
return results;
}),
// Open log directory in file explorer
openLogDirectory: os.output(z.void()).handler(async () => {
const logDir = getAgentDir();
await shell.openPath(logDir);
}),
});
+1 -6
View File
@@ -51,14 +51,9 @@ export function initTray(mainWindow: BrowserWindow) {
}
const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
: path.join(process.resourcesPath, 'assets/tray.png');
const icon = nativeImage.createFromPath(iconPath);
-34
View File
@@ -30,23 +30,9 @@ i18n
deleteBackup: 'Delete Backup',
backupCurrent: 'Backup Current',
retry: 'Retry',
openLogs: 'Open Log Directory',
},
error: {
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: {
accounts: 'Accounts',
@@ -91,7 +77,6 @@ i18n
version: 'Version',
platform: 'Platform',
license: 'License',
openLogDir: 'Open',
account: {
title: 'Account Settings',
description: 'Configure automatic account refresh and sync.',
@@ -227,9 +212,6 @@ i18n
list: {
noAccounts: 'No cloud accounts added yet.',
},
error: {
loadFailed: 'Failed to load cloud accounts.',
},
toast: {
syncSuccess: {
title: 'Sync Successful',
@@ -322,21 +304,9 @@ i18n
deleteBackup: '删除备份',
backupCurrent: '备份当前账号',
retry: '重试',
openLogs: '打开日志目录',
},
error: {
generic: '发生未知错误。',
keychainUnavailable: 'Keychain 不可用。',
keychainHint: {
translocation: '检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
keychainDenied: 'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
signNotarize: '请在可用时使用已签名并公证的版本。',
},
dataMigrationFailed: '旧版账号数据无法解密。',
dataMigrationHint: {
relogin: '请重新登录或重新添加账号。',
clearData: '如仍失败,请清理本地账号数据后再登录。',
},
},
nav: {
accounts: '账号',
@@ -381,7 +351,6 @@ i18n
version: '版本',
platform: '平台',
license: '许可证',
openLogDir: '打开',
account: {
title: '账号设置',
description: '配置自动刷新和同步账号。',
@@ -515,9 +484,6 @@ i18n
list: {
noAccounts: '暂无云账号。',
},
error: {
loadFailed: '加载云账号失败。',
},
toast: {
syncSuccess: {
title: '同步成功',
+4 -15
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import path from 'path';
import fs from 'fs';
@@ -9,7 +9,6 @@ import { IPC_CHANNELS } from './constants';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { logger } from './utils/logger';
import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService';
// Static Imports to fix Bundle Resolution Errors
@@ -224,19 +223,6 @@ process.on('unhandledRejection', (reason) => {
app
.whenReady()
.then(async () => {
logger.info('Step: Initialize CloudAccountRepo');
try {
await CloudAccountRepo.init();
} catch (e) {
logger.error('Startup: Failed to initialize CloudAccountRepo', e);
// We might want to exit here or show a dialog, but for now we proceed
// though functionality will be broken.
}
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
initDatabase();
})
.then(() => {
logger.info('Step: setupORPC');
return setupORPC();
@@ -256,6 +242,9 @@ app
.then(async () => {
// Initialize Cloud Monitor if enabled
try {
// Initialize DB & Migrate to Encrypted Storage
await CloudAccountRepo.init();
// Start OAuth Server
AuthServer.start();
+1 -11
View File
@@ -17,10 +17,9 @@ import { getAppVersion, getPlatform } from '@/actions/app';
import { useTranslation } from 'react-i18next';
import { setAppLanguage } from '@/actions/language';
import { useAppConfig } from '@/hooks/useAppConfig';
import { Loader2, FolderOpen } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ProxyConfig } from '@/types/config';
import { openLogDirectory } from '@/actions/system';
function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -194,15 +193,6 @@ function SettingsPage() {
<div className="text-muted-foreground">{t('settings.license')}</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>
</CardContent>
</Card>
+9 -51
View File
@@ -6,6 +6,8 @@ import { ProxyAgent } from 'undici';
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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';
declare const fetch: any;
const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
@@ -18,18 +20,6 @@ const URLS = {
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
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 ---
export interface TokenResponse {
@@ -75,7 +65,7 @@ interface LoadProjectResponse {
// --- Service Implementation ---
export class GoogleAPIService {
private static getFetchOptions() {
private static getFetchOptions(): any {
try {
const config = ConfigManager.loadConfig();
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
@@ -131,17 +121,7 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...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) {
@@ -167,17 +147,7 @@ export class GoogleAPIService {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...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) {
@@ -196,17 +166,7 @@ export class GoogleAPIService {
static async getUserInfo(accessToken: string): Promise<UserInfo> {
const response = await fetch(URLS.USER_INFO, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...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) {
@@ -261,7 +221,7 @@ export class GoogleAPIService {
const projectId = await this.fetchProjectId(accessToken);
// 2. Build Payload
const payload: Record<string, unknown> = {};
const payload: Record<string, any> = {};
if (projectId) {
payload['project'] = projectId;
}
@@ -325,13 +285,11 @@ export class GoogleAPIService {
}
return result;
} catch (e: unknown) {
if (e instanceof Error) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
}
lastError = e instanceof Error ? e : new Error(String(e));
} catch (e: any) {
console.warn(
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
);
lastError = e;
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
}
-82
View File
@@ -1,82 +0,0 @@
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');
});
});
+46 -173
View File
@@ -1,182 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock find-process module
vi.mock('find-process', () => ({
default: vi.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 { describe, it, expect, vi } from 'vitest';
import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler';
import findProcess from 'find-process';
import { exec } from 'child_process';
vi.mock('child_process', () => {
return {
exec: 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 },
}));
describe('Process Handler', () => {
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
it('should check if process is running', async () => {
// Mock exec to return stdout
const execMock = exec as unknown as ReturnType<typeof vi.fn>;
execMock.mockImplementation((cmd, cb) => {
if (cb) cb(null, { stdout: '12345' }, { stderr: '' });
return { stdout: '12345' };
});
beforeEach(() => {
vi.clearAllMocks();
// Wait, promisify wraps exec.
// If I mock exec, promisify(exec) will use the mock.
// But my mock implementation needs to match what promisify expects or I need to mock the promisified function.
// Since I mocked 'util', promisify returns the fn itself.
// So execAsync IS exec.
// But exec signature is (cmd, options, callback) or (cmd, callback).
// My implementation calls execAsync(command).
// So I should mock exec to return a Promise if I mocked promisify to return fn?
// No, if promisify returns fn, then `await execAsync(cmd)` means `await exec(cmd)`.
// `exec` returns a ChildProcess, not a Promise.
// So my mock of `util` is wrong if I want `await execAsync` to work.
// Better to mock the module that exports execAsync if possible, or mock `util` correctly.
// Or just mock `child_process` and use `vi.mocked(exec)`.
// But `promisify` is the key.
// Let's rely on vitest mocking capabilities.
// If I don't mock util, promisify will wrap the mocked exec.
// The mocked exec should behave like real exec (taking a callback).
});
describe('isProcessRunning', () => {
it('should return true when Antigravity main process is found on macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
// Skip complex mocking tests for now as they are fragile without proper setup.
// I'll write a basic test structure.
mockFindProcess.mockResolvedValue([
{
pid: 12345,
name: 'Antigravity',
cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity',
},
]);
const result = await isProcessRunning();
expect(result).toBe(true);
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true);
});
it('should return false when only helper processes are found', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
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(closeAntigravity).toBeDefined();
expect(startAntigravity).toBeDefined();
});
it('should be defined', () => {
expect(isProcessRunning).toBeDefined();
expect(closeAntigravity).toBeDefined();
expect(startAntigravity).toBeDefined();
});
});
-122
View File
@@ -1,122 +0,0 @@
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');
});
});
-78
View File
@@ -1,78 +0,0 @@
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,8 +124,6 @@ export function getAntigravityDbPaths(): string[] {
}
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'));
return paths;
}
+62 -435
View File
@@ -1,342 +1,40 @@
import keytar from 'keytar';
import crypto from 'crypto';
import { logger } from './logger';
import { safeStorage, app } from 'electron';
import path from 'path';
import fs from 'fs/promises';
const SERVICE_NAME = 'AntigravityManager';
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
let cachedMasterKey: Buffer | null = null;
let cachedMasterKeySource: KeySource | null = null;
function cacheMasterKey(key: Buffer, source: KeySource) {
cachedMasterKey = key;
cachedMasterKeySource = source;
}
async function getOrGenerateMasterKey(): Promise<Buffer> {
if (cachedMasterKey) return cachedMasterKey;
function getCachedMasterKey() {
if (!cachedMasterKey || !cachedMasterKeySource) {
return null;
}
return { key: cachedMasterKey, source: cachedMasterKeySource };
}
function buildKeychainAccessHint(error: unknown): string | null {
if (process.platform !== 'darwin') {
return null;
}
let appPath = '';
try {
appPath = app.getAppPath();
} catch {
appPath = '';
}
let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
const isTranslocated = appPath.includes('/AppTranslocation/');
if (isTranslocated) {
return KEYCHAIN_HINT_TRANSLOCATION;
}
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);
}
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;
cachedMasterKey = Buffer.from(hexKey, 'hex');
return cachedMasterKey;
} 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);
const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE;
throw new Error(message);
// Fallback? If we can't store the key, we can't persistently encrypt.
// 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('Key management system unavailable');
}
}
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
/**
* Encrypts a string using AES-256-GCM.
@@ -344,122 +42,21 @@ const IV_LENGTH = 16;
*/
export async function encrypt(text: string): Promise<string> {
try {
const { key } = await getPrimaryMasterKey();
return encryptWithKey(key, text);
const key = await getOrGenerateMasterKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
} 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 cipher = crypto.createCipheriv(ALGORITHM, 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}`;
}
function decryptWithKey(
key: Buffer,
ivHex: string,
authTagHex: string,
encryptedHex: string,
): string {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
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) {
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);
throw new Error('Decryption failed');
}
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}`);
throw error;
}
}
@@ -468,6 +65,36 @@ export async function decryptWithMigration(
* 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;
// Check if it's plain text (JSON) for backward compatibility
// 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 authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
logger.error('Security: Decryption failed', error);
// If decryption fails, it might be corrupted or using a different key.
throw error;
}
}