Compare commits

...
10 Commits
Author SHA1 Message Date
semantic-release-bot 9ca0ad0396 chore(release): 0.4.0 [skip ci]
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)

### ✨ Features

* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))

### 🐛 Bug Fixes

* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
2026-01-28 08:17:50 +00:00
Draculabo f0eb7c6b61 fix: stub nestjs optional modules for packaging 2026-01-28 16:16:27 +08:00
Draculabo ea51253d58 feat: add system autostart and single-instance support 2026-01-28 13:57:18 +08:00
Draculabo dd9e84a0db feat: implement smart foreground quota refresh with debounce
- Trigger immediate quota poll when main window gains focus.
- Add 10s debounce to prevent API spam on frequent window switching.
- Add concurrency guard to prevent overlapping poll requests.
- Reset background polling interval after manual refresh.
- Add unit tests for polling, debounce, and concurrency logic.
2026-01-28 12:58:35 +08:00
semantic-release-bot a988a33d21 chore(release): 0.3.5 [skip ci]
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)

### 🐛 Bug Fixes

* "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))

### 📝 Documentation

* remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
2026-01-26 20:39:04 +00:00
Sujit BiswasandDraculabo e959ee346e fix: "Check Quota Now" button not refreshing UI after polling (#42)
* feat: add React Query hooks for managing cloud accounts and update the generated route tree.

* feat: implement React Query hooks for comprehensive cloud account management.

* fix(cloud-accounts): add error handling to force poll mutation

Add an onError handler to log failures when forcing a cloud monitor poll, improving debuggability.

* fix: add error handling for cloud account polling failure

Add error toast notification when force polling fails, providing localized error message to improve user feedback.

* refactor: remove error logging from force poll cloud monitor hook

The onError handler was removed as error handling is now managed at a higher level in the application. This simplifies the hook and prevents duplicate error logging.

* fix(localization): add missing pollFailed translation keys

Add missing English and Chinese translations for the 'pollFailed' error message used when quota polling fails for all accounts.

* fix(CloudAccountList): prevent duplicate force poll requests

Add guard clause to disable button while mutation is pending to avoid duplicate API calls.

* fix: ensure UI updates after cloud quota poll and refresh

* chore: remove "peer": true flags from package-lock.json

---------

Co-authored-by: Draculabo <[email protected]>
2026-01-27 04:38:24 +08:00
Draculabo 5a2168030e docs: remove beta download links from README 2026-01-27 03:22:52 +08:00
semantic-release-bot 00bf4dea0b chore(release): 0.3.4 [skip ci]
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)

### 🐛 Bug Fixes

* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))

### 📝 Documentation

* update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
2026-01-26 19:06:28 +00:00
vin 02080588b7 fix(security): add safeStorage fallback for production builds (#38) (#43)
Summary
  - Replace hard keytar dependency with a multi-strategy key storage:
    1) safeStorage (Electron built-in) — preferred
    2) keytar (system keychain) — fallback
    3) file-based encryption — last resort
  - Dynamically import keytar to avoid asar load failures
  - Refactor cloudHandler to use const (clean code)
  - Fix “Failed to load cloud accounts” in packaged app

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

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

  References
  - Issue: #38
  - Commit: 92dc2f6
