Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db |
No files matched your search
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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)
|
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
Generated
+6
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"version": "0.3.5",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"version": "0.3.5",
|
"version": "0.4.0",
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"@nestjs/microservices": "^11.1.11",
|
"@nestjs/microservices": "^11.1.11",
|
||||||
"@nestjs/platform-express": "^11.1.11",
|
"@nestjs/platform-express": "^11.1.11",
|
||||||
"@nestjs/platform-fastify": "^11.1.10",
|
"@nestjs/platform-fastify": "^11.1.10",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
|
||||||
"@orpc/client": "^1.10.4",
|
"@orpc/client": "^1.10.4",
|
||||||
"@orpc/server": "^1.11.3",
|
"@orpc/server": "^1.11.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -4196,6 +4195,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.11.tgz",
|
||||||
"integrity": "sha512-apuP7C/gtMBIYNgA8IWt75GTZeWya5JQCnrLZFcOu+IZt00j9Xd/Bm7hbj/Qr/JVoM/7q6c/4p4oOZtBGx4aeA==",
|
"integrity": "sha512-apuP7C/gtMBIYNgA8IWt75GTZeWya5JQCnrLZFcOu+IZt00j9Xd/Bm7hbj/Qr/JVoM/7q6c/4p4oOZtBGx4aeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iterare": "1.2.1",
|
"iterare": "1.2.1",
|
||||||
"object-hash": "3.0.0",
|
"object-hash": "3.0.0",
|
||||||
@@ -21292,6 +21293,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.3.5",
|
"version": "0.4.0",
|
||||||
"description": "Antigravity Manager - Electron App",
|
"description": "Antigravity Manager - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -91,7 +91,6 @@
|
|||||||
"@nestjs/microservices": "^11.1.11",
|
"@nestjs/microservices": "^11.1.11",
|
||||||
"@nestjs/platform-express": "^11.1.11",
|
"@nestjs/platform-express": "^11.1.11",
|
||||||
"@nestjs/platform-fastify": "^11.1.10",
|
"@nestjs/platform-fastify": "^11.1.10",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
|
||||||
"@orpc/client": "^1.10.4",
|
"@orpc/client": "^1.10.4",
|
||||||
"@orpc/server": "^1.11.3",
|
"@orpc/server": "^1.11.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppConfig } from '../../types/config';
|
import { AppConfig } from '../../types/config';
|
||||||
import { ConfigManager } from './manager';
|
import { ConfigManager } from './manager';
|
||||||
|
import { syncAutoStart } from '../../utils/autoStart';
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
return ConfigManager.loadConfig();
|
return ConfigManager.loadConfig();
|
||||||
@@ -10,4 +11,5 @@ export function saveConfig(config: AppConfig): void {
|
|||||||
// Logic to update Tray if language changes
|
// Logic to update Tray if language changes
|
||||||
// For now just save
|
// For now just save
|
||||||
ConfigManager.saveConfig(config);
|
ConfigManager.saveConfig(config);
|
||||||
|
syncAutoStart(config);
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,14 @@ i18n
|
|||||||
auto_sync: 'Auto Sync Current Account',
|
auto_sync: 'Auto Sync Current Account',
|
||||||
auto_sync_desc: 'Periodically sync active account information',
|
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: {
|
proxy: {
|
||||||
title: 'Upstream Proxy',
|
title: 'Upstream Proxy',
|
||||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||||
@@ -391,6 +399,14 @@ i18n
|
|||||||
auto_sync: '自动获取当前账号',
|
auto_sync: '自动获取当前账号',
|
||||||
auto_sync_desc: '定期自动同步当前活跃账号信息',
|
auto_sync_desc: '定期自动同步当前活跃账号信息',
|
||||||
},
|
},
|
||||||
|
startup: {
|
||||||
|
title: '启动',
|
||||||
|
description: '控制系统启动时的应用行为。',
|
||||||
|
auto_startup: '随系统启动',
|
||||||
|
auto_startup_desc: '登录时自动启动并保持在托盘里',
|
||||||
|
macos_hint:
|
||||||
|
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: '上游代理',
|
title: '上游代理',
|
||||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||||
|
|||||||
+60
-4
@@ -18,6 +18,8 @@ import { bootstrapNestServer, stopNestServer } from './server/main';
|
|||||||
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
|
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
|
||||||
import { rpcHandler } from './ipc/handler';
|
import { rpcHandler } from './ipc/handler';
|
||||||
import { ConfigManager } from './ipc/config/manager';
|
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');
|
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 globalMainWindow: BrowserWindow | null = null;
|
||||||
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
||||||
let isQuitting = false;
|
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) => {
|
process.on('exit', (code) => {
|
||||||
logger.info(`Process exit event triggered with code: ${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
|
// 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');
|
logger.info('createWindow: start');
|
||||||
const preload = path.join(__dirname, 'preload.js');
|
const preload = path.join(__dirname, 'preload.js');
|
||||||
logger.info(`createWindow: preload path: ${preload}`);
|
logger.info(`createWindow: preload path: ${preload}`);
|
||||||
@@ -84,6 +120,7 @@ function createWindow() {
|
|||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
|
show: !startHidden,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
devTools: inDevelopment,
|
devTools: inDevelopment,
|
||||||
@@ -99,6 +136,10 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
globalMainWindow = mainWindow;
|
globalMainWindow = mainWindow;
|
||||||
logger.info('createWindow: BrowserWindow instance created');
|
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');
|
logger.info('createWindow: setting main window in ipcContext');
|
||||||
ipcContext.setMainWindow(mainWindow);
|
ipcContext.setMainWindow(mainWindow);
|
||||||
@@ -126,6 +167,7 @@ function createWindow() {
|
|||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
logger.info('Window closed event triggered');
|
logger.info('Window closed event triggered');
|
||||||
|
globalMainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
||||||
@@ -144,6 +186,10 @@ function createWindow() {
|
|||||||
const { level, message, lineNumber, sourceId } = details;
|
const { level, message, lineNumber, sourceId } = details;
|
||||||
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('focus', () => {
|
||||||
|
CloudMonitorService.handleAppFocus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('child-process-gone', (event, details) => {
|
app.on('child-process-gone', (event, details) => {
|
||||||
@@ -237,13 +283,23 @@ app
|
|||||||
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
|
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
|
||||||
initDatabase();
|
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(() => {
|
.then(() => {
|
||||||
logger.info('Step: setupORPC');
|
logger.info('Step: setupORPC');
|
||||||
return setupORPC();
|
return setupORPC();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
logger.info('Step: createWindow');
|
logger.info('Step: createWindow');
|
||||||
await createWindow();
|
await createWindow({ startHidden: shouldStartHidden });
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.info('Step: installExtensions (SKIPPED)');
|
logger.info('Step: installExtensions (SKIPPED)');
|
||||||
@@ -260,7 +316,7 @@ app
|
|||||||
AuthServer.start();
|
AuthServer.start();
|
||||||
|
|
||||||
// Gateway Server (NestJS) - auto-start if enabled
|
// Gateway Server (NestJS) - auto-start if enabled
|
||||||
const config = ConfigManager.loadConfig();
|
const config = startupConfig || ConfigManager.loadConfig();
|
||||||
if (config.proxy?.auto_start) {
|
if (config.proxy?.auto_start) {
|
||||||
const port = config.proxy?.port || 8045;
|
const port = config.proxy?.port || 8045;
|
||||||
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
|
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
|
||||||
@@ -302,7 +358,7 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow({ startHidden: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
//osX only ends
|
//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,
|
queryFn: getPlatform,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAutoStartSupported =
|
||||||
|
platform === 'win32' || platform === 'darwin' || platform === 'linux';
|
||||||
|
const isMac = platform === 'darwin';
|
||||||
|
|
||||||
const handleLanguageChange = (value: string) => {
|
const handleLanguageChange = (value: string) => {
|
||||||
setAppLanguage(value, i18n);
|
setAppLanguage(value, i18n);
|
||||||
};
|
};
|
||||||
@@ -179,6 +183,49 @@ function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||||
|
|||||||
@@ -6,17 +6,28 @@ import { logger } from '../utils/logger';
|
|||||||
export class CloudMonitorService {
|
export class CloudMonitorService {
|
||||||
private static intervalId: NodeJS.Timeout | null = null;
|
private static intervalId: NodeJS.Timeout | null = null;
|
||||||
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
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() {
|
static start() {
|
||||||
if (this.intervalId) return;
|
if (this.intervalId) return;
|
||||||
logger.info('Starting CloudMonitorService...');
|
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
|
// Initial Poll
|
||||||
this.poll().catch((e) => logger.error('Initial poll failed', e));
|
this.poll().catch((e) => logger.error('Initial poll failed', e));
|
||||||
|
|
||||||
this.intervalId = setInterval(() => {
|
this.startInterval();
|
||||||
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
|
||||||
}, this.POLL_INTERVAL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static stop() {
|
static stop() {
|
||||||
@@ -27,7 +38,59 @@ export class CloudMonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the application window gains focus.
|
||||||
|
* Triggers an immediate poll if not rate-limited by debounce.
|
||||||
|
*/
|
||||||
|
static async handleAppFocus() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
static async poll() {
|
||||||
|
if (this.isPolling) {
|
||||||
|
return; // Extra safety
|
||||||
|
}
|
||||||
|
this.isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
logger.info('CloudMonitor: Polling quotas...');
|
logger.info('CloudMonitor: Polling quotas...');
|
||||||
const accounts = await CloudAccountRepo.getAccounts();
|
const accounts = await CloudAccountRepo.getAccounts();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -62,5 +125,8 @@ export class CloudMonitorService {
|
|||||||
|
|
||||||
// 4. Check for Auto-Switch
|
// 4. Check for Auto-Switch
|
||||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ export const AppConfigSchema = z.object({
|
|||||||
refresh_interval: z.number(), // minutes
|
refresh_interval: z.number(), // minutes
|
||||||
auto_sync: z.boolean(), // 是否自动同步
|
auto_sync: z.boolean(), // 是否自动同步
|
||||||
sync_interval: z.number(), // minutes
|
sync_interval: z.number(), // minutes
|
||||||
|
auto_startup: z.boolean(), // 是否随系统启动
|
||||||
default_export_path: z.string().nullable().optional(), // 导出路径
|
default_export_path: z.string().nullable().optional(), // 导出路径
|
||||||
proxy: ProxyConfigSchema,
|
proxy: ProxyConfigSchema,
|
||||||
});
|
});
|
||||||
@@ -37,6 +38,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
refresh_interval: 15,
|
refresh_interval: 15,
|
||||||
auto_sync: false,
|
auto_sync: false,
|
||||||
sync_interval: 5,
|
sync_interval: 5,
|
||||||
|
auto_startup: false,
|
||||||
default_export_path: null,
|
default_export_path: null,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,17 @@ export default defineConfig({
|
|||||||
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
|
||||||
'@fastify/static': 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'),
|
'@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: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['better-sqlite3', 'keytar'],
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'keytar',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user