Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db |
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
# 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
|
||||
|
||||
Generated
+6
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.3.5",
|
||||
"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",
|
||||
@@ -4196,6 +4195,8 @@
|
||||
"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",
|
||||
@@ -21292,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"
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -91,7 +91,6 @@
|
||||
"@nestjs/microservices": "^11.1.11",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@orpc/client": "^1.10.4",
|
||||
"@orpc/server": "^1.11.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
@@ -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.',
|
||||
@@ -391,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 的请求配置代理。',
|
||||
|
||||
+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,40 +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);
|
||||
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
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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