Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db | ||
|
|
a988a33d21 | ||
|
|
e959ee346e | ||
|
|
5a2168030e | ||
|
|
00bf4dea0b | ||
|
|
02080588b7 | ||
|
|
d5ee08d5a0 | ||
|
|
e844aa9545 | ||
|
|
0d4e2ab21f | ||
|
|
f15bb48fdd | ||
|
|
515648d64d | ||
|
|
22265e153c |
No files matched your search
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+49
-22
@@ -2,6 +2,54 @@
|
||||
|
||||
# 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
|
||||
|
||||
* 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
|
||||
@@ -17,32 +65,11 @@
|
||||
* 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.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))
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
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.3.1] - 2026-01-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed startup race condition causing cloud accounts verify failure ([f0718db]
|
||||
- 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
|
||||
|
||||
Generated
+6
-217
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.3.2",
|
||||
"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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.3.2",
|
||||
"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",
|
||||
|
||||
+134
-2
@@ -6,6 +6,10 @@
|
||||
* 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',
|
||||
@@ -69,8 +73,128 @@ const releaseRules = [
|
||||
{ release: false, subject: '*skip release*' },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
branches: ['main', 'master'],
|
||||
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',
|
||||
@@ -107,6 +231,7 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
|
||||
appendReleaseNotesPlugin,
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
@@ -125,3 +250,10 @@ module.exports = {
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
releaseConfig.__internal = {
|
||||
appendReleaseNotes,
|
||||
buildReleaseFooter,
|
||||
};
|
||||
|
||||
module.exports = releaseConfig;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request /${methodPath} timed out`));
|
||||
}
|
||||
}, 10000);
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -56,8 +56,8 @@ function isPgrepNoMatchError(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message;
|
||||
const hasPgrep = message.includes('pgrep -xi Antigravity');
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
@@ -74,18 +74,35 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
|
||||
// Use find-process to search for Antigravity processes
|
||||
// 'name' search type matches process name
|
||||
let processes: ProcessInfo[] = [];
|
||||
try {
|
||||
processes = await findProcess('name', 'Antigravity', true);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
return false;
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
let sawNoMatch = false;
|
||||
|
||||
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;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'Antigravity'`);
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found ${processes.length} processes matching 'Antigravity/antigravity'`,
|
||||
);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CloudMonitorService } from '../../services/CloudMonitorService';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { AutoSwitchService } from '../../services/AutoSwitchService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../ipc/database/cloudHandler');
|
||||
vi.mock('../../services/GoogleAPIService');
|
||||
vi.mock('../../services/AutoSwitchService');
|
||||
vi.mock('../../utils/logger');
|
||||
|
||||
describe('CloudMonitorService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
CloudMonitorService.resetStateForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CloudMonitorService.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should start polling on start() and set up interval', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
|
||||
// Should call initial poll
|
||||
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fast forward 5 minutes
|
||||
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
|
||||
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
pollSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should poll accounts correctly', async () => {
|
||||
const mockAccounts = [
|
||||
{
|
||||
id: 'acc1',
|
||||
email: '[email protected]',
|
||||
token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||
|
||||
// Start poll but don't await immediately, as it pauses
|
||||
const pollPromise = CloudMonitorService.poll();
|
||||
|
||||
// Advance time to pass the 1s sleep
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Now await
|
||||
await pollPromise;
|
||||
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
|
||||
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token if expired during poll', async () => {
|
||||
const mockAccounts = [
|
||||
{
|
||||
id: 'acc1',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'old_token',
|
||||
refresh_token: 'ref_token',
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||
vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'new_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||
|
||||
// Same async pattern
|
||||
const pollPromise = CloudMonitorService.poll();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await pollPromise;
|
||||
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
||||
});
|
||||
|
||||
describe('handleAppFocus (Smart Refresh)', () => {
|
||||
it('should trigger poll when focused after debounce time', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
await CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Called once by start, once by focus
|
||||
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should NOT trigger poll if debounced (focused too soon)', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
|
||||
vi.setSystemTime(Date.now() + 1000);
|
||||
|
||||
await CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Called once by start, 0 by focus
|
||||
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active'));
|
||||
});
|
||||
|
||||
it('should NOT trigger poll if already polling (concurrency guard)', async () => {
|
||||
// Let's spy on poll to silence start's poll
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
CloudMonitorService.start();
|
||||
pollSpy.mockRestore(); // Restore so we can test the real guard logic
|
||||
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
// 2. Mock getAccounts to delay
|
||||
let resolveGetAccounts: (value: unknown) => void;
|
||||
const getAccountsPromise = new Promise((resolve) => {
|
||||
resolveGetAccounts = resolve;
|
||||
});
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never);
|
||||
|
||||
// 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep
|
||||
// Actually poll() calls getAccounts first thing.
|
||||
const p1 = CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Allow p1 to start execution and enter poll()
|
||||
// We invoke one run loop
|
||||
// But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await.
|
||||
|
||||
// 4. Trigger second focus immediately
|
||||
const p2 = CloudMonitorService.handleAppFocus();
|
||||
|
||||
// 5. Release the first poll
|
||||
resolveGetAccounts!([
|
||||
{ id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } },
|
||||
]);
|
||||
|
||||
// Advance timer for the 1s sleep inside poll
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('polling is already in progress'),
|
||||
);
|
||||
// getAccounts should be called once (by the first unblocked poll)
|
||||
// The second one blocked by guard before calling poll
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reset interval after successful focus poll', async () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||
|
||||
// Spy on poll to consume the start() call
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
CloudMonitorService.start();
|
||||
pollSpy.mockRestore(); // Restore real poll
|
||||
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
// Needs to handle async poll
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
|
||||
|
||||
const focusPromise = CloudMonitorService.handleAppFocus();
|
||||
await vi.advanceTimersByTimeAsync(1000); // For poll sleep
|
||||
await focusPromise;
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
// 1 for start (initial), 1 for reset = 2
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user