Compare commits

..
7 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
22 changed files with 606 additions and 275 deletions

No files matched your search

+21
View File
@@ -2,6 +2,27 @@
# 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
-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.4-beta.1">
Download pre-release v0.3.4-beta.1
</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
-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.4-beta.1">
下载 pre-release v0.3.4-beta.1
</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
+6 -217
View File
@@ -1,12 +1,12 @@
{
"name": "antigravity-manager",
"version": "0.3.4",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antigravity-manager",
"version": "0.3.4",
"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.4",
"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",
+7
View File
@@ -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',
}),
});
};
+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);
}
+1 -1
View File
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
pendingRequests.delete(id);
reject(new Error(`Request /${methodPath} timed out`));
}
}, 10000);
}, 60000);
});
}
+18
View File
@@ -100,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.',
@@ -245,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...',
@@ -390,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 的请求配置代理。',
@@ -533,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);
});
});
});
+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);
}
}
+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',
],
},
},
});