Compare commits
5
Commits
v0.2.2
...
v0.3.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ab4e0ce7 | ||
|
|
013f3290f3 | ||
|
|
f224fea081 | ||
|
|
a438070268 | ||
|
|
e927ac9506 |
No files matched your search
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||
}
|
||||
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.0-rc.1] - 2026-01-21
|
||||
|
||||
### New Features
|
||||
|
||||
- Verify Google OAuth code automatically after receipt ([e927ac9]
|
||||
- Add button to open logs folder ([a438070]
|
||||
- Add expiration warning for Google OAuth authentication ([f224fea]
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Add VS Code settings for auto-formatting and ESLint ([013f329])
|
||||
|
||||
## [0.2.2] - 2026-01-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ const config: ForgeConfig = {
|
||||
name: 'AntigravityManager',
|
||||
},
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
prerelease: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0-rc.1",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0-rc.1",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function openLogDirectory() {
|
||||
return ipc.client.system.openLogDirectory();
|
||||
}
|
||||
@@ -62,19 +62,49 @@ export function CloudAccountList() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
setAuthCode(code);
|
||||
// Auto submit if dialog is open
|
||||
if (isAddDialogOpen) {
|
||||
handleAddAccount(code);
|
||||
}
|
||||
// Note: Auto-submit will be triggered by the authCode change effect below
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isAddDialogOpen]);
|
||||
}, []);
|
||||
|
||||
// Auto-submit when authCode is set and dialog is open
|
||||
useEffect(() => {
|
||||
if (authCode && isAddDialogOpen && !addMutation.isPending) {
|
||||
console.log('[OAuth] Auto-submitting auth code');
|
||||
handleAddAccount(authCode);
|
||||
}
|
||||
}, [authCode, isAddDialogOpen]);
|
||||
|
||||
// Batch Operations State
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -176,28 +206,6 @@ export function CloudAccountList() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) return;
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openAuthUrl = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
|
||||
@@ -20,11 +20,13 @@ export class AuthServer {
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
logger.info('AuthServer: Received authorization code');
|
||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 4000 });
|
||||
logger.debug(`Process check stdout: ${stdout.trim()}`);
|
||||
|
||||
if (platform === 'win32' || isWsl()) {
|
||||
return stdout.includes('Antigravity.exe');
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { shell } from 'electron';
|
||||
import { getAgentDir } from '../../utils/paths';
|
||||
|
||||
// Schema for IP info
|
||||
const IpInfoSchema = z.object({
|
||||
@@ -61,4 +63,10 @@ export const systemHandler = os.router({
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// Open log directory in file explorer
|
||||
openLogDirectory: os.output(z.void()).handler(async () => {
|
||||
const logDir = getAgentDir();
|
||||
await shell.openPath(logDir);
|
||||
}),
|
||||
});
|
||||
@@ -30,6 +30,7 @@ i18n
|
||||
deleteBackup: 'Delete Backup',
|
||||
backupCurrent: 'Backup Current',
|
||||
retry: 'Retry',
|
||||
openLogs: 'Open Log Directory',
|
||||
},
|
||||
error: {
|
||||
generic: 'An unexpected error occurred.',
|
||||
@@ -77,6 +78,7 @@ i18n
|
||||
version: 'Version',
|
||||
platform: 'Platform',
|
||||
license: 'License',
|
||||
openLogDir: 'Open',
|
||||
account: {
|
||||
title: 'Account Settings',
|
||||
description: 'Configure automatic account refresh and sync.',
|
||||
@@ -304,6 +306,7 @@ i18n
|
||||
deleteBackup: '删除备份',
|
||||
backupCurrent: '备份当前账号',
|
||||
retry: '重试',
|
||||
openLogs: '打开日志目录',
|
||||
},
|
||||
error: {
|
||||
generic: '发生未知错误。',
|
||||
@@ -351,6 +354,7 @@ i18n
|
||||
version: '版本',
|
||||
platform: '平台',
|
||||
license: '许可证',
|
||||
openLogDir: '打开',
|
||||
account: {
|
||||
title: '账号设置',
|
||||
description: '配置自动刷新和同步账号。',
|
||||
|
||||
+11
-1
@@ -17,9 +17,10 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { openLogDirectory } from '@/actions/system';
|
||||
|
||||
function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -193,6 +194,15 @@ function SettingsPage() {
|
||||
|
||||
<div className="text-muted-foreground">{t('settings.license')}</div>
|
||||
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir', 'Open')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const CLIENT_SECRET = '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';
|
||||
|
||||
declare const fetch: any;
|
||||
|
||||
const URLS = {
|
||||
TOKEN: 'https://oauth2.googleapis.com/token',
|
||||
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
@@ -20,6 +18,18 @@ const URLS = {
|
||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
|
||||
// Request timeout in milliseconds (30 seconds)
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Creates an AbortSignal that times out after the specified duration.
|
||||
*/
|
||||
function createTimeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TokenResponse {
|
||||
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
|
||||
// --- Service Implementation ---
|
||||
|
||||
export class GoogleAPIService {
|
||||
private static getFetchOptions(): any {
|
||||
private static getFetchOptions() {
|
||||
try {
|
||||
const config = ConfigManager.loadConfig();
|
||||
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
||||
@@ -121,7 +131,17 @@ export class GoogleAPIService {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -147,7 +167,17 @@ export class GoogleAPIService {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token refresh timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -166,7 +196,17 @@ export class GoogleAPIService {
|
||||
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
||||
const response = await fetch(URLS.USER_INFO, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'User info request timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -221,7 +261,7 @@ export class GoogleAPIService {
|
||||
const projectId = await this.fetchProjectId(accessToken);
|
||||
|
||||
// 2. Build Payload
|
||||
const payload: Record<string, any> = {};
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (projectId) {
|
||||
payload['project'] = projectId;
|
||||
}
|
||||
@@ -285,11 +325,13 @@ export class GoogleAPIService {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
lastError = e;
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.warn(
|
||||
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
}
|
||||
lastError = e instanceof Error ? e : new Error(String(e));
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user