2026-01-27 03:05:41 +08:00
Draculabo d5ee08d5a0 docs: update beta release link 2026-01-26 02:10:10 +08:00
28 changed files with 1360 additions and 364 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- master
- beta
permissions:
contents: write
+37
View File
@@ -2,6 +2,43 @@
# Changelog
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)
### ✨ Features
* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))
### 🐛 Bug Fixes
* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
### 🐛 Bug Fixes
* "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))
### 📝 Documentation
* remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
### 🐛 Bug Fixes
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
### 📝 Documentation
* update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
### 🐛 Bug Fixes
* **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
### 🐛 Bug Fixes
-7
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.3-beta.1">
Download pre-release v0.3.3-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" />
-7
View File
@@ -33,13 +33,6 @@
</a>
</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">
<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" />
+6 -217
View File
@@ -1,12 +1,12 @@
{
"name": "antigravity-manager",
"version": "0.3.3",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antigravity-manager",
"version": "0.3.3",
"version": "0.4.0",
"license": "CC-BY-NC-SA-4.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
@@ -17,7 +17,6 @@
"@nestjs/microservices": "^11.1.11",
"@nestjs/platform-express": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.10",
"@nestjs/websockets": "^11.1.11",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.11.3",
"@radix-ui/react-avatar": "^1.1.11",
@@ -4191,32 +4190,13 @@
}
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/websockets": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.11.tgz",
"integrity": "sha512-apuP7C/gtMBIYNgA8IWt75GTZeWya5JQCnrLZFcOu+IZt00j9Xd/Bm7hbj/Qr/JVoM/7q6c/4p4oOZtBGx4aeA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@@ -8141,14 +8121,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -9436,17 +9408,6 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -10280,32 +10241,6 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -10891,17 +10826,6 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
@@ -11137,22 +11061,6 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -13487,50 +13395,6 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
"integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -19221,20 +19085,6 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -21443,6 +21293,8 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 6"
}
@@ -24957,53 +24809,6 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@@ -26953,22 +26758,6 @@
"node": ">=8"
}
},
"node_modules/utf-8-validate": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz",
"integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.3.3",
"version": "0.4.0",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -91,7 +91,6 @@
"@nestjs/microservices": "^11.1.11",
"@nestjs/platform-express": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.10",
"@nestjs/websockets": "^11.1.11",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.11.3",
"@radix-ui/react-avatar": "^1.1.11",
+1 -1
View File
@@ -194,7 +194,7 @@ const appendReleaseNotesPlugin = {
};
const releaseConfig = {
branches: ['main', 'master'],
branches: ['main', { name: 'beta', prerelease: true }],
plugins: [
[
'@semantic-release/commit-analyzer',
+15 -2
View File
@@ -47,7 +47,7 @@ import { getLocalizedErrorMessage } from '@/utils/errorMessages';
export function CloudAccountList() {
const { t } = useTranslation();
const { data: accounts, isLoading, isError } = useCloudAccounts();
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
const refreshMutation = useRefreshQuota();
const deleteMutation = useDeleteCloudAccount();
const addMutation = useAddGoogleAccount();
@@ -176,8 +176,15 @@ export function CloudAccountList() {
};
const handleForcePoll = () => {
if (forcePollMutation.isPending) return;
forcePollMutation.mutate(undefined, {
onSuccess: () => toast({ title: t('cloud.polling') }),
onError: (err) =>
toast({
title: t('cloud.toast.pollFailed'),
description: getLocalizedErrorMessage(err, t),
variant: 'destructive',
}),
});
};
@@ -273,7 +280,13 @@ export function CloudAccountList() {
}
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 (
+6 -2
View File
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
return useMutation({
mutationFn: refreshAccountQuota,
onSuccess: (updatedAccount: CloudAccount) => {
// Optimistically update the list or just invalidate?
// Invalidating is safer as sorting might change (last_used)
// Optimistically update
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
if (!oldData) return [updatedAccount];
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
},
});
}
@@ -97,8 +97,12 @@ export function useSetAutoSwitchEnabled() {
}
export function useForcePollCloudMonitor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forcePollCloudMonitor,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
},
});
}
+4
View File
@@ -123,6 +123,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
notifyTrayUpdate(account);
return account;
} catch (error: any) {
@@ -143,6 +145,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
account.quota = quota;
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
return account;
} catch (refreshError) {
logger.error(
+2
View File
@@ -1,5 +1,6 @@
import { AppConfig } from '../../types/config';
import { ConfigManager } from './manager';
import { syncAutoStart } from '../../utils/autoStart';
export function loadConfig(): AppConfig {
return ConfigManager.loadConfig();
@@ -10,4 +11,5 @@ export function saveConfig(config: AppConfig): void {
// Logic to update Tray if language changes
// For now just save
ConfigManager.saveConfig(config);
syncAutoStart(config);
}
+149 -25
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 { encrypt, decrypt } from '../../utils/security';
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
import { ProtobufUtils } from '../../utils/protobuf';
import { GoogleAPIService } from '../../services/GoogleAPIService';
@@ -79,6 +79,65 @@ function getDb(): Database.Database {
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 {
static async init(): Promise<void> {
const dbPath = getCloudAccountsDbPath();
@@ -171,6 +230,7 @@ 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');
@@ -183,24 +243,91 @@ export class CloudAccountRepo {
);
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
const accounts = await Promise.all(
rows.map(async (row) => ({
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({
id: row.id,
provider: row.provider,
email: row.email,
name: row.name,
avatar_url: row.avatar_url,
token: JSON.parse(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : 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();
}
}
@@ -214,14 +341,21 @@ 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(await decrypt(row.token_json)),
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
token: JSON.parse(tokenResult.value),
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
created_at: row.created_at,
last_used: row.last_used,
status: row.status,
@@ -296,14 +430,7 @@ export class CloudAccountRepo {
static injectCloudToken(account: CloudAccount): void {
const dbPaths = getAntigravityDbPaths();
let dbPath: string | null = null;
for (const p of dbPaths) {
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
if (!dbPath) {
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
@@ -441,14 +568,11 @@ export class CloudAccountRepo {
const dbPaths = getAntigravityDbPaths();
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
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;
}
}
const dbPath =
dbPaths.find((p) => {
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
return fs.existsSync(p);
}) ?? null;
if (!dbPath) {
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
+1 -1
View File
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
pendingRequests.delete(id);
reject(new Error(`Request /${methodPath} timed out`));
}
}, 10000);
}, 60000);
});
}
+34
View File
@@ -42,6 +42,11 @@ i18n
'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',
@@ -95,6 +100,14 @@ i18n
auto_sync: 'Auto Sync Current Account',
auto_sync_desc: 'Periodically sync active account information',
},
startup: {
title: 'Startup',
description: 'Control application launch behavior at system startup.',
auto_startup: 'Start with system',
auto_startup_desc: 'Launch at sign-in and keep the app in the system tray',
macos_hint:
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
},
proxy: {
title: 'Upstream Proxy',
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
@@ -222,6 +235,9 @@ i18n
list: {
noAccounts: 'No cloud accounts added yet.',
},
error: {
loadFailed: 'Failed to load cloud accounts.',
},
toast: {
syncSuccess: {
title: 'Sync Successful',
@@ -237,6 +253,7 @@ i18n
},
quotaRefreshed: 'Quota refreshed',
refreshFailed: 'Failed to refresh quota',
pollFailed: 'Failed to poll quota for all accounts',
switched: {
title: 'Account switched!',
description: 'Restarting Antigravity...',
@@ -324,6 +341,11 @@ i18n
keychainDenied: 'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
signNotarize: '请在可用时使用已签名并公证的版本。',
},
dataMigrationFailed: '旧版账号数据无法解密。',
dataMigrationHint: {
relogin: '请重新登录或重新添加账号。',
clearData: '如仍失败,请清理本地账号数据后再登录。',
},
},
nav: {
accounts: '账号',
@@ -377,6 +399,14 @@ i18n
auto_sync: '自动获取当前账号',
auto_sync_desc: '定期自动同步当前活跃账号信息',
},
startup: {
title: '启动',
description: '控制系统启动时的应用行为。',
auto_startup: '随系统启动',
auto_startup_desc: '登录时自动启动并保持在托盘里',
macos_hint:
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
},
proxy: {
title: '上游代理',
description: '为发往 Google/Gemini API 的请求配置代理。',
@@ -502,6 +532,9 @@ i18n
list: {
noAccounts: '暂无云账号。',
},
error: {
loadFailed: '加载云账号失败。',
},
toast: {
syncSuccess: {
title: '同步成功',
@@ -517,6 +550,7 @@ i18n
},
quotaRefreshed: '配额已刷新',
refreshFailed: '刷新配额失败',
pollFailed: '轮询所有账号配额失败',
switched: {
title: '账号已切换!',
description: '正在重启 Antigravity...',
+60 -4
View File
@@ -18,6 +18,8 @@ import { bootstrapNestServer, stopNestServer } from './server/main';
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
import { rpcHandler } from './ipc/handler';
import { ConfigManager } from './ipc/config/manager';
import { AppConfig } from './types/config';
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
@@ -63,6 +65,25 @@ const inDevelopment = process.env.NODE_ENV === 'development';
let globalMainWindow: BrowserWindow | null = null;
// let tray: Tray | null = null; // Moved to tray/handler.ts
let isQuitting = false;
let startupConfig: AppConfig | null = null;
let shouldStartHidden = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
logger.info('Second instance detected, focusing existing window');
if (app.isReady()) {
createWindow({ startHidden: false });
return;
}
app.whenReady().then(() => {
createWindow({ startHidden: false });
});
});
}
process.on('exit', (code) => {
logger.info(`Process exit event triggered with code: ${code}`);
@@ -75,7 +96,22 @@ process.on('before-exit', (code) => {
// let tray: Tray | null = null; // Moved to tray/handler.ts
function createWindow() {
function createWindow({ startHidden }: { startHidden: boolean }) {
if (globalMainWindow && !globalMainWindow.isDestroyed()) {
if (startHidden) {
globalMainWindow.hide();
return;
}
if (globalMainWindow.isMinimized()) {
globalMainWindow.restore();
}
if (!globalMainWindow.isVisible()) {
globalMainWindow.show();
}
globalMainWindow.focus();
return;
}
logger.info('createWindow: start');
const preload = path.join(__dirname, 'preload.js');
logger.info(`createWindow: preload path: ${preload}`);
@@ -84,6 +120,7 @@ function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: !startHidden,
autoHideMenuBar: true,
webPreferences: {
devTools: inDevelopment,
@@ -99,6 +136,10 @@ function createWindow() {
});
globalMainWindow = mainWindow;
logger.info('createWindow: BrowserWindow instance created');
if (startHidden) {
mainWindow.hide();
logger.info('createWindow: startHidden enabled, window hidden');
}
logger.info('createWindow: setting main window in ipcContext');
ipcContext.setMainWindow(mainWindow);
@@ -126,6 +167,7 @@ function createWindow() {
mainWindow.on('closed', () => {
logger.info('Window closed event triggered');
globalMainWindow = null;
});
mainWindow.webContents.on('render-process-gone', (event, details) => {
@@ -144,6 +186,10 @@ function createWindow() {
const { level, message, lineNumber, sourceId } = details;
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
});
mainWindow.on('focus', () => {
CloudMonitorService.handleAppFocus();
});
}
app.on('child-process-gone', (event, details) => {
@@ -237,13 +283,23 @@ app
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
initDatabase();
})
.then(() => {
logger.info('Step: Load Config');
const config = ConfigManager.loadConfig();
startupConfig = config;
syncAutoStart(config);
shouldStartHidden = isAutoStartLaunch() && config.auto_startup;
if (shouldStartHidden) {
logger.info('Startup: Auto-start detected, window will start hidden');
}
})
.then(() => {
logger.info('Step: setupORPC');
return setupORPC();
})
.then(async () => {
logger.info('Step: createWindow');
await createWindow();
await createWindow({ startHidden: shouldStartHidden });
})
.then(() => {
logger.info('Step: installExtensions (SKIPPED)');
@@ -260,7 +316,7 @@ app
AuthServer.start();
// Gateway Server (NestJS) - auto-start if enabled
const config = ConfigManager.loadConfig();
const config = startupConfig || ConfigManager.loadConfig();
if (config.proxy?.auto_start) {
const port = config.proxy?.port || 8045;
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
@@ -302,7 +358,7 @@ app.on('window-all-closed', () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
createWindow({ startHidden: false });
}
});
//osX only ends
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+47
View File
@@ -47,6 +47,10 @@ function SettingsPage() {
queryFn: getPlatform,
});
const isAutoStartSupported =
platform === 'win32' || platform === 'darwin' || platform === 'linux';
const isMac = platform === 'darwin';
const handleLanguageChange = (value: string) => {
setAppLanguage(value, i18n);
};
@@ -179,6 +183,49 @@ function SettingsPage() {
</CardContent>
</Card>
{isAutoStartSupported && (
<Card>
<CardHeader>
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
<CardDescription>
{t(
'settings.startup.description',
'Control application launch behavior at system startup.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.startup.auto_startup_desc',
'Launch at sign-in and keep the app in the system tray',
)}
</p>
</div>
<Switch
checked={config?.auto_startup || false}
onCheckedChange={async (checked) => {
if (config) {
await saveConfig({ ...config, auto_startup: checked });
}
}}
/>
</div>
{isMac && (
<p className="text-xs text-muted-foreground">
{t(
'settings.startup.macos_hint',
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
)}
</p>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t('settings.about.title')}</CardTitle>
+101 -34
View File
@@ -6,17 +6,28 @@ import { logger } from '../utils/logger';
export class CloudMonitorService {
private static intervalId: NodeJS.Timeout | null = null;
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
private static DEBOUNCE_TIME = 10000; // 10 seconds
private static lastFocusTime: number = 0;
private static isPolling: boolean = false;
// Helper for testing
static resetStateForTesting() {
this.lastFocusTime = 0;
this.isPolling = false;
this.stop();
}
static start() {
if (this.intervalId) return;
logger.info('Starting CloudMonitorService...');
// Set lastFocusTime to now to prevent "double-dip" on startup (focus event immediately after start)
this.lastFocusTime = Date.now();
// Initial Poll
this.poll().catch((e) => logger.error('Initial poll failed', e));
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
this.startInterval();
}
static stop() {
@@ -27,39 +38,95 @@ export class CloudMonitorService {
}
}
static async poll() {
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
/**
* Called when the application window gains focus.
* Triggers an immediate poll if not rate-limited by debounce.
*/
static async handleAppFocus() {
const now = Date.now();
for (const account of accounts) {
try {
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
let accessToken = account.token.access_token;
if (account.token.expiry_timestamp < now + 600) {
logger.info(`Monitor: Refreshing token for ${account.email}`);
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token;
}
// 2. Fetch Quota
// We delay slightly between requests to act human/avoid spike
await new Promise((r) => setTimeout(r, 1000));
const quota = await GoogleAPIService.fetchQuota(accessToken);
// 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota);
} catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429
}
// 1. Concurrency Guard: If we are already polling, don't pile up requests
if (this.isPolling) {
logger.info('Monitor: App focused, but polling is already in progress. Skipping.');
return;
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
// 2. Debounce: If we focused recently, don't poll again
if (now - this.lastFocusTime < this.DEBOUNCE_TIME) {
logger.info('Monitor: App focused, skipping poll (debounce active).');
return;
}
logger.info('Monitor: App focused, triggering immediate poll...');
this.lastFocusTime = now;
// 3. Trigger Poll
await this.poll().catch((e) => {
logger.error('Monitor: Focus poll failed', e);
});
// 4. Reset the background interval so we don't double-poll shortly after
this.resetInterval();
}
private static startInterval() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.intervalId = setInterval(() => {
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
}, this.POLL_INTERVAL);
}
private static resetInterval() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.startInterval(); // Restart the 5-minute timer
}
}
static async poll() {
if (this.isPolling) {
return; // Extra safety
}
this.isPolling = true;
try {
logger.info('CloudMonitor: Polling quotas...');
const accounts = await CloudAccountRepo.getAccounts();
const now = Math.floor(Date.now() / 1000);
for (const account of accounts) {
try {
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
let accessToken = account.token.access_token;
if (account.token.expiry_timestamp < now + 600) {
logger.info(`Monitor: Refreshing token for ${account.email}`);
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newToken.access_token;
account.token.expires_in = newToken.expires_in;
account.token.expiry_timestamp = now + newToken.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
accessToken = newToken.access_token;
}
// 2. Fetch Quota
// We delay slightly between requests to act human/avoid spike
await new Promise((r) => setTimeout(r, 1000));
const quota = await GoogleAPIService.fetchQuota(accessToken);
// 3. Update DB
await CloudAccountRepo.updateQuota(account.id, quota);
await CloudAccountRepo.updateLastUsed(account.id);
} catch (error) {
logger.error(`Monitor: Failed to update ${account.email}`, error);
// Could mark status as 'error' or 'rate_limited' if 429
}
}
// 4. Check for Auto-Switch
await AutoSwitchService.checkAndSwitchIfNeeded();
} finally {
this.isPolling = false;
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CloudMonitorService } from '../../services/CloudMonitorService';
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
import { GoogleAPIService } from '../../services/GoogleAPIService';
import { AutoSwitchService } from '../../services/AutoSwitchService';
import { logger } from '../../utils/logger';
// Mock dependencies
vi.mock('../../ipc/database/cloudHandler');
vi.mock('../../services/GoogleAPIService');
vi.mock('../../services/AutoSwitchService');
vi.mock('../../utils/logger');
describe('CloudMonitorService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
CloudMonitorService.resetStateForTesting();
});
afterEach(() => {
CloudMonitorService.stop();
vi.useRealTimers();
});
it('should start polling on start() and set up interval', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
// Should call initial poll
expect(pollSpy).toHaveBeenCalledTimes(1);
// Fast forward 5 minutes
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
expect(pollSpy).toHaveBeenCalledTimes(2);
pollSpy.mockRestore();
});
it('should poll accounts correctly', async () => {
const mockAccounts = [
{
id: 'acc1',
email: '[email protected]',
token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 },
},
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
// Start poll but don't await immediately, as it pauses
const pollPromise = CloudMonitorService.poll();
// Advance time to pass the 1s sleep
await vi.advanceTimersByTimeAsync(1000);
// Now await
await pollPromise;
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
});
it('should refresh token if expired during poll', async () => {
const mockAccounts = [
{
id: 'acc1',
email: '[email protected]',
token: {
access_token: 'old_token',
refresh_token: 'ref_token',
expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired
},
},
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({
access_token: 'new_token',
expires_in: 3600,
token_type: 'Bearer',
});
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
// Same async pattern
const pollPromise = CloudMonitorService.poll();
await vi.advanceTimersByTimeAsync(1000);
await pollPromise;
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
});
describe('handleAppFocus (Smart Refresh)', () => {
it('should trigger poll when focused after debounce time', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
vi.setSystemTime(Date.now() + 20000);
await CloudMonitorService.handleAppFocus();
// Called once by start, once by focus
expect(pollSpy).toHaveBeenCalledTimes(2);
});
it('should NOT trigger poll if debounced (focused too soon)', async () => {
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
vi.setSystemTime(Date.now() + 1000);
await CloudMonitorService.handleAppFocus();
// Called once by start, 0 by focus
expect(pollSpy).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active'));
});
it('should NOT trigger poll if already polling (concurrency guard)', async () => {
// Let's spy on poll to silence start's poll
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
pollSpy.mockRestore(); // Restore so we can test the real guard logic
vi.setSystemTime(Date.now() + 20000);
// 2. Mock getAccounts to delay
let resolveGetAccounts: (value: unknown) => void;
const getAccountsPromise = new Promise((resolve) => {
resolveGetAccounts = resolve;
});
vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never);
// 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep
// Actually poll() calls getAccounts first thing.
const p1 = CloudMonitorService.handleAppFocus();
// Allow p1 to start execution and enter poll()
// We invoke one run loop
// But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await.
// 4. Trigger second focus immediately
const p2 = CloudMonitorService.handleAppFocus();
// 5. Release the first poll
resolveGetAccounts!([
{ id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } },
]);
// Advance timer for the 1s sleep inside poll
await vi.advanceTimersByTimeAsync(1000);
await Promise.all([p1, p2]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('polling is already in progress'),
);
// getAccounts should be called once (by the first unblocked poll)
// The second one blocked by guard before calling poll
expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1);
});
it('should reset interval after successful focus poll', async () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const setIntervalSpy = vi.spyOn(global, 'setInterval');
// Spy on poll to consume the start() call
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
CloudMonitorService.start();
pollSpy.mockRestore(); // Restore real poll
vi.setSystemTime(Date.now() + 20000);
// Needs to handle async poll
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
const focusPromise = CloudMonitorService.handleAppFocus();
await vi.advanceTimersByTimeAsync(1000); // For poll sleep
await focusPromise;
expect(clearIntervalSpy).toHaveBeenCalled();
// 1 for start (initial), 1 for reset = 2
expect(setIntervalSpy).toHaveBeenCalledTimes(2);
});
});
});
+122
View File
@@ -0,0 +1,122 @@
import crypto from 'crypto';
import fs from 'fs/promises';
import keytar from 'keytar';
import { safeStorage } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decryptWithMigration } from '../../utils/security';
const primaryHex = '11'.repeat(32);
const fallbackHex = '22'.repeat(32);
vi.mock('electron', () => ({
safeStorage: {
isEncryptionAvailable: vi.fn(() => true),
decryptString: vi.fn(() => primaryHex),
encryptString: vi.fn((value: string) => Buffer.from(value)),
},
app: {
getPath: vi.fn(() => 'C:\\test'),
},
}));
vi.mock('fs/promises', () => ({
default: {
readFile: vi.fn(),
writeFile: vi.fn(),
rename: vi.fn(),
unlink: vi.fn(),
},
}));
vi.mock('keytar', () => ({
default: {
findCredentials: vi.fn(async () => []),
getPassword: vi.fn(async () => fallbackHex),
setPassword: vi.fn(),
},
}));
const fsMock = vi.mocked(fs, { deep: true });
const keytarMock = vi.mocked(keytar, { deep: true });
const safeStorageMock = vi.mocked(safeStorage, { deep: true });
function encryptWithKey(key: Buffer, text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
beforeEach(() => {
vi.clearAllMocks();
safeStorageMock.isEncryptionAvailable.mockReturnValue(true);
safeStorageMock.decryptString.mockReturnValue(primaryHex);
fsMock.readFile.mockImplementation(async (_path, encoding) => {
if (encoding === 'utf8') {
return 'not-hex';
}
return Buffer.from('encrypted');
});
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.rename.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
keytarMock.findCredentials.mockResolvedValue([]);
keytarMock.getPassword.mockResolvedValue(fallbackHex);
});
describe('decryptWithMigration', () => {
it('falls back to legacy key and re-encrypts', async () => {
const plaintext = '{"token":"legacy"}';
const ciphertext = encryptWithKey(Buffer.from(fallbackHex, 'hex'), plaintext);
const result = await decryptWithMigration(ciphertext);
expect(result.value).toBe(plaintext);
expect(result.usedFallback).toBe('keytar');
expect(result.reencrypted).toBeTypeOf('string');
expect(result.reencrypted).not.toBe(ciphertext);
expect(keytarMock.getPassword).toHaveBeenCalledTimes(1);
if (result.reencrypted) {
const migrated = await decryptWithMigration(result.reencrypted);
expect(migrated.value).toBe(plaintext);
expect(migrated.usedFallback).toBeUndefined();
}
});
it('does not use fallback when primary key works', async () => {
const plaintext = '{"token":"primary"}';
const ciphertext = encryptWithKey(Buffer.from(primaryHex, 'hex'), plaintext);
const result = await decryptWithMigration(ciphertext);
expect(result.value).toBe(plaintext);
expect(result.usedFallback).toBeUndefined();
expect(result.reencrypted).toBeUndefined();
});
it('throws migration error code when legacy keys are unavailable', async () => {
keytarMock.getPassword.mockResolvedValue(null);
fsMock.readFile.mockImplementation(async (_path, encoding) => {
if (encoding === 'utf8') {
return 'not-hex';
}
return Buffer.from('encrypted');
});
const plaintext = '{"token":"legacy"}';
const ciphertext = encryptWithKey(Buffer.from('33'.repeat(32), 'hex'), plaintext);
await expect(decryptWithMigration(ciphertext)).rejects.toThrow('ERR_DATA_MIGRATION_FAILED');
});
});
+2
View File
@@ -22,6 +22,7 @@ export const AppConfigSchema = z.object({
refresh_interval: z.number(), // minutes
auto_sync: z.boolean(), // 是否自动同步
sync_interval: z.number(), // minutes
auto_startup: z.boolean(), // 是否随系统启动
default_export_path: z.string().nullable().optional(), // 导出路径
proxy: ProxyConfigSchema,
});
@@ -37,6 +38,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
refresh_interval: 15,
auto_sync: false,
sync_interval: 5,
auto_startup: false,
default_export_path: null,
proxy: {
enabled: false,
+126
View File
@@ -0,0 +1,126 @@
import { app } from 'electron';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from './logger';
import { AppConfig } from '../types/config';
export const AUTO_START_ARG = '--autostart';
const LINUX_AUTOSTART_FILENAME = 'antigravity-manager.desktop';
function getLinuxAutoStartPath() {
const dir = path.join(os.homedir(), '.config', 'autostart');
return path.join(dir, LINUX_AUTOSTART_FILENAME);
}
export function isAutoStartLaunch(argv = process.argv) {
if (process.platform === 'linux') {
return argv.includes(AUTO_START_ARG);
}
if (process.platform === 'win32' || process.platform === 'darwin') {
try {
const settings = app.getLoginItemSettings();
return settings.wasOpenedAtLogin;
} catch (error) {
logger.error('AutoStart: Failed to read login item settings', error);
}
}
return false;
}
function syncWindowsOrMacAutoStart(enabled: boolean) {
try {
app.setLoginItemSettings({
openAtLogin: enabled,
openAsHidden: enabled,
path: process.execPath,
args: enabled ? [AUTO_START_ARG] : [],
});
const settings = app.getLoginItemSettings();
if (settings.openAtLogin !== enabled) {
logger.warn(
`AutoStart: Login item mismatch (expected ${enabled}, actual ${settings.openAtLogin})`,
);
}
logger.info(`AutoStart: Login item set to ${enabled}`);
} catch (error) {
logger.error('AutoStart: Failed to update login item settings', error);
}
}
function cleanupLinuxAutoStartEntries(currentPath: string) {
const autoStartDir = path.dirname(currentPath);
if (!fs.existsSync(autoStartDir)) {
return;
}
const legacyNames = [
`${app.getName()}.desktop`,
`${app.getName().replace(/\s+/g, '')}.desktop`,
'Antigravity Manager.desktop',
'AntigravityManager.desktop',
];
for (const name of legacyNames) {
const candidate = path.join(autoStartDir, name);
if (candidate === currentPath) {
continue;
}
if (fs.existsSync(candidate)) {
try {
fs.unlinkSync(candidate);
logger.info(`AutoStart: Removed legacy autostart entry ${candidate}`);
} catch (error) {
logger.warn('AutoStart: Failed to remove legacy autostart entry', error);
}
}
}
}
function syncLinuxAutoStart(enabled: boolean) {
const autoStartPath = getLinuxAutoStartPath();
if (!enabled) {
if (fs.existsSync(autoStartPath)) {
fs.unlinkSync(autoStartPath);
logger.info('AutoStart: Removed Linux autostart entry');
}
cleanupLinuxAutoStartEntries(autoStartPath);
return;
}
const autoStartDir = path.dirname(autoStartPath);
if (!fs.existsSync(autoStartDir)) {
fs.mkdirSync(autoStartDir, { recursive: true });
}
const execPath = process.execPath.replace(/"/g, '\\"');
const content = [
'[Desktop Entry]',
'Type=Application',
`Name=${app.getName()}`,
`Exec="${execPath}" ${AUTO_START_ARG}`,
'X-GNOME-Autostart-enabled=true',
'Hidden=false',
'NoDisplay=false',
].join('\n');
cleanupLinuxAutoStartEntries(autoStartPath);
fs.writeFileSync(autoStartPath, content, 'utf-8');
logger.info('AutoStart: Created Linux autostart entry');
}
export function syncAutoStart(config: AppConfig) {
const enabled = Boolean(config.auto_startup);
if (process.platform === 'win32' || process.platform === 'darwin') {
syncWindowsOrMacAutoStart(enabled);
return;
}
if (process.platform === 'linux') {
syncLinuxAutoStart(enabled);
}
}
+39
View File
@@ -4,6 +4,9 @@ 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',
@@ -11,6 +14,11 @@ const KEYCHAIN_HINT_I18N_MAP: Record<string, string> = {
[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) {
@@ -25,6 +33,20 @@ function resolveKeychainMessage(hintCode: string | undefined, t: TFunction): str
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;
@@ -32,8 +54,25 @@ export function getLocalizedErrorMessage(error: unknown, t: TFunction): string {
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);
}
+402 -60
View File
@@ -1,7 +1,8 @@
import keytar from 'keytar';
import crypto from 'crypto';
import { app } from 'electron';
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';
@@ -9,9 +10,32 @@ 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;
}
function getCachedMasterKey() {
if (!cachedMasterKey || !cachedMasterKeySource) {
return null;
}
return { key: cachedMasterKey, source: cachedMasterKeySource };
}
function buildKeychainAccessHint(error: unknown): string | null {
if (process.platform !== 'darwin') {
@@ -38,27 +62,274 @@ function buildKeychainAccessHint(error: unknown): string | null {
return KEYCHAIN_HINT_SIGN_NOTARIZE;
}
async function getOrGenerateMasterKey(): Promise<Buffer> {
if (cachedMasterKey) return cachedMasterKey;
// 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) {
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 {
let hexKey = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
const encryptedKey = await fs.readFile(keyPath);
const hexKey = safeStorage.decryptString(encryptedKey);
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);
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
return null;
}
cachedMasterKey = Buffer.from(hexKey, 'hex');
return cachedMasterKey;
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);
// 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)
const message = hint ? `${KEYCHAIN_ERROR_CODE}|${hint}` : KEYCHAIN_ERROR_CODE;
throw new Error(message);
}
@@ -73,21 +344,122 @@ const IV_LENGTH = 16;
*/
export async function encrypt(text: string): Promise<string> {
try {
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}`;
const { key } = await getPrimaryMasterKey();
return encryptWithKey(key, text);
} catch (error) {
logger.error('Security: Encryption failed', error);
throw 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}`);
}
}
@@ -96,36 +468,6 @@ export async function encrypt(text: string): Promise<string> {
* Input format: "iv_hex:auth_tag_hex:ciphertext_hex"
*/
export async function decrypt(text: string): Promise<string> {
// 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;
}
const result = await decryptWithMigration(text);
return result.value;
}
+6 -1
View File
@@ -14,12 +14,17 @@ export default defineConfig({
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@nestjs/microservices': path.resolve(process.cwd(), './src/mocks/nestjs-microservices'),
'@nestjs/websockets': path.resolve(process.cwd(), './src/mocks/nestjs-websockets'),
},
},
build: {
sourcemap: true,
rollupOptions: {
external: ['better-sqlite3', 'keytar'],
external: [
'better-sqlite3',
'keytar',
],
},
},
});