Initial Commit
This commit is contained in:
commit
d0417339c1
158 files changed
+31368
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Account, AccountBackupData } from '../../types/account';
|
||||
import {
|
||||
backupAccount as dbBackup,
|
||||
restoreAccount as dbRestore,
|
||||
getCurrentAccountInfo,
|
||||
} from '../database/handler';
|
||||
import { closeAntigravity, startAntigravity } from '../process/handler';
|
||||
|
||||
type AccountIndex = Record<string, Account>;
|
||||
|
||||
/**
|
||||
* Loads the accounts index from the file system.
|
||||
* @returns {AccountIndex} The accounts index.
|
||||
*/
|
||||
function loadAccountsIndex(): AccountIndex {
|
||||
const filePath = getAccountsFilePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load accounts index', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the accounts index to the file system.
|
||||
* @param accounts {AccountIndex} The accounts index to save.
|
||||
* @throws {Error} If the accounts index cannot be saved.
|
||||
*/
|
||||
function saveAccountsIndex(accounts: AccountIndex): void {
|
||||
const filePath = getAccountsFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save accounts index', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the accounts data.
|
||||
* @returns {Account[]} The list of accounts.
|
||||
* @throws {Error} If the accounts index cannot be loaded.
|
||||
*/
|
||||
export async function listAccountsData(): Promise<Account[]> {
|
||||
const accountsObj = loadAccountsIndex();
|
||||
const accountsList = Object.values(accountsObj);
|
||||
// NOTE: Sort by last_used descending
|
||||
accountsList.sort((a, b) => {
|
||||
const aTime = a.last_used || '';
|
||||
const bTime = b.last_used || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
return accountsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an account snapshot.
|
||||
* @returns {Account} The added account.
|
||||
* @throws {Error} If the account cannot be added.
|
||||
*/
|
||||
export async function addAccountSnapshot(): Promise<Account> {
|
||||
logger.info('Adding account snapshot...');
|
||||
|
||||
// NOTE Get current account info from DB
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Antigravity is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// NOTE Find existing account by email
|
||||
let existingId: string | null = null;
|
||||
for (const [id, acc] of Object.entries(accounts)) {
|
||||
if (acc.email === info.email) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let account: Account;
|
||||
let backupPath: string;
|
||||
|
||||
if (existingId) {
|
||||
// NOTE Update existing account
|
||||
account = accounts[existingId];
|
||||
|
||||
// NOTE Preserve custom name: only update if we have a name from DB AND it's not the default email prefix
|
||||
// NOTE if not name or name == email.split("@")[0]: name = existing_account.get("name", name)
|
||||
const defaultName = info.email.split('@')[0];
|
||||
if (!info.name || info.name === defaultName) {
|
||||
// NOTE Keep the existing custom name
|
||||
// (account.name is already set, no change needed)
|
||||
} else {
|
||||
// NOTE We have a non-default name from DB, use it
|
||||
account.name = info.name;
|
||||
}
|
||||
|
||||
account.last_used = now;
|
||||
|
||||
// NOTE Use existing backup path if available, otherwise generate new one
|
||||
backupPath = account.backup_file || path.join(getBackupsDir(), `${account.id}.json`);
|
||||
|
||||
logger.info(`Updating existing account: ${info.email}`);
|
||||
} else {
|
||||
const accountId = uuidv4();
|
||||
|
||||
// NOTE Generate name with edge case handling
|
||||
let accountName: string;
|
||||
if (info.name) {
|
||||
accountName = info.name;
|
||||
} else if (info.email && info.email !== 'Unknown') {
|
||||
accountName = info.email.split('@')[0];
|
||||
} else {
|
||||
// Edge case: email is "Unknown" or invalid
|
||||
accountName = `Account_${Date.now()}`;
|
||||
}
|
||||
|
||||
backupPath = path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
account = {
|
||||
id: accountId,
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
accounts[accountId] = account;
|
||||
logger.info(`Creating new account: ${info.email}`);
|
||||
}
|
||||
|
||||
// NOTE Backup data from DB
|
||||
const backupData = dbBackup(account);
|
||||
|
||||
// NOTE Save backup file
|
||||
const backupsDir = getBackupsDir();
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(backupPath, JSON.stringify(backupData, null, 2));
|
||||
|
||||
// NOTE Update backup_file in account object and save
|
||||
account.backup_file = backupPath;
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to an account.
|
||||
* @param accountId {string} The ID of the account to switch to.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
// NOTE Close Antigravity (with error handling)
|
||||
try {
|
||||
await closeAntigravity();
|
||||
} catch (error) {
|
||||
// NOTE Continue even if close fails
|
||||
logger.warn('Unable to close Antigravity, attempting forced restore...', error);
|
||||
}
|
||||
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
// NOTE Start Antigravity
|
||||
await startAntigravity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
* @param accountId {string} The ID of the account to delete.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function deleteAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Deleting account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Remove backup file using stored path
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
logger.info(`Backup file deleted: ${backupPath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete backup file: ${backupPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Remove from index
|
||||
delete accounts[accountId];
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
listAccountsData,
|
||||
addAccountSnapshot,
|
||||
switchAccount,
|
||||
deleteAccount,
|
||||
} from "./handler";
|
||||
import { AccountSchema } from "../../types/account";
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
return listAccountsData();
|
||||
}),
|
||||
|
||||
addAccountSnapshot: os.output(AccountSchema).handler(async () => {
|
||||
return addAccountSnapshot();
|
||||
}),
|
||||
|
||||
switchAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchAccount(input.accountId);
|
||||
}),
|
||||
|
||||
deleteAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteAccount(input.accountId);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { app } from "electron";
|
||||
|
||||
export const currentPlatfom = os.handler(() => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
export const appVersion = os.handler(() => {
|
||||
return app.getVersion();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { appVersion, currentPlatfom } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const app = os.router({
|
||||
currentPlatfom,
|
||||
appVersion,
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
logger.info('AuthServer: Received authorization code');
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Antigravity Manager.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
static stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
logger.info('AuthServer: Stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
import { closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||
import { updateTrayMenu } from '../../ipc/tray/handler';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
|
||||
// Fallback constants if service constants are not available or for direct usage
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
].join(' ');
|
||||
|
||||
// Helper to update tray
|
||||
function notifyTrayUpdate(account: CloudAccount) {
|
||||
try {
|
||||
// Fetch language setting. Default to 'en' if not set.
|
||||
|
||||
const lang = CloudAccountRepo.getSetting<string>('language', 'en');
|
||||
updateTrayMenu(account, lang);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update tray', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(authCode: string): Promise<CloudAccount> {
|
||||
try {
|
||||
// 1. Exchange code for tokens
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
||||
|
||||
// 2. Get User Info
|
||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
||||
|
||||
// 3. Construct CloudAccount Object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
provider: 'google',
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email,
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenResp.access_token,
|
||||
refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely
|
||||
expires_in: tokenResp.expires_in,
|
||||
expiry_timestamp: now + tokenResp.expires_in,
|
||||
token_type: tokenResp.token_type,
|
||||
email: userInfo.email,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
|
||||
if (!account.token.refresh_token) {
|
||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
await CloudAccountRepo.addAccount(account);
|
||||
|
||||
// 5. Initial Quota Check (Async, best effort)
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial quota', e);
|
||||
}
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Google account', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCloudAccounts(): Promise<CloudAccount[]> {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
}
|
||||
|
||||
export async function deleteCloudAccount(accountId: string): Promise<void> {
|
||||
await CloudAccountRepo.removeAccount(accountId);
|
||||
}
|
||||
|
||||
export async function refreshAccountQuota(accountId: string): Promise<CloudAccount> {
|
||||
const account = await CloudAccountRepo.getAccount(accountId);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
let now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
// 5 minutes buffer
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
|
||||
// Update token in memory object
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
|
||||
// Save to DB
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
// Force Refresh
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
// Retry Quota
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||
refreshError,
|
||||
);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
// Return existing account to avoid crash
|
||||
return account;
|
||||
}
|
||||
|
||||
logger.error(`Failed to refresh quota for ${account.email}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
try {
|
||||
const account = await CloudAccountRepo.getAccount(accountId);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
logger.info(`Switching to cloud account: ${account.email} (${account.id})`);
|
||||
|
||||
// 1. Ensure token is fresh before injecting
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing before switch...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refresh token before switch, trying with existing token', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop Antigravity Process
|
||||
await closeAntigravity(); // This waits for it to close
|
||||
|
||||
// 3. Backup Database (New Logic)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
// Find the valid DB path
|
||||
let dbPath: string | null = null;
|
||||
for (const p of dbPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dbPath) {
|
||||
try {
|
||||
const backupPath = `${dbPath}.backup`;
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
logger.info(`Backed up database to ${backupPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Failed to backup database', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inject Token
|
||||
// injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
|
||||
CloudAccountRepo.injectCloudToken(account.token);
|
||||
|
||||
// 4. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
CloudAccountRepo.setActive(account.id);
|
||||
|
||||
// 5. Restart Process
|
||||
await startAntigravity();
|
||||
|
||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (err: any) {
|
||||
logger.error('Failed to switch cloud account', err);
|
||||
throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoSwitchEnabled(): boolean {
|
||||
return CloudAccountRepo.getSetting<boolean>('auto_switch_enabled', false);
|
||||
}
|
||||
|
||||
export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
|
||||
CloudAccountRepo.setSetting('auto_switch_enabled', enabled);
|
||||
// Trigger an immediate check if enabled
|
||||
if (enabled) {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
CloudMonitorService.poll().catch((err: any) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function forcePollCloudMonitor(): Promise<void> {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
|
||||
export async function startAuthFlow(): Promise<void> {
|
||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
||||
|
||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
listCloudAccounts,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
switchCloudAccount,
|
||||
getAutoSwitchEnabled,
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
} from './handler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
refreshAccountQuota: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
getAutoSwitchEnabled: os.output(z.boolean()).handler(async () => {
|
||||
return getAutoSwitchEnabled();
|
||||
}),
|
||||
|
||||
setAutoSwitchEnabled: os
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await setAutoSwitchEnabled(input.enabled);
|
||||
}),
|
||||
|
||||
forcePollCloudMonitor: os.output(z.void()).handler(async () => {
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
||||
await startAuthFlow();
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
ConfigManager.saveConfig(config);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppConfig, AppConfigSchema, DEFAULT_APP_CONFIG } from '../../types/config';
|
||||
import { getAppDataDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
}
|
||||
return path.join(appDataDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
static loadConfig(): AppConfig {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const raw = JSON.parse(content);
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
};
|
||||
|
||||
// Fix deep merge for upstream_proxy if needed
|
||||
if (raw.proxy && raw.proxy.upstream_proxy) {
|
||||
merged.proxy.upstream_proxy = {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...raw.proxy.upstream_proxy,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
return merged as AppConfig;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { AppConfigSchema } from '../../types/config';
|
||||
import { loadConfig, saveConfig } from './handlers';
|
||||
|
||||
export const configRouter = os.router({
|
||||
load: os.output(AppConfigSchema).handler(async () => {
|
||||
return loadConfig();
|
||||
}),
|
||||
|
||||
save: os
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
saveConfig(input);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
class IPCContext {
|
||||
public mainWindow: BrowserWindow | undefined;
|
||||
|
||||
public setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
public get mainWindowContext() {
|
||||
// Return a middleware that checks for the window at execution time, not import time
|
||||
return os.middleware(({ next }) => {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window is not set in IPC context.');
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
window: this.mainWindow,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcContext = new IPCContext();
|
||||
@@ -0,0 +1,486 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { encrypt, decrypt } from '../../utils/security';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
/**
|
||||
* Ensures that the cloud database file and schema exist.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
*/
|
||||
function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Create accounts table
|
||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
token_json TEXT NOT NULL,
|
||||
quota_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: Check if is_active column exists
|
||||
const tableInfo = db.pragma('table_info(accounts)') as any[];
|
||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
// Create index on email for faster lookups
|
||||
// Create index on email for faster lookups
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);`);
|
||||
|
||||
// Create settings table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize cloud database schema', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a connection to the cloud accounts database.
|
||||
*/
|
||||
function getDb(): Database.Database {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
return new Database(dbPath);
|
||||
}
|
||||
|
||||
export class CloudAccountRepo {
|
||||
static async init(): Promise<void> {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
await this.migrateToEncrypted();
|
||||
}
|
||||
|
||||
static async migrateToEncrypted(): Promise<void> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, token_json, quota_json FROM accounts').all() as any[];
|
||||
|
||||
for (const row of rows) {
|
||||
let changed = false;
|
||||
let newToken = row.token_json;
|
||||
let newQuota = row.quota_json;
|
||||
|
||||
// Check if plain text (starts with {)
|
||||
if (newToken && newToken.startsWith('{')) {
|
||||
newToken = await encrypt(newToken);
|
||||
changed = true;
|
||||
}
|
||||
if (newQuota && newQuota.startsWith('{')) {
|
||||
newQuota = await encrypt(newQuota);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
db.prepare('UPDATE accounts SET token_json = ?, quota_json = ? WHERE id = ?').run(
|
||||
newToken,
|
||||
newQuota,
|
||||
row.id,
|
||||
);
|
||||
logger.info(`Migrated account ${row.id} to encrypted storage`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to migrate data', e);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async addAccount(account: CloudAccount): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||
const quotaEncrypted = account.quota ? await encrypt(JSON.stringify(account.quota)) : null;
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
// If this account is being set to active, deactivate all others first
|
||||
if (account.is_active) {
|
||||
logger.info(
|
||||
`[DEBUG] addAccount: Deactivating all other accounts because ${account.email} is active`,
|
||||
);
|
||||
const info = db.prepare('UPDATE accounts SET is_active = 0').run();
|
||||
logger.info(`[DEBUG] addAccount: Deactivation changed ${info.changes} rows`);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO accounts (
|
||||
id, provider, email, name, avatar_url, token_json, quota_json, created_at, last_used, status, is_active
|
||||
) VALUES (
|
||||
@id, @provider, @email, @name, @avatar_url, @token_json, @quota_json, @created_at, @last_used, @status, @is_active
|
||||
)
|
||||
`);
|
||||
|
||||
stmt.run({
|
||||
id: account.id,
|
||||
provider: account.provider,
|
||||
email: account.email,
|
||||
name: account.name || null,
|
||||
avatar_url: account.avatar_url || null,
|
||||
token_json: tokenEncrypted,
|
||||
quota_json: quotaEncrypted,
|
||||
created_at: account.created_at,
|
||||
last_used: account.last_used,
|
||||
status: account.status || 'active',
|
||||
is_active: account.is_active ? 1 : 0,
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
logger.info(`Added/Updated cloud account: ${account.email}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccounts(): Promise<CloudAccount[]> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
||||
const rows = stmt.all() as any[];
|
||||
|
||||
// DEBUG LOGS
|
||||
const activeRows = rows.filter((r) => r.is_active);
|
||||
logger.info(
|
||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
is_active: Boolean(row.is_active),
|
||||
})),
|
||||
);
|
||||
|
||||
return accounts;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccount(id: string): Promise<CloudAccount | undefined> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
|
||||
const row = stmt.get(id) as any;
|
||||
|
||||
if (!row) return undefined;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
is_active: Boolean(row.is_active),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async removeAccount(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
|
||||
logger.info(`Removed cloud account: ${id}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateToken(id: string, token: any): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(token));
|
||||
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(encrypted, id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(quota));
|
||||
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(encrypted, id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static updateLastUsed(id: string): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('UPDATE accounts SET last_used = ? WHERE id = ?').run(
|
||||
Math.floor(Date.now() / 1000),
|
||||
id,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setActive(id: string): void {
|
||||
const db = getDb();
|
||||
const updateAll = db.prepare('UPDATE accounts SET is_active = 0');
|
||||
const updateOne = db.prepare('UPDATE accounts SET is_active = 1 WHERE id = ?');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
updateAll.run();
|
||||
updateOne.run(id);
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.info(`Set account ${id} as active`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static injectCloudToken(token: CloudAccount['token']): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
let dbPath: string | null = null;
|
||||
|
||||
for (const p of dbPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
|
||||
|
||||
if (!row || !row.value) {
|
||||
throw new Error('jetskiStateSync.agentManagerInitState not found in database');
|
||||
}
|
||||
|
||||
// 1. Decode Base64
|
||||
const buffer = Buffer.from(row.value, 'base64');
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
// 2. Remove Field 6
|
||||
const cleanData = ProtobufUtils.removeField(data, 6);
|
||||
|
||||
// 3. Create New Field 6
|
||||
const newField = ProtobufUtils.createOAuthTokenInfo(
|
||||
token.access_token,
|
||||
token.refresh_token,
|
||||
token.expiry_timestamp,
|
||||
);
|
||||
|
||||
// 4. Concatenate
|
||||
const finalData = new Uint8Array(cleanData.length + newField.length);
|
||||
finalData.set(cleanData, 0);
|
||||
finalData.set(newField, cleanData.length);
|
||||
|
||||
// 5. Encode Base64
|
||||
const finalB64 = Buffer.from(finalData).toString('base64');
|
||||
|
||||
// 6. Write back
|
||||
db.prepare('UPDATE ItemTable SET value = ? WHERE key = ?').run(
|
||||
finalB64,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
|
||||
// 7. Inject Onboarding Flag (Missing in initial port)
|
||||
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
|
||||
'antigravityOnboarding',
|
||||
'true',
|
||||
);
|
||||
|
||||
logger.info(`Successfully injected cloud token into Antigravity database at ${dbPath}.`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static getSetting<T>(key: string, defaultValue: T): T {
|
||||
const db = getDb();
|
||||
try {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (!row) return defaultValue;
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get setting ${key}`, e);
|
||||
return defaultValue;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setSetting(key: string, value: any): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||
|
||||
let dbPath: string | null = null;
|
||||
for (const p of dbPaths) {
|
||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||
const ideDb = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
// 1. Read Raw Token Data
|
||||
const row = ideDb
|
||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
|
||||
|
||||
if (!row || !row.value) {
|
||||
const errorMsg =
|
||||
'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 2. Decode Protobuf
|
||||
const buffer = Buffer.from(row.value, 'base64');
|
||||
const data = new Uint8Array(buffer);
|
||||
const tokenInfo = ProtobufUtils.extractOAuthTokenInfo(data);
|
||||
|
||||
if (!tokenInfo) {
|
||||
const errorMsg =
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 3. Fetch User Info
|
||||
// We need to fetch user info to know who this token belongs to
|
||||
let userInfo;
|
||||
try {
|
||||
userInfo = await GoogleAPIService.getUserInfo(tokenInfo.accessToken);
|
||||
} catch (apiError: any) {
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE. Error: ${apiError.message}`;
|
||||
logger.error(`SyncLocal: ${errorMsg}`, apiError);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 4. Check Duplicate & Construct Account
|
||||
// We use existing addAccount logic which does UPSERT (REPLACE)
|
||||
// Construct CloudAccount object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(), // Generate new ID if new, but check existing email
|
||||
provider: 'google',
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenInfo.accessToken,
|
||||
refresh_token: tokenInfo.refreshToken,
|
||||
expires_in: 3600, // Unknown, assume 1 hour validity or let it refresh
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
status: 'active',
|
||||
is_active: true, // It is the active one in IDE
|
||||
};
|
||||
|
||||
// Check if email already exists to preserve ID
|
||||
const accounts = await this.getAccounts();
|
||||
const existing = accounts.find((a) => a.email === account.email);
|
||||
if (existing) {
|
||||
account.id = existing.id; // Keep existing ID
|
||||
account.created_at = existing.created_at;
|
||||
}
|
||||
|
||||
await this.addAccount(account);
|
||||
return account;
|
||||
} catch (error) {
|
||||
logger.error('SyncLocal: Failed to sync account from IDE', error);
|
||||
throw error;
|
||||
} finally {
|
||||
ideDb.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {Database.Database} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): Database.Database {
|
||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Antigravity database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Antigravity before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
|
||||
// Query for auth status
|
||||
const authRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let authStatus = null;
|
||||
if (authRow) {
|
||||
try {
|
||||
authStatus = JSON.parse(authRow.value);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let initState = null;
|
||||
if (initRow) {
|
||||
try {
|
||||
initState = JSON.parse(initRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.antigravity
|
||||
const googleRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let googleState = null;
|
||||
if (googleRow) {
|
||||
try {
|
||||
googleState = JSON.parse(googleRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for antigravityUserSettings.allUserSettings
|
||||
const settingsRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let settingsState = null;
|
||||
if (settingsRow) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (typeof obj.email === 'string') return obj.email;
|
||||
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
|
||||
if (row) {
|
||||
try {
|
||||
data[key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
data[key] = row.value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Antigravity database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
try {
|
||||
db = getDatabaseConnection(dbPath);
|
||||
|
||||
const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
insert.run(key, stringValue);
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
backupAccount,
|
||||
restoreAccount,
|
||||
getCurrentAccountInfo,
|
||||
} from "./handler";
|
||||
import {
|
||||
AccountBackupDataSchema,
|
||||
AccountInfoSchema,
|
||||
AccountSchema,
|
||||
} from "../../types/account";
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
export enum ResponseType {
|
||||
None = 0,
|
||||
Thinking = 1,
|
||||
Text = 2,
|
||||
Tool = 3,
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class ClaudeStreamConverter {
|
||||
public responseIndex: number = 0;
|
||||
public currentType: ResponseType = ResponseType.None;
|
||||
public hasContent: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
processChunk(jsonChunk: any): StreamEvent[] {
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
// 0. Handle Prompt Feedback (Blocked requests)
|
||||
if (!jsonChunk.candidates && !jsonChunk.choices) {
|
||||
if (jsonChunk.promptFeedback) {
|
||||
console.warn(
|
||||
`(Anthropic) Received promptFeedback (likely blocked): ${JSON.stringify(jsonChunk.promptFeedback)}`,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const choices = jsonChunk.choices;
|
||||
if (!choices || choices.length === 0) return events;
|
||||
|
||||
const choice = choices[0];
|
||||
const delta = choice.delta;
|
||||
|
||||
const deltaContent = delta.content || delta.text || ''; // Support both content and text fields just in case
|
||||
const isThought = delta.thought === true || !!delta.thinking; // Check for thinking flag or content
|
||||
const thinkingContent = delta.thinking || '';
|
||||
const thoughtSignature = delta.thoughtSignature;
|
||||
|
||||
// If we have content of any kind
|
||||
if (deltaContent || isThought || thoughtSignature || thinkingContent) {
|
||||
this.hasContent = true;
|
||||
}
|
||||
|
||||
// 1. Handle Thinking
|
||||
// If it is explicitly marked as thought, or has thinking content
|
||||
if (isThought || thoughtSignature || thinkingContent) {
|
||||
// Close Text if open
|
||||
if (this.currentType === ResponseType.Text) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Thinking if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'thinking', thinking: '' }, // Anthropic Beta expects 'thinking' block
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Thinking;
|
||||
}
|
||||
|
||||
if (thinkingContent) {
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'thinking_delta', thinking: thinkingContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Handle Regular Text
|
||||
else if (deltaContent) {
|
||||
// Close Thinking if open
|
||||
if (this.currentType === ResponseType.Thinking) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Text if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Text;
|
||||
}
|
||||
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'text_delta', text: deltaContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Stop Reason
|
||||
if (choice.finish_reason) {
|
||||
// Close any open block
|
||||
if (this.currentType !== ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
let stopReason = 'end_turn';
|
||||
const r = choice.finish_reason;
|
||||
if (r === 'length' || r === 'MAX_TOKENS') stopReason = 'max_tokens';
|
||||
else if (r === 'tool_calls') stopReason = 'tool_use';
|
||||
// Safety mappings
|
||||
else if (r === 'SAFETY' || r === 'RECITATION') stopReason = 'end_turn'; // Fallback
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_stop', {
|
||||
type: 'message_stop',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
static createMessageStart(msgId: string, model: string): StreamEvent {
|
||||
return {
|
||||
event: 'message_start',
|
||||
data: JSON.stringify({
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: msgId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createEvent(name: string, data: any): StreamEvent {
|
||||
return {
|
||||
event: name,
|
||||
data: JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
OpenAIMessage,
|
||||
GeminiContent,
|
||||
GeminiPart,
|
||||
GeminiInlineData,
|
||||
AnthropicChatRequest,
|
||||
AnthropicMessage,
|
||||
AnthropicContent,
|
||||
ContentPart,
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Helper to extract text from content
|
||||
function getTextFromContent(content: string | ContentPart[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
return content
|
||||
.map((p) => {
|
||||
if (p.type === 'text') return p.text || '';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getPreview(content: string | ContentPart[]): string {
|
||||
const text = getTextFromContent(content);
|
||||
return text.length > 200 ? text.substring(0, 200) + '...' : text;
|
||||
}
|
||||
|
||||
export const Converter = {
|
||||
/**
|
||||
* Convert OpenAI messages to Gemini contents
|
||||
*/
|
||||
convertOpenAIToGeminiContents(messages: OpenAIMessage[]): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
const pendingImages: GeminiInlineData[] = [];
|
||||
|
||||
// Regex for markdown images: 
|
||||
// Group 1: mime, Group 2: data
|
||||
const reMarkdown =
|
||||
/!\[.*?\]\(data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)\)/g;
|
||||
// Regex for explicit data URLs
|
||||
const reDataUrl = /data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)/;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
// logger.info(`Msg[${i}][${msg.role}] content=${getPreview(msg.content)}`);
|
||||
|
||||
const role = msg.role === 'assistant' ? 'model' : msg.role === 'system' ? 'user' : msg.role;
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
// 1. Inject Pending Images from Assistant history to User
|
||||
if (role === 'user' && pendingImages.length > 0) {
|
||||
logger.info(`Injecting ${pendingImages.length} pending images to User message`);
|
||||
while (pendingImages.length > 0) {
|
||||
const img = pendingImages.shift();
|
||||
if (img) parts.push({ inlineData: img });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse Content
|
||||
if (typeof msg.content === 'string') {
|
||||
const text = msg.content;
|
||||
let lastEnd = 0;
|
||||
let match;
|
||||
|
||||
// Reset regex state
|
||||
reMarkdown.lastIndex = 0;
|
||||
|
||||
while ((match = reMarkdown.exec(text)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = reMarkdown.lastIndex;
|
||||
|
||||
if (matchStart > lastEnd) {
|
||||
const textPart = text.substring(lastEnd, matchStart);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const data = match[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
lastEnd = matchEnd;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
const textPart = text.substring(lastEnd);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Array format
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text') {
|
||||
if (part.text) parts.push({ text: part.text });
|
||||
} else if (part.type === 'image_url' && part.image_url) {
|
||||
const url = part.image_url.url;
|
||||
const validMatch = url.match(reDataUrl);
|
||||
if (validMatch) {
|
||||
const mimeType = validMatch[1];
|
||||
const data = validMatch[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Ignoring unsupported image URL format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallbacks
|
||||
if (role === 'model' && parts.length === 0 && pendingImages.length > 0) {
|
||||
// Was likely just an image in assistant history, we moved it to pending.
|
||||
// But model message cannot be empty?
|
||||
// Actually the loop above pushes for 'model' into pendingImages ONLY if it matches helper logic.
|
||||
// If the loops finishes and we have parts, good.
|
||||
// If empty and we put stuff in pendingImages, we need a placeholder?
|
||||
parts.push({ text: '[Image Generated]' });
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push({ text: '' });
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
// Merge User Messages
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
convertAnthropicToGeminiContents(
|
||||
request: AnthropicChatRequest,
|
||||
signatureMap?: Map<string, string>,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
|
||||
for (const msg of request.messages) {
|
||||
const role = msg.role === 'assistant' ? 'model' : 'user';
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
const msgContent = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: [{ type: 'text', text: msg.content } as AnthropicContent];
|
||||
|
||||
for (const content of msgContent) {
|
||||
if (content.type === 'text' && content.text) {
|
||||
parts.push({ text: content.text });
|
||||
} else if (content.type === 'image' && content.source && content.source.type === 'base64') {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: content.source.media_type,
|
||||
data: content.source.data,
|
||||
},
|
||||
});
|
||||
} else if (content.type === 'thinking') {
|
||||
// Ignore thinking input
|
||||
}
|
||||
}
|
||||
|
||||
// Thought Signature Injection (for Assistant)
|
||||
if (role === 'model' && signatureMap) {
|
||||
const latest = signatureMap.get('latest');
|
||||
if (latest) {
|
||||
parts.push({ thoughtSignature: latest });
|
||||
}
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
mergeConsecutiveUserMessages(contents: GeminiContent[]): GeminiContent[] {
|
||||
let i = 1;
|
||||
while (i < contents.length) {
|
||||
if (contents[i].role === 'user' && contents[i - 1].role === 'user') {
|
||||
const prev = contents[i - 1];
|
||||
const curr = contents[i];
|
||||
|
||||
// Add separator if needed
|
||||
const prevLast = prev.parts[prev.parts.length - 1];
|
||||
const currFirst = curr.parts[0];
|
||||
|
||||
if (prevLast && prevLast.text !== undefined && currFirst && currFirst.text !== undefined) {
|
||||
prev.parts.push({ text: '\n\n' });
|
||||
}
|
||||
|
||||
prev.parts.push(...curr.parts);
|
||||
contents.splice(i, 1);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OpenAIChatRequest, AnthropicChatRequest, GeminiContent } from './types';
|
||||
import { Converter } from './converter';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { UpstreamProxyConfig } from '../../types/config';
|
||||
|
||||
// Type definitions for fetch (since we are in Node)
|
||||
// Electron main process has 'fetch' globally available in newer versions (Node 18+),
|
||||
// but we might need to handle types.
|
||||
declare const fetch: any;
|
||||
|
||||
export class GeminiClient {
|
||||
private timeoutMs: number;
|
||||
private upstreamProxy?: UpstreamProxyConfig;
|
||||
|
||||
constructor(timeoutSeconds: number = 60, upstreamProxy?: UpstreamProxyConfig) {
|
||||
this.timeoutMs = timeoutSeconds * 1000;
|
||||
this.upstreamProxy = upstreamProxy;
|
||||
}
|
||||
|
||||
private getFetchOptions() {
|
||||
if (this.upstreamProxy?.enabled && this.upstreamProxy.url) {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(this.upstreamProxy.url),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamGenerateAnthropic(
|
||||
request: AnthropicChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
signatureMap?: Map<string, string>,
|
||||
modelMapping?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
// Returns a ReadableStream or similar
|
||||
const url =
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse';
|
||||
|
||||
const contents = Converter.convertAnthropicToGeminiContents(request, signatureMap);
|
||||
|
||||
// ... (System prompt handling remains same)
|
||||
const systemInstruction = request.system
|
||||
? {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof request.system === 'string'
|
||||
? request.system
|
||||
: JSON.stringify(request.system),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { role: 'user', parts: [{ text: '' }] };
|
||||
|
||||
const generationConfig: any = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 16384,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
if (request.model.includes('sonnet') || request.model.includes('thinking')) {
|
||||
generationConfig.thinkingConfig = {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 8191,
|
||||
};
|
||||
}
|
||||
|
||||
let upstreamModel = request.model;
|
||||
|
||||
// 1. User Custom Mapping (Fuzzy match)
|
||||
if (modelMapping) {
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (request.model.includes(key)) {
|
||||
upstreamModel = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Built-in Fallback Logic
|
||||
const lowerName = upstreamModel.toLowerCase();
|
||||
if (!upstreamModel.startsWith('gemini')) {
|
||||
// Only map if not already a gemini model (roughly)
|
||||
if (lowerName.includes('sonnet') || lowerName.includes('thinking')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('haiku')) {
|
||||
upstreamModel = 'gemini-2.0-flash-exp';
|
||||
} else if (lowerName.includes('opus')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('claude')) {
|
||||
upstreamModel = 'gemini-2.5-flash-thinking';
|
||||
} else if (lowerName === 'gemini-3-pro-high' || lowerName === 'gemini-3-pro-low') {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName === 'gemini-3-flash') {
|
||||
upstreamModel = 'gemini-3-flash-preview';
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: upstreamModel,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction,
|
||||
generationConfig,
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`(Gateway) Anthropic Request: ${request.model} -> ${upstreamModel} | Proj: ${projectId}`,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'claude-cli/1.0.83 (external, cli)',
|
||||
'X-App': 'cli',
|
||||
'Anthropic-Beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
'X-Stainless-Lang': 'js',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.body; // Node.js Readable Web Stream
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: OpenAIChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<any> {
|
||||
const url = 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent';
|
||||
|
||||
// Split System
|
||||
const systemMessages = request.messages.filter((m) => m.role === 'system');
|
||||
const chatMessages = request.messages.filter((m) => m.role !== 'system');
|
||||
|
||||
const systemText = systemMessages
|
||||
.map((m) => {
|
||||
if (typeof m.content === 'string') return m.content;
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const contents = Converter.convertOpenAIToGeminiContents(chatMessages);
|
||||
|
||||
const generationConfig = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 8096,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: request.model,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
role: 'user',
|
||||
parts: [{ text: systemText }],
|
||||
},
|
||||
generationConfig,
|
||||
toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } },
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'antigravity/1.11.3 windows/amd64',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service
|
||||
*/
|
||||
import { gatewayInstance } from './server';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Start the gateway server
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
if (gatewayInstance.isRunning()) {
|
||||
await gatewayInstance.stop();
|
||||
}
|
||||
await gatewayInstance.start(port);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
await gatewayInstance.stop();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return gatewayInstance.getStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
export const generateApiKey = async (): Promise<string> => {
|
||||
const newKey = `sk-${uuidv4().replace(/-/g, '')}`;
|
||||
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Gateway ORPC Router
|
||||
* Provides routes for controlling the API Gateway service
|
||||
*/
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { startGateway, stopGateway, getGatewayStatus, generateApiKey } from './handlers';
|
||||
|
||||
export const gatewayRouter = os.prefix('/gateway').router({
|
||||
start: os
|
||||
.input(z.object({ port: z.number().min(1024).max(65535) }))
|
||||
.handler(async ({ input }) => {
|
||||
const success = await startGateway(input.port);
|
||||
if (!success) {
|
||||
throw new Error('Failed to start gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
stop: os.handler(async () => {
|
||||
const success = await stopGateway();
|
||||
if (!success) {
|
||||
throw new Error('Failed to stop gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
status: os.handler(async () => {
|
||||
return getGatewayStatus();
|
||||
}),
|
||||
|
||||
generateKey: os.handler(async () => {
|
||||
const newKey = await generateApiKey();
|
||||
return { api_key: newKey };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import http from 'http';
|
||||
import { TokenManager } from './tokenManager';
|
||||
import { GeminiClient } from './geminiClient';
|
||||
import { ClaudeStreamConverter } from './claudeConverter';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const PORT = 3000; // Default, make configurable?
|
||||
|
||||
export class GatewayServer {
|
||||
private server: http.Server | null = null;
|
||||
private tokenManager: TokenManager;
|
||||
private client: GeminiClient;
|
||||
private signatureMap: Map<string, string> = new Map();
|
||||
private currentPort: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.tokenManager = new TokenManager();
|
||||
this.client = new GeminiClient(60);
|
||||
}
|
||||
|
||||
async start(port: number = PORT) {
|
||||
// Load config and accounts
|
||||
const config = ConfigManager.loadConfig();
|
||||
await this.tokenManager.loadAccounts();
|
||||
logger.info(`Gateway: Loaded ${await this.tokenManager.loadAccounts()} accounts`);
|
||||
|
||||
// Initialize Client with config
|
||||
// Default to 60s if not set, pass upstream proxy config
|
||||
this.client = new GeminiClient(
|
||||
config.proxy?.request_timeout || 60,
|
||||
config.proxy?.upstream_proxy,
|
||||
);
|
||||
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
|
||||
try {
|
||||
if (url.pathname === '/v1/messages' && req.method === 'POST') {
|
||||
await this.handleAnthropicMessages(req, res);
|
||||
} else if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
|
||||
// await this.handleChatCompletions(req, res);
|
||||
res.writeHead(501);
|
||||
res.end('Not Implemented Yet');
|
||||
} else if (url.pathname === '/healthz') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Gateway: Request Error', e);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: String(e) }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (e: any) => {
|
||||
if (e.code === 'EADDRINUSE') {
|
||||
logger.error(`Gateway: Port ${port} is already in use.`);
|
||||
} else {
|
||||
logger.error('Gateway: Server error', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
this.currentPort = port;
|
||||
logger.info(`Gateway: Server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAnthropicMessages(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const body = await this.readBody(req);
|
||||
const request = JSON.parse(body);
|
||||
|
||||
logger.info(`(Anthropic) Request ${request.model}`);
|
||||
|
||||
// Retry Loop
|
||||
const maxRetries = 3; // or tokenManager count
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
attempts++;
|
||||
const token = await this.tokenManager.getToken();
|
||||
|
||||
if (!token) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({ error: { type: 'overloaded_error', message: 'No accounts available' } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`(Anthropic) Using account ${token.email} (Attempt ${attempts})`);
|
||||
|
||||
try {
|
||||
const config = ConfigManager.loadConfig();
|
||||
const mapping = config.proxy?.anthropic_mapping || {};
|
||||
|
||||
const stream = await this.client.streamGenerateAnthropic(
|
||||
request,
|
||||
token.access_token,
|
||||
token.project_id || 'unknown-project',
|
||||
token.session_id,
|
||||
this.signatureMap,
|
||||
mapping,
|
||||
);
|
||||
|
||||
// Handle SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const msgId = `msg_${uuidv4()}`;
|
||||
const startEvent = ClaudeStreamConverter.createMessageStart(msgId, request.model);
|
||||
res.write(`event: ${startEvent.event}\ndata: ${startEvent.data}\n\n`);
|
||||
|
||||
const converter = new ClaudeStreamConverter();
|
||||
|
||||
// Read the stream
|
||||
// Node fetch response.body is a ReadableStream (web stream) or IncomingMessage (Node stream) depending on fetch polyfill
|
||||
// Assuming it's an async iterable or we can get a reader.
|
||||
// In Node 18+ native fetch, body is a Web stream.
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// chunk is Buffer or Uint8Array
|
||||
const text = Buffer.from(chunk).toString('utf-8');
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
// Logic from server.rs: process_chunk -> get events
|
||||
const events = converter.processChunk(json);
|
||||
for (const event of events) {
|
||||
res.write(`event: ${event.event}\ndata: ${event.data}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Gateway: Failed to parse Gemini chunk', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.end();
|
||||
return; // Success
|
||||
} catch (e: any) {
|
||||
logger.error(`(Anthropic) Request failed: ${e.message}`);
|
||||
// Check retry
|
||||
if (this.shouldRetry(e.message) && attempts < maxRetries) {
|
||||
logger.warn(`(Anthropic) Retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Final error
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { type: 'api_error', message: e.message } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetry(errorMsg: string): boolean {
|
||||
// Simple heuristic
|
||||
if (
|
||||
errorMsg.includes('429') ||
|
||||
errorMsg.includes('503') ||
|
||||
errorMsg.includes('500') ||
|
||||
errorMsg.includes('403')
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => resolve(body));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Lifecycle & Status Methods =====
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
*/
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close((err) => {
|
||||
if (err) {
|
||||
logger.error('Gateway: Failed to stop server', err);
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info('Gateway: Server stopped');
|
||||
this.server = null;
|
||||
this.currentPort = 0;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.server !== null && this.server.listening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current gateway status
|
||||
*/
|
||||
async getStatus(): Promise<{
|
||||
running: boolean;
|
||||
port: number;
|
||||
base_url: string;
|
||||
active_accounts: number;
|
||||
}> {
|
||||
const accountCount = await this.getAccountCount();
|
||||
return {
|
||||
running: this.isRunning(),
|
||||
port: this.currentPort,
|
||||
base_url: this.isRunning() ? `http://localhost:${this.currentPort}` : '',
|
||||
active_accounts: accountCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of loaded accounts
|
||||
*/
|
||||
async getAccountCount(): Promise<number> {
|
||||
return this.tokenManager.loadAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global access
|
||||
export const gatewayInstance = new GatewayServer();
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ProxyToken } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
|
||||
export class TokenManager {
|
||||
private tokens: Map<string, ProxyToken> = new Map();
|
||||
private currentIndex: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async loadAccounts(): Promise<number> {
|
||||
// Load from SQLite DB
|
||||
// Fix: Use getAccounts() instead of list()
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
let count = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const token = this.convertAccountToToken(account);
|
||||
if (token) {
|
||||
this.tokens.set(token.account_id, token);
|
||||
count++;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Gateway: Failed to load account ${account.email}`, e);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private convertAccountToToken(account: CloudAccount): ProxyToken | null {
|
||||
// Fix: account.token instead of account.token_data
|
||||
if (!account.token) return null;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
email: account.email,
|
||||
access_token: account.token.access_token,
|
||||
refresh_token: account.token.refresh_token,
|
||||
expires_in: account.token.expires_in,
|
||||
timestamp: account.token.expiry_timestamp,
|
||||
account_path: '', // Not used with DB
|
||||
project_id: account.token.project_id || undefined,
|
||||
session_id: account.token.session_id || this.generateSessionId(),
|
||||
};
|
||||
}
|
||||
|
||||
generateSessionId(): string {
|
||||
const min = 1_000_000_000_000_000_000n;
|
||||
const max = 9_000_000_000_000_000_000n;
|
||||
const range = max - min;
|
||||
const rand = BigInt(Math.floor(Math.random() * Number(range)));
|
||||
return (-(min + rand)).toString();
|
||||
}
|
||||
|
||||
async getToken(): Promise<ProxyToken | null> {
|
||||
if (this.tokens.size === 0) {
|
||||
await this.loadAccounts();
|
||||
}
|
||||
if (this.tokens.size === 0) return null;
|
||||
|
||||
const keys = Array.from(this.tokens.keys());
|
||||
this.currentIndex = (this.currentIndex + 1) % keys.length;
|
||||
const key = keys[this.currentIndex];
|
||||
const token = this.tokens.get(key);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now >= token.timestamp - 300) {
|
||||
logger.info(`Gateway: Token for ${token.email} expiring, refreshing...`);
|
||||
try {
|
||||
const newTokens = await GoogleAPIService.refreshAccessToken(token.refresh_token);
|
||||
|
||||
// Update Token Object
|
||||
token.access_token = newTokens.access_token;
|
||||
token.expires_in = newTokens.expires_in;
|
||||
token.timestamp = Math.floor(Date.now() / 1000) + newTokens.expires_in;
|
||||
|
||||
// Update DB
|
||||
await this.saveRefreshedToken(token.account_id, token);
|
||||
|
||||
this.tokens.set(token.account_id, token);
|
||||
logger.info(`Gateway: Token refreshed for ${token.email}`);
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to refresh token for ${token.email}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token.project_id) {
|
||||
try {
|
||||
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
|
||||
token.project_id = mockId;
|
||||
await this.saveProjectId(token.account_id, mockId);
|
||||
} catch (e) {
|
||||
logger.warn('Gateway: Failed to resolve project ID', e);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async saveRefreshedToken(accountId: string, token: ProxyToken) {
|
||||
try {
|
||||
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||
if (acc && acc.token) {
|
||||
// Update token fields
|
||||
const newToken = {
|
||||
...acc.token,
|
||||
access_token: token.access_token,
|
||||
expires_in: token.expires_in,
|
||||
expiry_timestamp: token.timestamp,
|
||||
};
|
||||
|
||||
// Use updateToken which expects the whole token object (or partial?)
|
||||
// CloudHandler.updateToken expects JSON object that it stringifies.
|
||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to save refreshed token to DB`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async saveProjectId(accountId: string, projectId: string) {
|
||||
try {
|
||||
// CloudAccountRepo.getAccount is async
|
||||
const acc = await CloudAccountRepo.getAccount(accountId);
|
||||
if (acc && acc.token) {
|
||||
const newToken = {
|
||||
...acc.token,
|
||||
project_id: projectId,
|
||||
};
|
||||
await CloudAccountRepo.updateToken(accountId, newToken);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Gateway: Failed to save project ID to DB`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface ProxyToken {
|
||||
account_id: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
timestamp: number;
|
||||
email: string;
|
||||
account_path: string;
|
||||
project_id?: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
// OpenAI Types
|
||||
export interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string | ContentPart[];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ContentPart {
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}
|
||||
|
||||
export interface OpenAIChatRequest {
|
||||
model: string;
|
||||
messages: OpenAIMessage[];
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
stream?: boolean;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
user?: string;
|
||||
[key: string]: any; // Extra params
|
||||
}
|
||||
|
||||
// Anthropic Types
|
||||
export interface AnthropicContent {
|
||||
type: 'text' | 'image' | 'thinking';
|
||||
text?: string; // for text or thinking
|
||||
thinking?: string;
|
||||
signature?: string;
|
||||
source?: {
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnthropicMessage {
|
||||
role: string;
|
||||
content: AnthropicContent[] | string; // Can be string in some clients
|
||||
thought_signature?: string;
|
||||
}
|
||||
|
||||
export interface AnthropicChatRequest {
|
||||
model: string;
|
||||
messages: AnthropicMessage[];
|
||||
system?: string | any[]; // System prompt
|
||||
max_tokens?: number;
|
||||
metadata?: any;
|
||||
stop_sequences?: string[];
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
}
|
||||
|
||||
// Gemini Types
|
||||
export interface GeminiInlineData {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface GeminiPart {
|
||||
text?: string;
|
||||
inlineData?: GeminiInlineData;
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
|
||||
export interface GeminiContent {
|
||||
role: string;
|
||||
parts: GeminiPart[];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RPCHandler } from '@orpc/server/message-port';
|
||||
import { router } from './router';
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Debug logging
|
||||
try {
|
||||
logger.info('Initializing rpcHandler');
|
||||
logger.info(`Router keys: ${Object.keys(router).join(', ')}`);
|
||||
if (router) {
|
||||
const proc = router.proc || (router as any)._def?.router?.proc; // Check 'proc' not 'process'
|
||||
logger.info(`Process router (proc) check: ${!!proc}`);
|
||||
try {
|
||||
if (proc) {
|
||||
// @ts-ignore
|
||||
const procKeys = proc.$handlers ? Object.keys(proc.$handlers) : Object.keys(proc);
|
||||
// Try to find handlers in _def if not direct properties
|
||||
// @ts-ignore
|
||||
const defKeys = proc._def?.router ? Object.keys(proc._def.router) : [];
|
||||
logger.info(`Process router keys (direct): ${JSON.stringify(procKeys)}`);
|
||||
logger.info(`Process router keys (_def): ${JSON.stringify(defKeys)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('Could not stringify process router keys', e);
|
||||
}
|
||||
|
||||
const db = router.database;
|
||||
logger.info(`Database router check: ${!!db}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error logging router:', e);
|
||||
}
|
||||
|
||||
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { IPC_CHANNELS } from '@/constants';
|
||||
|
||||
// Custom IPC Client that correctly builds method paths
|
||||
function createIPCClient(port: MessagePort) {
|
||||
let requestId = 0;
|
||||
const pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (v: any) => void; reject: (e: any) => void }
|
||||
>();
|
||||
|
||||
port.onmessage = (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
|
||||
const id = data.i || data.id;
|
||||
const pending = pendingRequests.get(id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(id);
|
||||
if (data.e || data.error) {
|
||||
const errorMsg = data.e?.m || data.error?.message || JSON.stringify(data.e || data.error);
|
||||
console.error('[IPCClient] Error response:', errorMsg);
|
||||
pending.reject(new Error(errorMsg));
|
||||
} else {
|
||||
// ORPC response format: { i, p: { b: { json: value }, s?: statusCode } }
|
||||
// Or error: { i, p: { s: 500, b: { json: { code, message } } } }
|
||||
let result;
|
||||
const payload = data.p;
|
||||
|
||||
// Check for error status
|
||||
if (payload?.s && payload.s >= 400) {
|
||||
const errorData = payload.b?.json;
|
||||
const errorMsg = errorData?.message || errorData?.code || 'Server error';
|
||||
console.error('[IPCClient] Server error:', errorData);
|
||||
pending.reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract successful result
|
||||
if (payload?.b?.json !== undefined) {
|
||||
result = payload.b.json;
|
||||
} else if (data.r !== undefined) {
|
||||
// Fallback to 'r' field if present
|
||||
result = data.r?.b?.json ?? data.r;
|
||||
} else if ('result' in data) {
|
||||
result = data.result;
|
||||
}
|
||||
|
||||
pending.resolve(result);
|
||||
}
|
||||
} else {
|
||||
console.warn('[IPCClient] No pending request for id:', id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPCClient] Error parsing response:', e, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
async function callMethod(path: string[], input?: unknown): Promise<any> {
|
||||
const id = String(++requestId);
|
||||
const methodPath = path.join('/');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
// ORPC MessagePort protocol format
|
||||
const payload = {
|
||||
i: id,
|
||||
p: {
|
||||
u: `orpc://localhost/${methodPath}`,
|
||||
b: { json: input ?? null },
|
||||
},
|
||||
};
|
||||
|
||||
port.postMessage(JSON.stringify(payload));
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request /${methodPath} timed out`));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a Proxy that builds the path chain
|
||||
function createProxyChain(pathSoFar: string[] = []): any {
|
||||
return new Proxy(() => {}, {
|
||||
get(_target, prop: string) {
|
||||
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
||||
// Don't intercept promise methods
|
||||
return undefined;
|
||||
}
|
||||
// Build deeper path
|
||||
return createProxyChain([...pathSoFar, prop]);
|
||||
},
|
||||
apply(_target, _thisArg, args) {
|
||||
// Called as function - execute RPC
|
||||
const input = args[0];
|
||||
return callMethod(pathSoFar, input);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createProxyChain();
|
||||
}
|
||||
|
||||
class IPCManager {
|
||||
private readonly clientPort: MessagePort;
|
||||
private readonly serverPort: MessagePort;
|
||||
|
||||
public readonly client: any; // Will be typed via usage
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
this.clientPort = port1;
|
||||
this.serverPort = port2;
|
||||
|
||||
this.client = createIPCClient(this.clientPort);
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientPort.start();
|
||||
|
||||
window.postMessage(IPC_CHANNELS.START_ORPC_SERVER, '*', [this.serverPort]);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const ipc = new IPCManager();
|
||||
ipc.initialize();
|
||||
@@ -0,0 +1,300 @@
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Checks if the Antigravity process is running.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
let command = '';
|
||||
|
||||
if (platform === 'win32' || isWsl()) {
|
||||
// Use absolute path for tasklist to be safe on Windows too
|
||||
const cmd = isWsl()
|
||||
? '/mnt/c/Windows/System32/tasklist.exe'
|
||||
: process.env.SystemRoot // Usually C:\Windows
|
||||
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
|
||||
: 'tasklist';
|
||||
|
||||
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
|
||||
} else {
|
||||
// macOS and Linux (native)
|
||||
command = 'pgrep -x Antigravity';
|
||||
}
|
||||
|
||||
logger.debug(`Checking process with command: ${command}`);
|
||||
// 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 {
|
||||
// pgrep returns PIDs if found, empty if not
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
} catch (cmdError) {
|
||||
if (platform === 'win32' && !isWsl()) {
|
||||
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
|
||||
// Fallback to wmic (filtered)
|
||||
try {
|
||||
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
|
||||
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
|
||||
return stdout.includes('ProcessId');
|
||||
} catch (wmicError) {
|
||||
logger.error('wmic fallback also failed', wmicError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
throw cmdError;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking process status:', error);
|
||||
// pgrep returns exit code 1 if no process found
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Antigravity process.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
*/
|
||||
export async function closeAntigravity(): Promise<void> {
|
||||
logger.info('Closing Antigravity...');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
// Stage 1: Graceful Shutdown (Platform specific)
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use AppleScript to quit gracefully
|
||||
try {
|
||||
logger.info('Attempting graceful exit via AppleScript...');
|
||||
execSync('osascript -e \'tell application "Antigravity" to quit\'', {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
// Wait for a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
} catch {
|
||||
logger.warn('AppleScript exit failed, proceeding to next stage');
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use taskkill /IM (without /F) for graceful close
|
||||
try {
|
||||
logger.info('Attempting graceful exit via taskkill...');
|
||||
// /T = Tree (child processes), /IM = Image Name
|
||||
// We do not wait long here.
|
||||
execSync('taskkill /IM "Antigravity.exe" /T', {
|
||||
stdio: 'ignore',
|
||||
timeout: 2000,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch {
|
||||
// Ignore failure, we play hard next.
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2 & 3: Find and Kill remaining processes
|
||||
// We use a more aggressive approach here but try to avoid killing ourselves
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Helper to list processes
|
||||
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
|
||||
try {
|
||||
let cmd = '';
|
||||
if (platform === 'win32') {
|
||||
// Use wmic to get process info
|
||||
cmd = 'wmic process get ProcessId,Name,CommandLine /FORMAT:CSV';
|
||||
} else {
|
||||
// Unix/Linux/macOS
|
||||
cmd = 'ps -A -o pid,comm,args';
|
||||
}
|
||||
|
||||
const output = execSync(cmd, { encoding: 'utf-8' });
|
||||
const lines = output.split('\n');
|
||||
const processList = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
if (platform === 'win32') {
|
||||
// CSV format parsing would be better, but simple check for now
|
||||
if (line.includes('Antigravity')) {
|
||||
// Extract PID (usually last or second to last in CSV)
|
||||
const match = line.match(/(\d+)(?:\s*$)/);
|
||||
if (match) {
|
||||
processList.push({ pid: parseInt(match[1]), name: 'Antigravity', cmd: line });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
if (rest.includes('Antigravity') || rest.includes('antigravity')) {
|
||||
processList.push({ pid, name: parts[1], cmd: rest });
|
||||
}
|
||||
}
|
||||
}
|
||||
return processList;
|
||||
} catch (e) {
|
||||
logger.error('Failed to list processes', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const targetProcessList = getProcesses().filter((p) => {
|
||||
// Exclude self
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Antigravity Manager)
|
||||
if (p.cmd.includes('Antigravity Manager')) {
|
||||
return false;
|
||||
}
|
||||
// Match Antigravity
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('Antigravity.exe') ||
|
||||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity');
|
||||
}
|
||||
});
|
||||
|
||||
if (targetProcessList.length === 0) {
|
||||
logger.info('No Antigravity processes found running.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${targetProcessList.length} remaining Antigravity processes. Killing...`);
|
||||
|
||||
for (const p of targetProcessList) {
|
||||
try {
|
||||
process.kill(p.pid, 'SIGKILL'); // Force kill as final step
|
||||
} catch {
|
||||
// Ignore if already dead
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Antigravity', error);
|
||||
// Fallback to simple kill if everything fails
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
execSync('taskkill /F /IM "Antigravity.exe" /T', { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync('pkill -9 -f Antigravity', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Antigravity process to exit.
|
||||
* @param timeoutMs {number} The timeout in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (!(await isProcessRunning())) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error('Process did not exit within timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a URI protocol.
|
||||
* @param uri {string} The URI to open.
|
||||
* @returns {Promise<boolean>} True if the URI was opened successfully, false otherwise.
|
||||
*/
|
||||
async function openUri(uri: string): Promise<boolean> {
|
||||
const platform = process.platform;
|
||||
const wsl = isWsl();
|
||||
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
// macOS: use open command
|
||||
await execAsync(`open "${uri}"`);
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: use start command
|
||||
await execAsync(`start "" "${uri}"`);
|
||||
} else if (wsl) {
|
||||
// WSL: use cmd.exe to open URI
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${uri}"`);
|
||||
} else {
|
||||
// Linux: use xdg-open
|
||||
await execAsync(`xdg-open "${uri}"`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to open URI', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Antigravity process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startAntigravity(useUri = true): Promise<void> {
|
||||
logger.info('Starting Antigravity...');
|
||||
|
||||
if (await isProcessRunning()) {
|
||||
logger.info('Antigravity is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUri) {
|
||||
logger.info('Using URI protocol to start...');
|
||||
const uri = 'antigravity://oauth-success';
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Antigravity URI launch command sent');
|
||||
return;
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to executable path
|
||||
logger.info('Using executable path to start...');
|
||||
const execPath = getAntigravityExecutablePath();
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Antigravity`);
|
||||
} else if (process.platform === 'win32') {
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
} else if (isWsl()) {
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
} else {
|
||||
// Linux native
|
||||
const child = exec(`"${execPath}"`);
|
||||
child.unref();
|
||||
}
|
||||
logger.info('Antigravity launch command sent');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Antigravity via executable', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { app } from './app';
|
||||
import { theme } from './theme';
|
||||
import { window } from './window';
|
||||
import { databaseRouter } from './database/router';
|
||||
import { accountRouter } from './account/router';
|
||||
import { cloudRouter } from './cloud/router';
|
||||
import { configRouter } from './config/router';
|
||||
import { gatewayRouter } from './gateway/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
||||
|
||||
// Log middleware setup
|
||||
const logMiddleware = os.middleware(async (opts: any) => {
|
||||
const { next, path, meta } = opts;
|
||||
const requestPath = path || meta?.path || 'unknown';
|
||||
|
||||
try {
|
||||
const result = await next({});
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit Router Definition
|
||||
export const router = os.use(logMiddleware).router({
|
||||
ping: os.output(z.string()).handler(async () => 'pong'),
|
||||
|
||||
theme,
|
||||
window,
|
||||
app,
|
||||
database: databaseRouter,
|
||||
|
||||
// Inline process router to ensure structure
|
||||
proc: os.router({
|
||||
isProcessRunning: os.output(z.boolean()).handler(async () => {
|
||||
return await isProcessRunning();
|
||||
}),
|
||||
closeAntigravity: os.output(z.void()).handler(async () => {
|
||||
await closeAntigravity();
|
||||
}),
|
||||
startAntigravity: os.output(z.void()).handler(async () => {
|
||||
await startAntigravity();
|
||||
}),
|
||||
}),
|
||||
|
||||
account: accountRouter,
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { nativeTheme } from "electron";
|
||||
import { setThemeModeInputSchema } from "./schemas";
|
||||
|
||||
export const getCurrentThemeMode = os.handler(() => {
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
|
||||
export const toggleThemeMode = os.handler(() => {
|
||||
if (nativeTheme.shouldUseDarkColors) {
|
||||
nativeTheme.themeSource = "light";
|
||||
} else {
|
||||
nativeTheme.themeSource = "dark";
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
export const setThemeMode = os
|
||||
.input(setThemeModeInputSchema)
|
||||
.handler(({ input: mode }) => {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
nativeTheme.themeSource = "light";
|
||||
break;
|
||||
case "dark":
|
||||
nativeTheme.themeSource = "dark";
|
||||
break;
|
||||
case "system":
|
||||
nativeTheme.themeSource = "system";
|
||||
break;
|
||||
}
|
||||
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getCurrentThemeMode, setThemeMode, toggleThemeMode } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const theme = os.router({
|
||||
getCurrentThemeMode,
|
||||
setThemeMode,
|
||||
toggleThemeMode,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]);
|
||||
@@ -0,0 +1,160 @@
|
||||
import { app, Tray, Menu, nativeImage, BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getTrayTexts } from './i18n';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let globalMainWindow: BrowserWindow | null = null;
|
||||
let lastAccount: CloudAccount | null = null;
|
||||
let lastLanguage: string = 'en';
|
||||
|
||||
function getQuotaText(account: CloudAccount | null, texts: any): string[] {
|
||||
if (!account) return [`${texts.quota}: --`];
|
||||
if (!account.quota || !account.quota.models) return [`${texts.quota}: ${texts.unknown_quota}`];
|
||||
|
||||
const lines: string[] = [];
|
||||
const models = account.quota.models;
|
||||
|
||||
let gHigh = 0;
|
||||
let gImage = 0;
|
||||
let claude = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(models)) {
|
||||
const k = key.toLowerCase();
|
||||
if (k.includes('high')) gHigh = val.percentage;
|
||||
else if (k.includes('image')) gImage = val.percentage;
|
||||
else if (k.includes('claude')) claude = val.percentage;
|
||||
}
|
||||
|
||||
lines.push(`Gemini High: ${gHigh}%`);
|
||||
lines.push(`Gemini Image: ${gImage}%`);
|
||||
lines.push(`Claude 4.5: ${claude}%`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function initTray(mainWindow: BrowserWindow) {
|
||||
globalMainWindow = mainWindow;
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
const iconPath = inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/tray.png')
|
||||
: path.join(process.resourcesPath, 'assets/tray.png');
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Antigravity Manager');
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (globalMainWindow) {
|
||||
if (globalMainWindow.isVisible()) {
|
||||
globalMainWindow.hide();
|
||||
} else {
|
||||
globalMainWindow.show();
|
||||
globalMainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateTrayMenu(null);
|
||||
}
|
||||
|
||||
export function updateTrayMenu(account: CloudAccount | null, language?: string) {
|
||||
lastAccount = account;
|
||||
if (language) {
|
||||
lastLanguage = language;
|
||||
}
|
||||
|
||||
if (!tray || !globalMainWindow) return;
|
||||
|
||||
const texts = getTrayTexts(lastLanguage);
|
||||
const quotaLines = getQuotaText(account, texts);
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: account
|
||||
? `${texts.current}: ${account.email}`
|
||||
: `${texts.current}: ${texts.no_account}`,
|
||||
enabled: false,
|
||||
},
|
||||
...quotaLines.map((line) => ({ label: line, enabled: false })),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.switch_next,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
let nextIndex = 0;
|
||||
if (current) {
|
||||
const idx = accounts.findIndex((a) => a.id === current.id);
|
||||
nextIndex = (idx + 1) % accounts.length;
|
||||
}
|
||||
const next = accounts[nextIndex];
|
||||
|
||||
CloudAccountRepo.setActive(next.id);
|
||||
logger.info(`Tray: Switched to account ${next.email}`);
|
||||
|
||||
updateTrayMenu(next, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://account-switched', next.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Switch account failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: texts.refresh_current,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
if (!current) return;
|
||||
|
||||
logger.info(`Tray: Refreshing quota for ${current.email}`);
|
||||
|
||||
const quota = await GoogleAPIService.fetchQuota(current.token.access_token);
|
||||
await CloudAccountRepo.updateQuota(current.id, quota);
|
||||
|
||||
// Reload account to get updated obj
|
||||
const updated = await CloudAccountRepo.getAccount(current.id);
|
||||
if (updated) updateTrayMenu(updated, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://refresh-current');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Refresh quota failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.show_window,
|
||||
click: () => {
|
||||
globalMainWindow?.show();
|
||||
globalMainWindow?.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.quit,
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
tray.setContextMenu(menu);
|
||||
}
|
||||
|
||||
export function setTrayLanguage(lang: string) {
|
||||
updateTrayMenu(lastAccount, lang);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type TrayTexts = {
|
||||
current: string;
|
||||
quota: string;
|
||||
switch_next: string;
|
||||
refresh_current: string;
|
||||
show_window: string;
|
||||
quit: string;
|
||||
no_account: string;
|
||||
unknown_quota: string;
|
||||
forbidden: string;
|
||||
};
|
||||
|
||||
const en: TrayTexts = {
|
||||
current: 'Current',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Switch to Next Account',
|
||||
refresh_current: 'Refresh Current Quota',
|
||||
show_window: 'Show Main Window',
|
||||
quit: 'Quit Application',
|
||||
no_account: 'No Account',
|
||||
unknown_quota: 'Unknown',
|
||||
forbidden: 'Account Forbidden',
|
||||
};
|
||||
|
||||
const zh: TrayTexts = {
|
||||
current: '当前账号',
|
||||
quota: '当前额度',
|
||||
switch_next: '切换下一个账号',
|
||||
refresh_current: '刷新当前额度',
|
||||
show_window: '显示主窗口',
|
||||
quit: '退出程序',
|
||||
no_account: '无账号',
|
||||
unknown_quota: '未知',
|
||||
forbidden: '账号无权限',
|
||||
};
|
||||
|
||||
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
||||
if (lang.startsWith('zh')) {
|
||||
return zh;
|
||||
}
|
||||
return en;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { ipcContext } from "../context";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
export const minimizeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
window.minimize();
|
||||
});
|
||||
|
||||
export const maximizeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
export const closeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
logger.info('IPC: closeWindow called');
|
||||
const { window } = context;
|
||||
|
||||
window.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const window = os.router({
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
closeWindow,
|
||||
});
|
||||
Reference in new issue
Block a user