style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings

This commit is contained in:
Gemini Nexus Bot committed 2026-04-28 03:49:34 -03:00
1 parent b80531f8a8
commit bac7b5591e
738 files changed
+138513 -138123

No files matched your search

+483 -483
View File
@@ -1,483 +1,483 @@
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,
DeviceProfile,
DeviceProfilesSnapshot,
DeviceProfileVersion,
} from '../../types/account';
import {
backupAccount as dbBackup,
restoreAccount as dbRestore,
getCurrentAccountInfo,
} from '../database/handler';
import {
applyDeviceProfile,
ensureGlobalOriginalFromCurrentStorage,
generateDeviceProfile,
isIdentityProfileApplyEnabled,
loadGlobalOriginalProfile,
readCurrentDeviceProfile,
saveGlobalOriginalProfile,
getStorageDirectoryPath,
} from '../device/handler';
import { runWithSwitchGuard } from '../switchGuard';
import { executeSwitchFlow } from '../switchFlow';
import { shell } from 'electron';
type AccountIndex = Record<string, Account>;
const SWITCH_EXIT_TIMEOUT_MS = 10000;
function getDeviceHistory(account: Account): DeviceProfileVersion[] {
if (!account.deviceHistory) {
account.deviceHistory = [];
}
return account.deviceHistory;
}
function bindDeviceProfileToAccount(
account: Account,
profile: DeviceProfile,
label: string,
addHistory: boolean,
): void {
account.deviceProfile = profile;
if (!addHistory) {
return;
}
const history = getDeviceHistory(account);
for (const version of history) {
version.isCurrent = false;
}
history.push({
id: uuidv4(),
createdAt: Math.floor(Date.now() / 1000),
label,
profile,
isCurrent: true,
});
}
/**
* 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 Gemini Nexus 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,
deviceHistory: [],
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> {
await runWithSwitchGuard('local-account-switch', async () => {
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}`);
}
ensureGlobalOriginalFromCurrentStorage();
if (!account.deviceProfile) {
const generated = generateDeviceProfile();
saveGlobalOriginalProfile(generated);
bindDeviceProfileToAccount(account, generated, 'auto_generated', true);
}
await executeSwitchFlow({
scope: 'local',
targetProfile: account.deviceProfile || null,
applyFingerprint: isIdentityProfileApplyEnabled(),
processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS,
performSwitch: async () => {
// 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);
},
});
});
}
export async function previewGenerateIdentityProfile(): Promise<DeviceProfile> {
return generateDeviceProfile();
}
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let currentStorage: DeviceProfile | undefined;
try {
currentStorage = readCurrentDeviceProfile();
} catch (error) {
logger.warn('Failed to read current storage device profile', error);
}
return {
currentStorage,
boundProfile: account.deviceProfile,
history: account.deviceHistory || [],
baseline: loadGlobalOriginalProfile() || undefined,
};
}
export async function bindIdentityProfile(
accountId: string,
mode: 'capture' | 'generate',
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let profile: DeviceProfile;
if (mode === 'capture') {
profile = readCurrentDeviceProfile();
} else {
profile = generateDeviceProfile();
}
ensureGlobalOriginalFromCurrentStorage();
saveGlobalOriginalProfile(profile);
applyDeviceProfile(profile);
bindDeviceProfileToAccount(account, profile, mode, true);
saveAccountsIndex(accounts);
return profile;
}
export async function bindIdentityProfileWithPayload(
accountId: string,
profile: DeviceProfile,
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
ensureGlobalOriginalFromCurrentStorage();
saveGlobalOriginalProfile(profile);
applyDeviceProfile(profile);
bindDeviceProfileToAccount(account, profile, 'generated', true);
saveAccountsIndex(accounts);
return profile;
}
export async function applyBoundIdentityProfile(accountId: string): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
if (!account.deviceProfile) {
throw new Error('Account has no bound device profile');
}
applyDeviceProfile(account.deviceProfile);
account.last_used = new Date().toISOString();
saveAccountsIndex(accounts);
return account.deviceProfile;
}
export async function restoreIdentityProfileRevision(
accountId: string,
versionId: string,
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let targetProfile: DeviceProfile | null = null;
if (versionId === 'baseline') {
targetProfile = loadGlobalOriginalProfile();
if (!targetProfile) {
throw new Error('Global original profile not found');
}
for (const version of getDeviceHistory(account)) {
version.isCurrent = false;
}
} else if (versionId === 'current') {
targetProfile = account.deviceProfile || null;
if (!targetProfile) {
throw new Error('No currently bound profile');
}
} else {
const history = getDeviceHistory(account);
const targetVersion = history.find((version) => version.id === versionId);
if (!targetVersion) {
throw new Error('Device profile version not found');
}
targetProfile = targetVersion.profile;
for (const version of history) {
version.isCurrent = version.id === versionId;
}
}
applyDeviceProfile(targetProfile);
account.deviceProfile = targetProfile;
saveAccountsIndex(accounts);
return targetProfile;
}
export async function deleteIdentityProfileRevision(
accountId: string,
versionId: string,
): Promise<void> {
if (versionId === 'baseline') {
throw new Error('Original profile cannot be deleted');
}
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const history = getDeviceHistory(account);
if (history.some((version) => version.id === versionId && version.isCurrent)) {
throw new Error('Currently bound profile cannot be deleted');
}
const before = history.length;
account.deviceHistory = history.filter((version) => version.id !== versionId);
if (account.deviceHistory.length === before) {
throw new Error('Historical device profile not found');
}
saveAccountsIndex(accounts);
}
export async function restoreBaselineProfile(accountId: string): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const baseline = loadGlobalOriginalProfile();
if (!baseline) {
throw new Error('Global original profile not found');
}
account.deviceProfile = baseline;
for (const version of getDeviceHistory(account)) {
version.isCurrent = false;
}
saveAccountsIndex(accounts);
return baseline;
}
export async function openIdentityStorageFolder(): Promise<void> {
const directory = getStorageDirectoryPath();
const result = await shell.openPath(directory);
if (result) {
throw new Error(`Failed to open device folder: ${result}`);
}
}
/**
* 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);
}
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,
DeviceProfile,
DeviceProfilesSnapshot,
DeviceProfileVersion,
} from '../../types/account';
import {
backupAccount as dbBackup,
restoreAccount as dbRestore,
getCurrentAccountInfo,
} from '../database/handler';
import {
applyDeviceProfile,
ensureGlobalOriginalFromCurrentStorage,
generateDeviceProfile,
isIdentityProfileApplyEnabled,
loadGlobalOriginalProfile,
readCurrentDeviceProfile,
saveGlobalOriginalProfile,
getStorageDirectoryPath,
} from '../device/handler';
import { runWithSwitchGuard } from '../switchGuard';
import { executeSwitchFlow } from '../switchFlow';
import { shell } from 'electron';
type AccountIndex = Record<string, Account>;
const SWITCH_EXIT_TIMEOUT_MS = 10000;
function getDeviceHistory(account: Account): DeviceProfileVersion[] {
if (!account.deviceHistory) {
account.deviceHistory = [];
}
return account.deviceHistory;
}
function bindDeviceProfileToAccount(
account: Account,
profile: DeviceProfile,
label: string,
addHistory: boolean,
): void {
account.deviceProfile = profile;
if (!addHistory) {
return;
}
const history = getDeviceHistory(account);
for (const version of history) {
version.isCurrent = false;
}
history.push({
id: uuidv4(),
createdAt: Math.floor(Date.now() / 1000),
label,
profile,
isCurrent: true,
});
}
/**
* 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 Gemini Nexus 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,
deviceHistory: [],
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> {
await runWithSwitchGuard('local-account-switch', async () => {
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}`);
}
ensureGlobalOriginalFromCurrentStorage();
if (!account.deviceProfile) {
const generated = generateDeviceProfile();
saveGlobalOriginalProfile(generated);
bindDeviceProfileToAccount(account, generated, 'auto_generated', true);
}
await executeSwitchFlow({
scope: 'local',
targetProfile: account.deviceProfile || null,
applyFingerprint: isIdentityProfileApplyEnabled(),
processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS,
performSwitch: async () => {
// 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);
},
});
});
}
export async function previewGenerateIdentityProfile(): Promise<DeviceProfile> {
return generateDeviceProfile();
}
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let currentStorage: DeviceProfile | undefined;
try {
currentStorage = readCurrentDeviceProfile();
} catch (error) {
logger.warn('Failed to read current storage device profile', error);
}
return {
currentStorage,
boundProfile: account.deviceProfile,
history: account.deviceHistory || [],
baseline: loadGlobalOriginalProfile() || undefined,
};
}
export async function bindIdentityProfile(
accountId: string,
mode: 'capture' | 'generate',
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let profile: DeviceProfile;
if (mode === 'capture') {
profile = readCurrentDeviceProfile();
} else {
profile = generateDeviceProfile();
}
ensureGlobalOriginalFromCurrentStorage();
saveGlobalOriginalProfile(profile);
applyDeviceProfile(profile);
bindDeviceProfileToAccount(account, profile, mode, true);
saveAccountsIndex(accounts);
return profile;
}
export async function bindIdentityProfileWithPayload(
accountId: string,
profile: DeviceProfile,
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
ensureGlobalOriginalFromCurrentStorage();
saveGlobalOriginalProfile(profile);
applyDeviceProfile(profile);
bindDeviceProfileToAccount(account, profile, 'generated', true);
saveAccountsIndex(accounts);
return profile;
}
export async function applyBoundIdentityProfile(accountId: string): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
if (!account.deviceProfile) {
throw new Error('Account has no bound device profile');
}
applyDeviceProfile(account.deviceProfile);
account.last_used = new Date().toISOString();
saveAccountsIndex(accounts);
return account.deviceProfile;
}
export async function restoreIdentityProfileRevision(
accountId: string,
versionId: string,
): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
let targetProfile: DeviceProfile | null = null;
if (versionId === 'baseline') {
targetProfile = loadGlobalOriginalProfile();
if (!targetProfile) {
throw new Error('Global original profile not found');
}
for (const version of getDeviceHistory(account)) {
version.isCurrent = false;
}
} else if (versionId === 'current') {
targetProfile = account.deviceProfile || null;
if (!targetProfile) {
throw new Error('No currently bound profile');
}
} else {
const history = getDeviceHistory(account);
const targetVersion = history.find((version) => version.id === versionId);
if (!targetVersion) {
throw new Error('Device profile version not found');
}
targetProfile = targetVersion.profile;
for (const version of history) {
version.isCurrent = version.id === versionId;
}
}
applyDeviceProfile(targetProfile);
account.deviceProfile = targetProfile;
saveAccountsIndex(accounts);
return targetProfile;
}
export async function deleteIdentityProfileRevision(
accountId: string,
versionId: string,
): Promise<void> {
if (versionId === 'baseline') {
throw new Error('Original profile cannot be deleted');
}
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const history = getDeviceHistory(account);
if (history.some((version) => version.id === versionId && version.isCurrent)) {
throw new Error('Currently bound profile cannot be deleted');
}
const before = history.length;
account.deviceHistory = history.filter((version) => version.id !== versionId);
if (account.deviceHistory.length === before) {
throw new Error('Historical device profile not found');
}
saveAccountsIndex(accounts);
}
export async function restoreBaselineProfile(accountId: string): Promise<DeviceProfile> {
const accounts = loadAccountsIndex();
const account = accounts[accountId];
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
const baseline = loadGlobalOriginalProfile();
if (!baseline) {
throw new Error('Global original profile not found');
}
account.deviceProfile = baseline;
for (const version of getDeviceHistory(account)) {
version.isCurrent = false;
}
saveAccountsIndex(accounts);
return baseline;
}
export async function openIdentityStorageFolder(): Promise<void> {
const directory = getStorageDirectoryPath();
const result = await shell.openPath(directory);
if (result) {
throw new Error(`Failed to open device folder: ${result}`);
}
}
/**
* 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);
}
+103 -103
View File
@@ -1,103 +1,103 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import {
applyBoundIdentityProfile,
addAccountSnapshot,
bindIdentityProfile,
bindIdentityProfileWithPayload,
deleteAccount,
deleteIdentityProfileRevision,
getIdentityProfiles,
listAccountsData,
previewGenerateIdentityProfile,
restoreBaselineProfile,
restoreIdentityProfileRevision,
switchAccount,
openIdentityStorageFolder,
} from './handler';
import {
AccountSchema,
DeviceProfileSchema,
DeviceProfilesSnapshotSchema,
} 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);
}),
previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
return previewGenerateIdentityProfile();
}),
getIdentityProfiles: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfilesSnapshotSchema)
.handler(async ({ input }) => {
return getIdentityProfiles(input.accountId);
}),
bindIdentityProfile: os
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindIdentityProfile(input.accountId, input.mode);
}),
bindIdentityProfileWithPayload: os
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindIdentityProfileWithPayload(input.accountId, input.profile);
}),
applyBoundIdentityProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return applyBoundIdentityProfile(input.accountId);
}),
restoreIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreIdentityProfileRevision(input.accountId, input.versionId);
}),
deleteIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
await deleteIdentityProfileRevision(input.accountId, input.versionId);
}),
restoreBaselineProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreBaselineProfile(input.accountId);
}),
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
await openIdentityStorageFolder();
}),
});
import { z } from 'zod';
import { os } from '@orpc/server';
import {
applyBoundIdentityProfile,
addAccountSnapshot,
bindIdentityProfile,
bindIdentityProfileWithPayload,
deleteAccount,
deleteIdentityProfileRevision,
getIdentityProfiles,
listAccountsData,
previewGenerateIdentityProfile,
restoreBaselineProfile,
restoreIdentityProfileRevision,
switchAccount,
openIdentityStorageFolder,
} from './handler';
import {
AccountSchema,
DeviceProfileSchema,
DeviceProfilesSnapshotSchema,
} 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);
}),
previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
return previewGenerateIdentityProfile();
}),
getIdentityProfiles: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfilesSnapshotSchema)
.handler(async ({ input }) => {
return getIdentityProfiles(input.accountId);
}),
bindIdentityProfile: os
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindIdentityProfile(input.accountId, input.mode);
}),
bindIdentityProfileWithPayload: os
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindIdentityProfileWithPayload(input.accountId, input.profile);
}),
applyBoundIdentityProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return applyBoundIdentityProfile(input.accountId);
}),
restoreIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreIdentityProfileRevision(input.accountId, input.versionId);
}),
deleteIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
await deleteIdentityProfileRevision(input.accountId, input.versionId);
}),
restoreBaselineProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreBaselineProfile(input.accountId);
}),
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
await openIdentityStorageFolder();
}),
});
+10 -10
View File
@@ -1,10 +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();
});
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();
});
+7 -7
View File
@@ -1,7 +1,7 @@
import { appVersion, currentPlatfom } from './handlers';
import { os } from '@orpc/server';
export const app = os.router({
currentPlatfom,
appVersion,
});
import { appVersion, currentPlatfom } from './handlers';
import { os } from '@orpc/server';
export const app = os.router({
currentPlatfom,
appVersion,
});
+136 -136
View File
@@ -1,136 +1,136 @@
import http from 'http';
import { logger } from '../../utils/logger';
import { ipcContext } from '../context';
import { escapeHtml } from '../../utils/url';
export class AuthServer {
private static server: http.Server | null = null;
private static PORT = 8888;
static async start() {
if (this.server) {
logger.warn('AuthServer: Server already running');
return;
}
const tryPorts = [8888, 8889, 8890, 8891, 8892];
let boundPort: number | null = null;
for (const port of tryPorts) {
try {
await new Promise<void>((resolve, reject) => {
const testServer = http.createServer();
testServer.once('error', reject);
testServer.listen(port, '127.0.0.1', () => {
testServer.close(() => resolve());
});
});
boundPort = port;
break;
} catch {
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
}
}
if (!boundPort) {
logger.error('AuthServer: No available ports found for OAuth callback server');
return;
}
if (boundPort !== 8888) {
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
}
this.PORT = boundPort;
try {
this.server = http.createServer((req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { Allow: 'GET' });
res.end('Method Not Allowed');
return;
}
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) {
const escapedCode = escapeHtml(code);
logger.info(
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
);
// Send code to renderer
if (ipcContext.mainWindow) {
logger.info('AuthServer: Sending code to renderer via IPC');
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
logger.info('AuthServer: Code sent successfully');
} else {
logger.error('AuthServer: Main window not found, cannot send code');
}
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 Gemini Nexus.</p>
<script>
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>
`);
} else if (error) {
const escapedError = escapeHtml(error);
logger.error(`AuthServer: OAuth error: ${escapedError}`);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<body>
<h1>Login Failed</h1>
<p>Error: ${escapedError}</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, '127.0.0.1', () => {
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
});
} catch (e) {
logger.error('AuthServer: Failed to create or start server', e);
if (this.server) {
this.server.close();
this.server = null;
}
}
}
static getRedirectUri(): string {
return `http://localhost:${this.PORT}/oauth-callback`;
}
static stop() {
if (this.server) {
this.server.close();
this.server = null;
logger.info('AuthServer: Stopped');
}
}
}
import http from 'http';
import { logger } from '../../utils/logger';
import { ipcContext } from '../context';
import { escapeHtml } from '../../utils/url';
export class AuthServer {
private static server: http.Server | null = null;
private static PORT = 8888;
static async start() {
if (this.server) {
logger.warn('AuthServer: Server already running');
return;
}
const tryPorts = [8888, 8889, 8890, 8891, 8892];
let boundPort: number | null = null;
for (const port of tryPorts) {
try {
await new Promise<void>((resolve, reject) => {
const testServer = http.createServer();
testServer.once('error', reject);
testServer.listen(port, '127.0.0.1', () => {
testServer.close(() => resolve());
});
});
boundPort = port;
break;
} catch {
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
}
}
if (!boundPort) {
logger.error('AuthServer: No available ports found for OAuth callback server');
return;
}
if (boundPort !== 8888) {
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
}
this.PORT = boundPort;
try {
this.server = http.createServer((req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { Allow: 'GET' });
res.end('Method Not Allowed');
return;
}
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) {
const escapedCode = escapeHtml(code);
logger.info(
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
);
// Send code to renderer
if (ipcContext.mainWindow) {
logger.info('AuthServer: Sending code to renderer via IPC');
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
logger.info('AuthServer: Code sent successfully');
} else {
logger.error('AuthServer: Main window not found, cannot send code');
}
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 Gemini Nexus.</p>
<script>
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>
`);
} else if (error) {
const escapedError = escapeHtml(error);
logger.error(`AuthServer: OAuth error: ${escapedError}`);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<body>
<h1>Login Failed</h1>
<p>Error: ${escapedError}</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, '127.0.0.1', () => {
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
});
} catch (e) {
logger.error('AuthServer: Failed to create or start server', e);
if (this.server) {
this.server.close();
this.server = null;
}
}
}
static getRedirectUri(): string {
return `http://localhost:${this.PORT}/oauth-callback`;
}
static stop() {
if (this.server) {
this.server.close();
this.server = null;
logger.info('AuthServer: Stopped');
}
}
}
+843 -843
View File
File diff suppressed because it is too large. Load diff
+305 -305
View File
@@ -1,305 +1,305 @@
import { z } from 'zod';
import { os, ORPCError } from '@orpc/server';
import {
addGoogleAccount,
bindCloudIdentityProfile,
bindCloudIdentityProfileWithPayload,
deleteCloudIdentityProfileRevision,
listCloudAccounts,
deleteCloudAccount,
getCloudIdentityProfiles,
openCloudIdentityStorageFolder,
previewGenerateCloudIdentityProfile,
refreshAccountQuota,
restoreCloudIdentityProfileRevision,
restoreCloudBaselineProfile,
switchCloudAccount,
getAutoSwitchEnabled,
setAutoSwitchEnabled,
forcePollCloudMonitor,
startAuthFlow,
listOAuthClients,
getActiveOAuthClient,
setActiveOAuthClient,
exportCloudAccounts,
importCloudAccounts,
} from './handler';
import { CloudAccountRepo } from '../database/cloudHandler';
import { CloudAccountSchema } from '../../types/cloudAccount';
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
import { logger } from '../../utils/logger';
import { getSwitchMetricsSnapshot } from '../switchMetrics';
import { getSwitchGuardSnapshot } from '../switchGuard';
import { getDeviceHardeningSnapshot } from '../device/handler';
const switchOwnerSchema = z.enum(['local-account-switch', 'cloud-account-switch']);
const switchMetricBucketSchema = z.object({
switchSuccess: z.number(),
switchFailure: z.number(),
rollbackAttempt: z.number(),
rollbackSuccess: z.number(),
rollbackFailure: z.number(),
failureReasons: z.record(z.string(), z.number()),
lastFailure: z
.object({
reason: z.string(),
message: z.string(),
occurredAt: z.number(),
})
.nullable(),
});
const switchMetricsSnapshotSchema = z.object({
local: switchMetricBucketSchema,
cloud: switchMetricBucketSchema,
});
const switchGuardSnapshotSchema = z.object({
activeOwner: switchOwnerSchema.nullable(),
pendingOwners: z.array(switchOwnerSchema),
pendingCount: z.number(),
});
const switchStatusSnapshotSchema = z.object({
metrics: switchMetricsSnapshotSchema,
guard: switchGuardSnapshotSchema,
hardening: z.object({
consecutiveApplyFailures: z.number(),
safeModeActive: z.boolean(),
safeModeUntil: z.number().nullable(),
lastFailureReason: z.string().nullable(),
lastFailureStage: z.string().nullable(),
lastFailureAt: z.number().nullable(),
}),
});
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
const message = extractErrorMessage(error);
const normalized = message.toLowerCase();
if (
normalized.includes('unauthenticated') ||
normalized.includes('unauthorized') ||
normalized.includes('token may be expired') ||
normalized.includes('re-login in geminiNexus ide')
) {
return new ORPCError('UNAUTHORIZED', { message });
}
if (
normalized.includes('no cloud account found in ide') ||
normalized.includes('no oauth token found in ide state') ||
normalized.includes('geminiNexus database not found')
) {
return new ORPCError('BAD_REQUEST', { message });
}
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
}
export const cloudRouter = os.router({
addGoogleAccount: os
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
.output(CloudAccountSchema)
.handler(async ({ input }) => {
return addGoogleAccount(input.authCode, input.oauthClientKey);
}),
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
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
.output(z.void())
.handler(async ({ input }) => {
await startAuthFlow(input?.oauthClientKey);
}),
listOAuthClients: os
.output(
z.array(
z.object({
key: z.string(),
label: z.string(),
client_id: z.string(),
is_active: z.boolean(),
is_builtin: z.boolean(),
}),
),
)
.handler(async () => {
return listOAuthClients();
}),
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
return {
client_key: getActiveOAuthClient(),
};
}),
setActiveOAuthClient: os
.input(z.object({ clientKey: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
setActiveOAuthClient(input.clientKey);
}),
setAccountProxy: os
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
.output(z.void())
.handler(async ({ input }) => {
try {
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
} catch (error: unknown) {
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
try {
const result = await CloudAccountRepo.syncFromIDE();
return result;
} catch (error: unknown) {
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw toSyncLocalAccountORPCError(error);
}
}),
getSwitchStatus: os.output(switchStatusSnapshotSchema).handler(async () => {
return {
metrics: getSwitchMetricsSnapshot(),
guard: getSwitchGuardSnapshot(),
hardening: getDeviceHardeningSnapshot(),
};
}),
getIdentityProfiles: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfilesSnapshotSchema)
.handler(async ({ input }) => {
return getCloudIdentityProfiles(input.accountId);
}),
previewIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
return previewGenerateCloudIdentityProfile();
}),
bindIdentityProfile: os
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindCloudIdentityProfile(input.accountId, input.mode);
}),
bindIdentityProfileWithPayload: os
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindCloudIdentityProfileWithPayload(input.accountId, input.profile);
}),
restoreIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreCloudIdentityProfileRevision(input.accountId, input.versionId);
}),
restoreBaselineProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreCloudBaselineProfile(input.accountId);
}),
deleteIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
await deleteCloudIdentityProfileRevision(input.accountId, input.versionId);
}),
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
await openCloudIdentityStorageFolder();
}),
exportCloudAccounts: os
.input(z.object({ stripTokens: z.boolean().default(false) }))
.output(z.string())
.handler(async ({ input }) => {
try {
return await exportCloudAccounts(input.stripTokens);
} catch (error: unknown) {
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
importCloudAccounts: os
.input(
z.object({
jsonContent: z.string(),
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
}),
)
.output(
z.object({
imported: z.number(),
skipped: z.number(),
updated: z.number(),
errors: z.array(z.string()),
}),
)
.handler(async ({ input }) => {
try {
return await importCloudAccounts(input.jsonContent, input.strategy);
} catch (error: unknown) {
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
});
import { z } from 'zod';
import { os, ORPCError } from '@orpc/server';
import {
addGoogleAccount,
bindCloudIdentityProfile,
bindCloudIdentityProfileWithPayload,
deleteCloudIdentityProfileRevision,
listCloudAccounts,
deleteCloudAccount,
getCloudIdentityProfiles,
openCloudIdentityStorageFolder,
previewGenerateCloudIdentityProfile,
refreshAccountQuota,
restoreCloudIdentityProfileRevision,
restoreCloudBaselineProfile,
switchCloudAccount,
getAutoSwitchEnabled,
setAutoSwitchEnabled,
forcePollCloudMonitor,
startAuthFlow,
listOAuthClients,
getActiveOAuthClient,
setActiveOAuthClient,
exportCloudAccounts,
importCloudAccounts,
} from './handler';
import { CloudAccountRepo } from '../database/cloudHandler';
import { CloudAccountSchema } from '../../types/cloudAccount';
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
import { logger } from '../../utils/logger';
import { getSwitchMetricsSnapshot } from '../switchMetrics';
import { getSwitchGuardSnapshot } from '../switchGuard';
import { getDeviceHardeningSnapshot } from '../device/handler';
const switchOwnerSchema = z.enum(['local-account-switch', 'cloud-account-switch']);
const switchMetricBucketSchema = z.object({
switchSuccess: z.number(),
switchFailure: z.number(),
rollbackAttempt: z.number(),
rollbackSuccess: z.number(),
rollbackFailure: z.number(),
failureReasons: z.record(z.string(), z.number()),
lastFailure: z
.object({
reason: z.string(),
message: z.string(),
occurredAt: z.number(),
})
.nullable(),
});
const switchMetricsSnapshotSchema = z.object({
local: switchMetricBucketSchema,
cloud: switchMetricBucketSchema,
});
const switchGuardSnapshotSchema = z.object({
activeOwner: switchOwnerSchema.nullable(),
pendingOwners: z.array(switchOwnerSchema),
pendingCount: z.number(),
});
const switchStatusSnapshotSchema = z.object({
metrics: switchMetricsSnapshotSchema,
guard: switchGuardSnapshotSchema,
hardening: z.object({
consecutiveApplyFailures: z.number(),
safeModeActive: z.boolean(),
safeModeUntil: z.number().nullable(),
lastFailureReason: z.string().nullable(),
lastFailureStage: z.string().nullable(),
lastFailureAt: z.number().nullable(),
}),
});
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
const message = extractErrorMessage(error);
const normalized = message.toLowerCase();
if (
normalized.includes('unauthenticated') ||
normalized.includes('unauthorized') ||
normalized.includes('token may be expired') ||
normalized.includes('re-login in geminiNexus ide')
) {
return new ORPCError('UNAUTHORIZED', { message });
}
if (
normalized.includes('no cloud account found in ide') ||
normalized.includes('no oauth token found in ide state') ||
normalized.includes('geminiNexus database not found')
) {
return new ORPCError('BAD_REQUEST', { message });
}
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
}
export const cloudRouter = os.router({
addGoogleAccount: os
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
.output(CloudAccountSchema)
.handler(async ({ input }) => {
return addGoogleAccount(input.authCode, input.oauthClientKey);
}),
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
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
.output(z.void())
.handler(async ({ input }) => {
await startAuthFlow(input?.oauthClientKey);
}),
listOAuthClients: os
.output(
z.array(
z.object({
key: z.string(),
label: z.string(),
client_id: z.string(),
is_active: z.boolean(),
is_builtin: z.boolean(),
}),
),
)
.handler(async () => {
return listOAuthClients();
}),
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
return {
client_key: getActiveOAuthClient(),
};
}),
setActiveOAuthClient: os
.input(z.object({ clientKey: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
setActiveOAuthClient(input.clientKey);
}),
setAccountProxy: os
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
.output(z.void())
.handler(async ({ input }) => {
try {
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
} catch (error: unknown) {
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
try {
const result = await CloudAccountRepo.syncFromIDE();
return result;
} catch (error: unknown) {
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw toSyncLocalAccountORPCError(error);
}
}),
getSwitchStatus: os.output(switchStatusSnapshotSchema).handler(async () => {
return {
metrics: getSwitchMetricsSnapshot(),
guard: getSwitchGuardSnapshot(),
hardening: getDeviceHardeningSnapshot(),
};
}),
getIdentityProfiles: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfilesSnapshotSchema)
.handler(async ({ input }) => {
return getCloudIdentityProfiles(input.accountId);
}),
previewIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
return previewGenerateCloudIdentityProfile();
}),
bindIdentityProfile: os
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindCloudIdentityProfile(input.accountId, input.mode);
}),
bindIdentityProfileWithPayload: os
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return bindCloudIdentityProfileWithPayload(input.accountId, input.profile);
}),
restoreIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreCloudIdentityProfileRevision(input.accountId, input.versionId);
}),
restoreBaselineProfile: os
.input(z.object({ accountId: z.string() }))
.output(DeviceProfileSchema)
.handler(async ({ input }) => {
return restoreCloudBaselineProfile(input.accountId);
}),
deleteIdentityProfileRevision: os
.input(z.object({ accountId: z.string(), versionId: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
await deleteCloudIdentityProfileRevision(input.accountId, input.versionId);
}),
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
await openCloudIdentityStorageFolder();
}),
exportCloudAccounts: os
.input(z.object({ stripTokens: z.boolean().default(false) }))
.output(z.string())
.handler(async ({ input }) => {
try {
return await exportCloudAccounts(input.stripTokens);
} catch (error: unknown) {
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
importCloudAccounts: os
.input(
z.object({
jsonContent: z.string(),
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
}),
)
.output(
z.object({
imported: z.number(),
skipped: z.number(),
updated: z.number(),
errors: z.array(z.string()),
}),
)
.handler(async ({ input }) => {
try {
return await importCloudAccounts(input.jsonContent, input.strategy);
} catch (error: unknown) {
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error;
}
}),
});
+22 -22
View File
@@ -1,22 +1,22 @@
import { AppConfig } from '../../types/config';
import { ConfigManager } from './manager';
import { syncAutoStart } from '../../utils/autoStart';
import { logger } from '../../utils/logger';
import { setServerConfig } from '../../server/server-config';
export function loadConfig(): AppConfig {
return ConfigManager.loadConfig();
}
export async function saveConfig(config: AppConfig): Promise<void> {
// Logic to notify proxy server if configuration changes (hot update)
// Logic to update Tray if language changes
// For now just save
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
await ConfigManager.saveConfig(config);
setServerConfig(config.proxy);
logger.setErrorReportingEnabled(config.error_reporting_enabled);
if (previous.auto_startup !== config.auto_startup) {
syncAutoStart(config);
}
}
import { AppConfig } from '../../types/config';
import { ConfigManager } from './manager';
import { syncAutoStart } from '../../utils/autoStart';
import { logger } from '../../utils/logger';
import { setServerConfig } from '../../server/server-config';
export function loadConfig(): AppConfig {
return ConfigManager.loadConfig();
}
export async function saveConfig(config: AppConfig): Promise<void> {
// Logic to notify proxy server if configuration changes (hot update)
// Logic to update Tray if language changes
// For now just save
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
await ConfigManager.saveConfig(config);
setServerConfig(config.proxy);
logger.setErrorReportingEnabled(config.error_reporting_enabled);
if (previous.auto_startup !== config.auto_startup) {
syncAutoStart(config);
}
}
+83 -83
View File
@@ -1,83 +1,83 @@
import path from 'path';
import fs from 'fs';
import { AppConfig, 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 cachedConfig: AppConfig | null = null;
private static saveQueue: Promise<void> = Promise.resolve();
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`);
this.cachedConfig = DEFAULT_APP_CONFIG;
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: AppConfig = {
...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
this.cachedConfig = merged;
return merged;
} catch (e) {
logger.error('Config: Failed to load config', e);
this.cachedConfig = DEFAULT_APP_CONFIG;
return DEFAULT_APP_CONFIG;
}
}
static getCachedConfig(): AppConfig | null {
return this.cachedConfig;
}
static async saveConfig(config: AppConfig): Promise<void> {
const configPath = this.getConfigPath();
const content = JSON.stringify(config, null, 2);
this.saveQueue = this.saveQueue
.catch(() => undefined)
.then(async () => {
await fs.promises.writeFile(configPath, content, 'utf-8');
this.cachedConfig = config;
logger.info(`Config: Saved to ${configPath}`);
})
.catch((e) => {
logger.error('Config: Failed to save config', e);
throw e;
});
return this.saveQueue;
}
}
import path from 'path';
import fs from 'fs';
import { AppConfig, 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 cachedConfig: AppConfig | null = null;
private static saveQueue: Promise<void> = Promise.resolve();
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`);
this.cachedConfig = DEFAULT_APP_CONFIG;
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: AppConfig = {
...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
this.cachedConfig = merged;
return merged;
} catch (e) {
logger.error('Config: Failed to load config', e);
this.cachedConfig = DEFAULT_APP_CONFIG;
return DEFAULT_APP_CONFIG;
}
}
static getCachedConfig(): AppConfig | null {
return this.cachedConfig;
}
static async saveConfig(config: AppConfig): Promise<void> {
const configPath = this.getConfigPath();
const content = JSON.stringify(config, null, 2);
this.saveQueue = this.saveQueue
.catch(() => undefined)
.then(async () => {
await fs.promises.writeFile(configPath, content, 'utf-8');
this.cachedConfig = config;
logger.info(`Config: Saved to ${configPath}`);
})
.catch((e) => {
logger.error('Config: Failed to save config', e);
throw e;
});
return this.saveQueue;
}
}
+17 -17
View File
@@ -1,17 +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 }) => {
await saveConfig(input);
}),
});
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 }) => {
await saveConfig(input);
}),
});
+26 -26
View File
@@ -1,26 +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();
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();
File diff suppressed because it is too large. Load diff
+34 -34
View File
@@ -1,34 +1,34 @@
import Database from 'better-sqlite3';
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
export interface DbConfigOptions {
readOnly?: boolean;
busyTimeoutMs?: number;
enableWal?: boolean;
}
export interface DrizzleConnection {
raw: Database.Database;
orm: BetterSQLite3Database<typeof schema>;
}
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
const enableWal = options.enableWal ?? !options.readOnly;
if (enableWal) {
db.pragma('journal_mode = WAL');
}
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
}
export function openDrizzleConnection(
dbPath: string,
options: Database.Options = {},
config: DbConfigOptions = {},
): DrizzleConnection {
const raw = new Database(dbPath, options);
configureDatabase(raw, config);
const orm = drizzle(raw, { schema });
return { raw, orm };
}
import Database from 'better-sqlite3';
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
export interface DbConfigOptions {
readOnly?: boolean;
busyTimeoutMs?: number;
enableWal?: boolean;
}
export interface DrizzleConnection {
raw: Database.Database;
orm: BetterSQLite3Database<typeof schema>;
}
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
const enableWal = options.enableWal ?? !options.readOnly;
if (enableWal) {
db.pragma('journal_mode = WAL');
}
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
}
export function openDrizzleConnection(
dbPath: string,
options: Database.Options = {},
config: DbConfigOptions = {},
): DrizzleConnection {
const raw = new Database(dbPath, options);
configureDatabase(raw, config);
const orm = drizzle(raw, { schema });
return { raw, orm };
}
+349 -349
View File
@@ -1,349 +1,349 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { isString } from 'lodash-es';
import { AccountBackupData, AccountInfo } from '../../types/account';
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
import { logger } from '../../utils/logger';
import { getGeminiNexusDbPaths } from '../../utils/paths';
import { parseRow } from '../../utils/sqlite';
import { openDrizzleConnection } from './dbConnection';
import { itemTable } from './schema';
const KEYS_TO_BACKUP: ItemTableKey[] = [
'geminiNexusAuthStatus',
'jetskiStateSync.agentManagerInitState',
'geminiNexusUnifiedStateSync.oauthToken',
];
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
return openDrizzleConnection(
dbPath,
{ readonly: readOnly, fileMustExist: false },
{ readOnly, busyTimeoutMs: 3000 },
);
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getGeminiNexusDbPaths();
if (dbPaths.length === 0) {
return;
}
const { raw } = getDatabaseConnection();
raw.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/**
* 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 {ReturnType<typeof openDrizzleConnection>} The database connection.
*/
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
if (!targetPath) {
throw new Error('No Gemini Nexus database path found');
}
ensureDatabaseExists(targetPath);
try {
return openIdeDb(targetPath);
} 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 Gemini Nexus before proceeding.');
}
throw error;
}
}
function readItemValue(
orm: ReturnType<typeof openDrizzleConnection>['orm'],
key: string,
context: string,
): string | null {
const rows = orm
.select({ value: itemTable.value })
.from(itemTable)
.where(eq(itemTable.key, key))
.all();
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
return row?.value ?? null;
}
/**
* Gets the current account info.
* @returns {AccountInfo} The current account info.
*/
export function getCurrentAccountInfo(): AccountInfo {
// NOTE Database existence is now handled by getDatabaseConnection
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection();
const { orm } = connection;
// Query for auth status
const authValue = readItemValue(
orm,
'geminiNexusAuthStatus',
'ide.itemTable.geminiNexusAuthStatus',
);
let authStatus = null;
if (authValue) {
try {
authStatus = JSON.parse(authValue);
} catch {
// NOTE Ignore JSON parse errors
}
}
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
const initValue = readItemValue(
orm,
'jetskiStateSync.agentManagerInitState',
'ide.itemTable.jetskiStateSync.agentManagerInitState',
);
let initState = null;
if (initValue) {
try {
initState = JSON.parse(initValue);
} catch {
// Ignore JSON parse errors (this key often contains non-JSON data)
}
}
// Query for google.geminiNexus
const googleValue = readItemValue(
orm,
'google.geminiNexus',
'ide.itemTable.google.geminiNexus',
);
let googleState = null;
if (googleValue) {
try {
googleState = JSON.parse(googleValue);
} catch {
// Ignore JSON parse errors
}
}
// Query for geminiNexusUserSettings.allUserSettings
const settingsValue = readItemValue(
orm,
'geminiNexusUserSettings.allUserSettings',
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
);
let settingsState = null;
if (settingsValue) {
try {
settingsState = JSON.parse(settingsValue);
} catch {
// Ignore JSON parse errors
}
}
// Helper to find email in object
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
if (!obj) return '';
if (isString(obj.email)) return obj.email;
if (obj.user && isString(obj.user.email)) 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 (connection) {
connection.raw.close();
}
}
}
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection();
const { orm } = connection;
// NOTE Backup only specific keys
const data: Record<string, unknown> = {};
for (const key of KEYS_TO_BACKUP) {
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
if (value) {
try {
data[key] = JSON.parse(value);
} catch {
data[key] = 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 (connection) {
connection.raw.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 = getGeminiNexusDbPaths();
if (dbPaths.length === 0) {
throw new Error('No Gemini Nexus 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 connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection(dbPath);
const { orm } = connection;
orm.transaction((tx) => {
// 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 = isString(value) ? value : JSON.stringify(value);
tx.insert(itemTable)
.values({ key, value: stringValue })
.onConflictDoUpdate({
target: itemTable.key,
set: { value: stringValue },
})
.run();
logger.debug(`Restored key: ${key}`);
}
}
});
logger.info(`Database restoration complete: ${dbPath}`);
return true;
} catch (error) {
logger.error(`Failed to restore database: ${dbPath}`, error);
return false;
} finally {
if (connection) {
connection.raw.close();
}
}
}
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { isString } from 'lodash-es';
import { AccountBackupData, AccountInfo } from '../../types/account';
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
import { logger } from '../../utils/logger';
import { getGeminiNexusDbPaths } from '../../utils/paths';
import { parseRow } from '../../utils/sqlite';
import { openDrizzleConnection } from './dbConnection';
import { itemTable } from './schema';
const KEYS_TO_BACKUP: ItemTableKey[] = [
'geminiNexusAuthStatus',
'jetskiStateSync.agentManagerInitState',
'geminiNexusUnifiedStateSync.oauthToken',
];
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
return openDrizzleConnection(
dbPath,
{ readonly: readOnly, fileMustExist: false },
{ readOnly, busyTimeoutMs: 3000 },
);
}
/**
* Initializes the database and ensures WAL mode is enabled.
* Should be called on application startup.
*/
export function initDatabase(): void {
try {
const dbPaths = getGeminiNexusDbPaths();
if (dbPaths.length === 0) {
return;
}
const { raw } = getDatabaseConnection();
raw.close();
logger.info('Database initialized and verified (WAL mode)');
} catch (error) {
logger.error('Failed to initialize database on startup', error);
}
}
/**
* 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 {ReturnType<typeof openDrizzleConnection>} The database connection.
*/
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
if (!targetPath) {
throw new Error('No Gemini Nexus database path found');
}
ensureDatabaseExists(targetPath);
try {
return openIdeDb(targetPath);
} 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 Gemini Nexus before proceeding.');
}
throw error;
}
}
function readItemValue(
orm: ReturnType<typeof openDrizzleConnection>['orm'],
key: string,
context: string,
): string | null {
const rows = orm
.select({ value: itemTable.value })
.from(itemTable)
.where(eq(itemTable.key, key))
.all();
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
return row?.value ?? null;
}
/**
* Gets the current account info.
* @returns {AccountInfo} The current account info.
*/
export function getCurrentAccountInfo(): AccountInfo {
// NOTE Database existence is now handled by getDatabaseConnection
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection();
const { orm } = connection;
// Query for auth status
const authValue = readItemValue(
orm,
'geminiNexusAuthStatus',
'ide.itemTable.geminiNexusAuthStatus',
);
let authStatus = null;
if (authValue) {
try {
authStatus = JSON.parse(authValue);
} catch {
// NOTE Ignore JSON parse errors
}
}
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
const initValue = readItemValue(
orm,
'jetskiStateSync.agentManagerInitState',
'ide.itemTable.jetskiStateSync.agentManagerInitState',
);
let initState = null;
if (initValue) {
try {
initState = JSON.parse(initValue);
} catch {
// Ignore JSON parse errors (this key often contains non-JSON data)
}
}
// Query for google.geminiNexus
const googleValue = readItemValue(
orm,
'google.geminiNexus',
'ide.itemTable.google.geminiNexus',
);
let googleState = null;
if (googleValue) {
try {
googleState = JSON.parse(googleValue);
} catch {
// Ignore JSON parse errors
}
}
// Query for geminiNexusUserSettings.allUserSettings
const settingsValue = readItemValue(
orm,
'geminiNexusUserSettings.allUserSettings',
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
);
let settingsState = null;
if (settingsValue) {
try {
settingsState = JSON.parse(settingsValue);
} catch {
// Ignore JSON parse errors
}
}
// Helper to find email in object
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
if (!obj) return '';
if (isString(obj.email)) return obj.email;
if (obj.user && isString(obj.user.email)) 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 (connection) {
connection.raw.close();
}
}
}
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection();
const { orm } = connection;
// NOTE Backup only specific keys
const data: Record<string, unknown> = {};
for (const key of KEYS_TO_BACKUP) {
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
if (value) {
try {
data[key] = JSON.parse(value);
} catch {
data[key] = 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 (connection) {
connection.raw.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 = getGeminiNexusDbPaths();
if (dbPaths.length === 0) {
throw new Error('No Gemini Nexus 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 connection: ReturnType<typeof openDrizzleConnection> | null = null;
try {
connection = getDatabaseConnection(dbPath);
const { orm } = connection;
orm.transaction((tx) => {
// 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 = isString(value) ? value : JSON.stringify(value);
tx.insert(itemTable)
.values({ key, value: stringValue })
.onConflictDoUpdate({
target: itemTable.key,
set: { value: stringValue },
})
.run();
logger.debug(`Restored key: ${key}`);
}
}
});
logger.info(`Database restoration complete: ${dbPath}`);
return true;
} catch (error) {
logger.error(`Failed to restore database: ${dbPath}`, error);
return false;
} finally {
if (connection) {
connection.raw.close();
}
}
}
+452 -452
View File
@@ -1,452 +1,452 @@
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
export interface RecordProxyRequestParams {
endpoint: string;
statusCode: number;
durationMs: number;
timestamp: number;
accountId?: string | null;
tokensPrompt?: number | null;
tokensCompletion?: number | null;
errorMessage?: string | null;
}
let proxyMetricsDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getProxyMetricsDb() {
if (!proxyMetricsDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
const conn = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
ensureProxyMetricsTable(conn.raw);
proxyMetricsDbConnection = conn;
}
return proxyMetricsDbConnection;
}
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
try {
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
account_id TEXT,
tokens_prompt INTEGER,
tokens_completion INTEGER,
error_message TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS traffic_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
direction TEXT NOT NULL,
request_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT,
status INTEGER,
headers_json TEXT,
body_json TEXT,
chunk_json TEXT,
duration_ms INTEGER,
error_message TEXT,
error_stack TEXT,
error_status INTEGER,
metadata_json TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
);
} catch (error) {
logger.error('Failed to ensure proxy_request_metrics table', error);
throw error;
}
}
export class ProxyMetricsRepo {
static recordRequest(params: RecordProxyRequestParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(proxyRequestMetrics)
.values({
endpoint: params.endpoint,
statusCode: params.statusCode,
durationMs: params.durationMs,
timestamp: params.timestamp,
accountId: params.accountId ?? null,
tokensPrompt: params.tokensPrompt ?? null,
tokensCompletion: params.tokensCompletion ?? null,
errorMessage: params.errorMessage ?? null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record proxy request metric', error);
return false;
}
}
static getRecentAggregates(): {
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const oneMinuteAgo = now - 60_000;
const fiveMinutesAgo = now - 5 * 60_000;
try {
const requestsPerMinuteRow = raw
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
.get(oneMinuteAgo) as { count: number } | undefined;
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
const aggregatesRow = raw
.prepare(
`SELECT
COUNT(*) as total,
AVG(duration_ms) as avgLatency,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics
WHERE timestamp >= ?`,
)
.get(fiveMinutesAgo) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
return {
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
};
} catch (error) {
logger.error('Failed to get recent proxy metrics aggregates', error);
return {
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
};
}
}
static recordConnectionSnapshot(count: number): boolean {
const { orm } = getProxyMetricsDb();
try {
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
return true;
} catch (error) {
logger.error('Failed to record connection snapshot', error);
return false;
}
}
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
totalRequests: number;
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
avgActiveConnections: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const cutoff = now - windowMs;
try {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics WHERE timestamp >= ?`,
)
.get(cutoff) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
const errorRatePercent = reqRow?.errorRate ?? 0;
const connRow = raw
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
.get(cutoff) as { avg: number | null } | undefined;
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
return {
totalRequests,
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
avgActiveConnections,
};
} catch (error) {
logger.error('Failed to get historical proxy metrics aggregates', error);
return {
totalRequests: 0,
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
avgActiveConnections: 0,
};
}
}
static deleteOldRecords(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old proxy metrics records', error);
return false;
}
}
}
export interface InsertTrafficLogParams {
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}
export class TrafficLogsRepo {
static insert(params: InsertTrafficLogParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(trafficLogs)
.values({
timestamp: params.timestamp,
direction: params.direction,
requestId: params.requestId,
endpoint: params.endpoint,
method: params.method ?? null,
status: params.status ?? null,
headersJson: params.headers ? JSON.stringify(params.headers) : null,
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
durationMs: params.durationMs ?? null,
errorMessage: params.error?.message ?? null,
errorStack: params.error?.stack ?? null,
errorStatus: params.error?.status ?? null,
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to insert traffic log', error);
return false;
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
let sql = `SELECT * FROM traffic_logs`;
const args: (number | string)[] = [];
if (direction) {
sql += ` WHERE direction = ?`;
args.push(direction);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
args.push(limit);
const rows = raw.prepare(sql).all(...args) as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return [];
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
direction: string;
endpoint: string;
cnt: number;
}>;
const byDirection: Record<string, number> = {};
const byEndpoint: Record<string, number> = {};
let total = 0;
for (const r of rows) {
total += r.cnt;
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
}
return { byDirection, byEndpoint, total };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { byDirection: {}, byEndpoint: {}, total: 0 };
}
}
static getAllForExport(): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get all traffic logs for export', error);
return [];
}
}
static deleteOld(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old traffic logs', error);
return false;
}
}
}
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
export interface RecordProxyRequestParams {
endpoint: string;
statusCode: number;
durationMs: number;
timestamp: number;
accountId?: string | null;
tokensPrompt?: number | null;
tokensCompletion?: number | null;
errorMessage?: string | null;
}
let proxyMetricsDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getProxyMetricsDb() {
if (!proxyMetricsDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
const conn = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
ensureProxyMetricsTable(conn.raw);
proxyMetricsDbConnection = conn;
}
return proxyMetricsDbConnection;
}
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
try {
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
account_id TEXT,
tokens_prompt INTEGER,
tokens_completion INTEGER,
error_message TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS traffic_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
direction TEXT NOT NULL,
request_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT,
status INTEGER,
headers_json TEXT,
body_json TEXT,
chunk_json TEXT,
duration_ms INTEGER,
error_message TEXT,
error_stack TEXT,
error_status INTEGER,
metadata_json TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
);
} catch (error) {
logger.error('Failed to ensure proxy_request_metrics table', error);
throw error;
}
}
export class ProxyMetricsRepo {
static recordRequest(params: RecordProxyRequestParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(proxyRequestMetrics)
.values({
endpoint: params.endpoint,
statusCode: params.statusCode,
durationMs: params.durationMs,
timestamp: params.timestamp,
accountId: params.accountId ?? null,
tokensPrompt: params.tokensPrompt ?? null,
tokensCompletion: params.tokensCompletion ?? null,
errorMessage: params.errorMessage ?? null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record proxy request metric', error);
return false;
}
}
static getRecentAggregates(): {
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const oneMinuteAgo = now - 60_000;
const fiveMinutesAgo = now - 5 * 60_000;
try {
const requestsPerMinuteRow = raw
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
.get(oneMinuteAgo) as { count: number } | undefined;
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
const aggregatesRow = raw
.prepare(
`SELECT
COUNT(*) as total,
AVG(duration_ms) as avgLatency,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics
WHERE timestamp >= ?`,
)
.get(fiveMinutesAgo) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
return {
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
};
} catch (error) {
logger.error('Failed to get recent proxy metrics aggregates', error);
return {
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
};
}
}
static recordConnectionSnapshot(count: number): boolean {
const { orm } = getProxyMetricsDb();
try {
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
return true;
} catch (error) {
logger.error('Failed to record connection snapshot', error);
return false;
}
}
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
totalRequests: number;
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
avgActiveConnections: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const cutoff = now - windowMs;
try {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics WHERE timestamp >= ?`,
)
.get(cutoff) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
const errorRatePercent = reqRow?.errorRate ?? 0;
const connRow = raw
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
.get(cutoff) as { avg: number | null } | undefined;
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
return {
totalRequests,
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
avgActiveConnections,
};
} catch (error) {
logger.error('Failed to get historical proxy metrics aggregates', error);
return {
totalRequests: 0,
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
avgActiveConnections: 0,
};
}
}
static deleteOldRecords(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old proxy metrics records', error);
return false;
}
}
}
export interface InsertTrafficLogParams {
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}
export class TrafficLogsRepo {
static insert(params: InsertTrafficLogParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(trafficLogs)
.values({
timestamp: params.timestamp,
direction: params.direction,
requestId: params.requestId,
endpoint: params.endpoint,
method: params.method ?? null,
status: params.status ?? null,
headersJson: params.headers ? JSON.stringify(params.headers) : null,
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
durationMs: params.durationMs ?? null,
errorMessage: params.error?.message ?? null,
errorStack: params.error?.stack ?? null,
errorStatus: params.error?.status ?? null,
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to insert traffic log', error);
return false;
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
let sql = `SELECT * FROM traffic_logs`;
const args: (number | string)[] = [];
if (direction) {
sql += ` WHERE direction = ?`;
args.push(direction);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
args.push(limit);
const rows = raw.prepare(sql).all(...args) as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return [];
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
direction: string;
endpoint: string;
cnt: number;
}>;
const byDirection: Record<string, number> = {};
const byEndpoint: Record<string, number> = {};
let total = 0;
for (const r of rows) {
total += r.cnt;
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
}
return { byDirection, byEndpoint, total };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { byDirection: {}, byEndpoint: {}, total: 0 };
}
}
static getAllForExport(): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get all traffic logs for export', error);
return [];
}
}
static deleteOld(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old traffic logs', error);
return false;
}
}
}
+24 -24
View File
@@ -1,24 +1,24 @@
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();
}),
});
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();
}),
});
+77 -77
View File
@@ -1,77 +1,77 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const accounts = sqliteTable('accounts', {
id: text('id').primaryKey(),
provider: text('provider').notNull(),
email: text('email').notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
tokenJson: text('token_json').notNull(),
quotaJson: text('quota_json'),
deviceProfileJson: text('device_profile_json'),
deviceHistoryJson: text('device_history_json'),
createdAt: integer('created_at').notNull(),
lastUsed: integer('last_used').notNull(),
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
statusReason: text('status_reason'),
isActive: integer('is_active').notNull().default(0),
proxyUrl: text('proxy_url'),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const itemTable = sqliteTable('ItemTable', {
key: text('key').primaryKey(),
value: text('value'),
});
export const tokenUsage = sqliteTable('token_usage', {
id: integer('id').primaryKey({ autoIncrement: true }),
accountId: text('account_id').notNull(),
model: text('model').notNull(),
promptTokens: integer('prompt_tokens').notNull().default(0),
completionTokens: integer('completion_tokens').notNull().default(0),
totalTokens: integer('total_tokens').notNull().default(0),
timestamp: integer('timestamp').notNull(),
requestType: text('request_type'),
isEstimated: integer('is_estimated'),
});
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
id: integer('id').primaryKey({ autoIncrement: true }),
endpoint: text('endpoint').notNull(),
statusCode: integer('status_code').notNull(),
durationMs: integer('duration_ms').notNull(),
timestamp: integer('timestamp').notNull(),
accountId: text('account_id'),
tokensPrompt: integer('tokens_prompt'),
tokensCompletion: integer('tokens_completion'),
errorMessage: text('error_message'),
});
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
count: integer('count').notNull().default(0),
});
export const trafficLogs = sqliteTable('traffic_logs', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
direction: text('direction').notNull(),
requestId: text('request_id').notNull(),
endpoint: text('endpoint').notNull(),
method: text('method'),
status: integer('status'),
headersJson: text('headers_json'),
bodyJson: text('body_json'),
chunkJson: text('chunk_json'),
durationMs: integer('duration_ms'),
errorMessage: text('error_message'),
errorStack: text('error_stack'),
errorStatus: integer('error_status'),
metadataJson: text('metadata_json'),
});
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const accounts = sqliteTable('accounts', {
id: text('id').primaryKey(),
provider: text('provider').notNull(),
email: text('email').notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
tokenJson: text('token_json').notNull(),
quotaJson: text('quota_json'),
deviceProfileJson: text('device_profile_json'),
deviceHistoryJson: text('device_history_json'),
createdAt: integer('created_at').notNull(),
lastUsed: integer('last_used').notNull(),
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
statusReason: text('status_reason'),
isActive: integer('is_active').notNull().default(0),
proxyUrl: text('proxy_url'),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const itemTable = sqliteTable('ItemTable', {
key: text('key').primaryKey(),
value: text('value'),
});
export const tokenUsage = sqliteTable('token_usage', {
id: integer('id').primaryKey({ autoIncrement: true }),
accountId: text('account_id').notNull(),
model: text('model').notNull(),
promptTokens: integer('prompt_tokens').notNull().default(0),
completionTokens: integer('completion_tokens').notNull().default(0),
totalTokens: integer('total_tokens').notNull().default(0),
timestamp: integer('timestamp').notNull(),
requestType: text('request_type'),
isEstimated: integer('is_estimated'),
});
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
id: integer('id').primaryKey({ autoIncrement: true }),
endpoint: text('endpoint').notNull(),
statusCode: integer('status_code').notNull(),
durationMs: integer('duration_ms').notNull(),
timestamp: integer('timestamp').notNull(),
accountId: text('account_id'),
tokensPrompt: integer('tokens_prompt'),
tokensCompletion: integer('tokens_completion'),
errorMessage: text('error_message'),
});
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
count: integer('count').notNull().default(0),
});
export const trafficLogs = sqliteTable('traffic_logs', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
direction: text('direction').notNull(),
requestId: text('request_id').notNull(),
endpoint: text('endpoint').notNull(),
method: text('method'),
status: integer('status'),
headersJson: text('headers_json'),
bodyJson: text('body_json'),
chunkJson: text('chunk_json'),
durationMs: integer('duration_ms'),
errorMessage: text('error_message'),
errorStack: text('error_stack'),
errorStatus: integer('error_status'),
metadataJson: text('metadata_json'),
});
+274 -274
View File
@@ -1,274 +1,274 @@
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { tokenUsage } from './schema';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
export interface RecordUsageParams {
accountId: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
timestamp: number;
requestType?: string;
isEstimated?: boolean;
}
let usageDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getUsageDb() {
if (!usageDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
usageDbConnection = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
}
return usageDbConnection;
}
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
}
export class TokenUsageRepo {
static recordUsage(params: RecordUsageParams): boolean {
const { orm } = getUsageDb();
try {
orm
.insert(tokenUsage)
.values({
accountId: params.accountId,
model: params.model,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
totalTokens: params.totalTokens,
timestamp: params.timestamp,
requestType: params.requestType ?? null,
isEstimated: params.isEstimated ? 1 : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record token usage', error);
return false;
}
}
static getUsageByHour(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => HourlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get hourly usage', error);
return [];
}
}
static getUsageByDay(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => DailyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get daily usage', error);
return [];
}
}
static getUsageByWeek(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => WeeklyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get weekly usage', error);
return [];
}
}
static getUsageByMonth(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => MonthlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get monthly usage', error);
return [];
}
}
static getUsageByModel(
accountId?: string,
start?: number,
end?: number,
): Array<{
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
model,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY model
ORDER BY totalTokens DESC
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => ModelUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get model usage', error);
return [];
}
}
}
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { tokenUsage } from './schema';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
export interface RecordUsageParams {
accountId: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
timestamp: number;
requestType?: string;
isEstimated?: boolean;
}
let usageDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getUsageDb() {
if (!usageDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
usageDbConnection = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
}
return usageDbConnection;
}
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
}
export class TokenUsageRepo {
static recordUsage(params: RecordUsageParams): boolean {
const { orm } = getUsageDb();
try {
orm
.insert(tokenUsage)
.values({
accountId: params.accountId,
model: params.model,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
totalTokens: params.totalTokens,
timestamp: params.timestamp,
requestType: params.requestType ?? null,
isEstimated: params.isEstimated ? 1 : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record token usage', error);
return false;
}
}
static getUsageByHour(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => HourlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get hourly usage', error);
return [];
}
}
static getUsageByDay(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => DailyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get daily usage', error);
return [];
}
}
static getUsageByWeek(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => WeeklyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get weekly usage', error);
return [];
}
}
static getUsageByMonth(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => MonthlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get monthly usage', error);
return [];
}
}
static getUsageByModel(
accountId?: string,
start?: number,
end?: number,
): Array<{
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
model,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY model
ORDER BY totalTokens DESC
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => ModelUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get model usage', error);
return [];
}
}
}
+717 -717
View File
File diff suppressed because it is too large. Load diff
+60 -60
View File
@@ -1,60 +1,60 @@
/**
* Gateway IPC Handlers
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
*/
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../utils/logger';
/**
* Start the gateway server (NestJS)
*/
export const startGateway = async (port: number): Promise<boolean> => {
try {
// Stop if already running
await stopNestServer();
// Load full config and start NestJS server
const config = ConfigManager.loadConfig();
const proxyConfig = { ...config.proxy, port };
return await bootstrapNestServer(proxyConfig);
} catch (e) {
logger.error('Failed to start gateway:', e);
return false;
}
};
/**
* Stop the gateway server (NestJS)
*/
export const stopGateway = async (): Promise<boolean> => {
try {
return await stopNestServer();
} catch (e) {
logger.error('Failed to stop gateway:', e);
return false;
}
};
/**
* Get gateway status (NestJS)
*/
export const getGatewayStatus = async () => {
return getNestServerStatus();
};
/**
* 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;
await ConfigManager.saveConfig(config);
return newKey;
};
/**
* Gateway IPC Handlers
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
*/
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../utils/logger';
/**
* Start the gateway server (NestJS)
*/
export const startGateway = async (port: number): Promise<boolean> => {
try {
// Stop if already running
await stopNestServer();
// Load full config and start NestJS server
const config = ConfigManager.loadConfig();
const proxyConfig = { ...config.proxy, port };
return await bootstrapNestServer(proxyConfig);
} catch (e) {
logger.error('Failed to start gateway:', e);
return false;
}
};
/**
* Stop the gateway server (NestJS)
*/
export const stopGateway = async (): Promise<boolean> => {
try {
return await stopNestServer();
} catch (e) {
logger.error('Failed to stop gateway:', e);
return false;
}
};
/**
* Get gateway status (NestJS)
*/
export const getGatewayStatus = async () => {
return getNestServerStatus();
};
/**
* 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;
await ConfigManager.saveConfig(config);
return newKey;
};
+36 -36
View File
@@ -1,36 +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 };
}),
});
/**
* 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 };
}),
});
+34 -34
View File
@@ -1,34 +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) {
logger.error('Error logging router:', e);
}
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
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) {
logger.error('Error logging router:', e);
}
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
+42 -42
View File
@@ -1,42 +1,42 @@
import { IPC_CHANNELS } from '@/constants';
import { createORPCClient } from '@orpc/client';
import { RPCLink } from '@orpc/client/message-port';
import type { RouterClient } from '@orpc/server';
import type { router } from './router';
type IPCClient = RouterClient<typeof router>;
class IPCManager {
private readonly clientPort: MessagePort;
private readonly serverPort: MessagePort;
public readonly client: IPCClient;
private initialized: boolean = false;
constructor() {
const { port1, port2 } = new MessageChannel();
this.clientPort = port1;
this.serverPort = port2;
this.client = createORPCClient<IPCClient>(
new RPCLink({
port: 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();
import { IPC_CHANNELS } from '@/constants';
import { createORPCClient } from '@orpc/client';
import { RPCLink } from '@orpc/client/message-port';
import type { RouterClient } from '@orpc/server';
import type { router } from './router';
type IPCClient = RouterClient<typeof router>;
class IPCManager {
private readonly clientPort: MessagePort;
private readonly serverPort: MessagePort;
public readonly client: IPCClient;
private initialized: boolean = false;
constructor() {
const { port1, port2 } = new MessageChannel();
this.clientPort = port1;
this.serverPort = port2;
this.client = createORPCClient<IPCClient>(
new RPCLink({
port: 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();
+65 -65
View File
@@ -1,65 +1,65 @@
import { logger } from '../../utils/logger';
import {
bootstrapNestServer,
isNestServerRunning,
stopNestServer,
} from '../../server/main';
import { ConfigManager } from '../config/manager';
/**
* Checks if the Gemini Nexus process is running.
* Uses find-process package for robust cross-platform process detection.
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/
export async function isProcessRunning(): Promise<boolean> {
// Check the embedded NestJS proxy server status instead of an external process
return isNestServerRunning();
}
/**
* Closes the Gemini Nexus process.
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/
export async function closeGeminiNexus(): Promise<void> {
logger.info('Stopping Gemini Nexus proxy server...');
await stopNestServer();
}
/**
* Waits for the Gemini Nexus process to exit.
* Polls the NestJS server status until it stops or the timeout is reached.
* @param timeoutMs {number} Maximum time to wait in milliseconds.
* @returns {Promise<void>} A promise that resolves when the process exits.
*/
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
const start = Date.now();
while (isNestServerRunning()) {
if (Date.now() - start > timeoutMs) {
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Starts the Gemini Nexus process.
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
* @returns {Promise<void>} A promise that resolves when the process starts.
*/
export async function startGeminiNexus(_useUri = true): Promise<void> {
logger.info('Starting Gemini Nexus proxy server...');
if (isNestServerRunning()) {
logger.info('Gemini Nexus proxy server is already running');
return;
}
const config = ConfigManager.loadConfig();
if (config.proxy) {
await bootstrapNestServer(config.proxy);
} else {
throw new Error('Proxy configuration not found');
}
}
import { logger } from '../../utils/logger';
import {
bootstrapNestServer,
isNestServerRunning,
stopNestServer,
} from '../../server/main';
import { ConfigManager } from '../config/manager';
/**
* Checks if the Gemini Nexus process is running.
* Uses find-process package for robust cross-platform process detection.
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/
export async function isProcessRunning(): Promise<boolean> {
// Check the embedded NestJS proxy server status instead of an external process
return isNestServerRunning();
}
/**
* Closes the Gemini Nexus process.
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/
export async function closeGeminiNexus(): Promise<void> {
logger.info('Stopping Gemini Nexus proxy server...');
await stopNestServer();
}
/**
* Waits for the Gemini Nexus process to exit.
* Polls the NestJS server status until it stops or the timeout is reached.
* @param timeoutMs {number} Maximum time to wait in milliseconds.
* @returns {Promise<void>} A promise that resolves when the process exits.
*/
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
const start = Date.now();
while (isNestServerRunning()) {
if (Date.now() - start > timeoutMs) {
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Starts the Gemini Nexus process.
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
* @returns {Promise<void>} A promise that resolves when the process starts.
*/
export async function startGeminiNexus(_useUri = true): Promise<void> {
logger.info('Starting Gemini Nexus proxy server...');
if (isNestServerRunning()) {
logger.info('Gemini Nexus proxy server is already running');
return;
}
const config = ConfigManager.loadConfig();
if (config.proxy) {
await bootstrapNestServer(config.proxy);
} else {
throw new Error('Proxy configuration not found');
}
}
+372 -372
View File
@@ -1,372 +1,372 @@
import { logger } from '../../utils/logger';
import { getServiceOrThrow } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
import { CloudAccountRepo } from '../database/cloudHandler';
export interface TrafficLogEntryParsed {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: {
message: string;
stack?: string;
status?: number;
body?: unknown;
};
metadata?: Record<string, unknown>;
}
function normalizeServiceError(error: unknown): string {
if (error instanceof Error && error.message.includes('is not registered')) {
return 'Proxy advanced service not initialized';
}
return error instanceof Error ? error.message : String(error);
}
export async function getRecentTrafficLogs(
limit: number = 50,
direction?: string,
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
try {
const rows = TrafficLogsRepo.getRecent(limit, direction);
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
timestamp: new Date(r.timestamp).toISOString(),
direction: r.direction,
requestId: r.requestId,
endpoint: r.endpoint,
method: r.method,
status: r.status,
headers: r.headers,
body: r.body,
chunk: r.chunk,
durationMs: r.durationMs,
error: r.error,
metadata: r.metadata,
}));
return { success: true, data: entries };
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getTrafficLogStats(): Promise<{
success: boolean;
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
error?: string;
}> {
try {
const stats = TrafficLogsRepo.getStats();
return { success: true, data: stats };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function exportTrafficLogs(
format: 'json' | 'csv',
): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const rows = TrafficLogsRepo.getAllForExport();
if (format === 'json') {
return { success: true, data: JSON.stringify(rows, null, 2) };
}
// CSV format
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
const rowsCsv = rows.map((e) =>
[
new Date(e.timestamp).toISOString(),
e.direction,
e.requestId,
e.endpoint,
e.method || '',
e.status != null ? e.status : '',
e.durationMs != null ? e.durationMs : '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
);
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
} catch (error) {
logger.error('Failed to export traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getParityCounters(): Promise<{
success: boolean;
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
return {
success: true,
data: tokenManager.getParityCounters(),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getCircuitBreakerStatus(): Promise<{
success: boolean;
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
const rawStatus = tokenManager.getCircuitBreakerStatus();
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
for (const [accountId, entry] of Object.entries(rawStatus)) {
states[accountId] = {
state: entry.state,
failures: entry.failureCount,
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
};
}
return {
success: true,
data: {
states,
globalEnabled: true,
},
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
avgLatency: number;
errorRate: number;
requestsPerMinute: number;
cacheHitRate: number;
};
error?: string;
}> {
try {
const proxyMetrics = getServiceOrThrow('proxyMetrics');
const metrics = proxyMetrics.getMetrics();
return {
success: true,
data: {
uptimeSeconds: Math.floor(process.uptime()),
totalRequests: metrics.totalRequests,
activeConnections: metrics.activeConnections,
avgLatency: metrics.avgLatency,
errorRate: metrics.errorRate,
requestsPerMinute: metrics.requestsPerMinute,
cacheHitRate: metrics.cacheHitRate,
},
};
} catch (error) {
logger.error('Failed to get proxy metrics', error);
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
return {
success: true,
data: proxyReplay.getRecentRequests(20),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function replayRequest(
requestId: string,
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const result = await proxyReplay.replayRequest(requestId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export interface ModelCapabilityEntry {
id: string;
object: string;
displayName?: string;
capabilities: {
vision: boolean;
thinking: boolean;
streaming: boolean;
jsonMode: boolean;
recommended: boolean;
};
limits: {
maxTokens?: number;
maxOutputTokens?: number;
thinkingBudget?: number;
};
}
export async function getModelCapabilities(): Promise<{
success: boolean;
data?: ModelCapabilityEntry[];
error?: string;
}> {
try {
const accounts = await CloudAccountRepo.getAccounts();
const modelMap = new Map<string, ModelCapabilityEntry>();
for (const account of accounts) {
if (!account.quota || !account.quota.models) {
continue;
}
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
if (modelMap.has(modelId)) {
continue;
}
modelMap.set(modelId, {
id: modelId,
object: 'model',
displayName: modelInfo.display_name,
capabilities: {
vision: modelInfo.supports_images ?? false,
thinking: modelInfo.supports_thinking ?? false,
streaming: true,
jsonMode: true,
recommended: modelInfo.recommended ?? false,
},
limits: {
maxTokens: modelInfo.max_tokens,
maxOutputTokens: modelInfo.max_output_tokens,
thinkingBudget: modelInfo.thinking_budget,
},
});
}
}
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
return { success: true, data };
} catch (error) {
logger.error('Failed to get model capabilities', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function mapIdeConfigResult(
result: IdeConfigResult,
): {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
} {
switch (result.ide) {
case 'vscode':
return {
name: 'VS Code',
settingsPath: 'settings.json',
proxySetting: 'openai.url',
apiKeySetting: 'openai.apiKey',
instructions: 'Add these settings to your VS Code settings.json',
};
case 'cursor':
return {
name: 'Cursor',
settingsPath: 'settings.json',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Set these environment variables for Cursor',
};
case 'claude-code':
return {
name: 'Claude Code',
settingsPath: 'Shell environment',
proxySetting: 'ANTHROPIC_BASE_URL',
apiKeySetting: 'ANTHROPIC_API_KEY',
instructions: 'Run these export commands before starting Claude Code',
};
case 'jetbrains':
return {
name: 'JetBrains',
settingsPath: 'IDE Settings',
proxySetting: 'Base URL',
apiKeySetting: 'API Key',
instructions: String(result.content),
};
case 'opencide':
return {
name: 'Opencide',
settingsPath: '.env',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Add these lines to your .env file',
};
default: {
const exhaustiveCheck: never = result.ide;
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
export async function generateIdeConfig(
ide: string,
): Promise<{
success: boolean;
data?: {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
};
error?: string;
}> {
try {
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
if (!supportedIdes.includes(normalizedIde)) {
return { success: false, error: `Unsupported IDE: ${ide}` };
}
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
if (!result) {
return { success: false, error: 'Failed to generate IDE config: server not configured' };
}
return {
success: true,
data: mapIdeConfigResult(result),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
import { logger } from '../../utils/logger';
import { getServiceOrThrow } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
import { CloudAccountRepo } from '../database/cloudHandler';
export interface TrafficLogEntryParsed {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: {
message: string;
stack?: string;
status?: number;
body?: unknown;
};
metadata?: Record<string, unknown>;
}
function normalizeServiceError(error: unknown): string {
if (error instanceof Error && error.message.includes('is not registered')) {
return 'Proxy advanced service not initialized';
}
return error instanceof Error ? error.message : String(error);
}
export async function getRecentTrafficLogs(
limit: number = 50,
direction?: string,
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
try {
const rows = TrafficLogsRepo.getRecent(limit, direction);
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
timestamp: new Date(r.timestamp).toISOString(),
direction: r.direction,
requestId: r.requestId,
endpoint: r.endpoint,
method: r.method,
status: r.status,
headers: r.headers,
body: r.body,
chunk: r.chunk,
durationMs: r.durationMs,
error: r.error,
metadata: r.metadata,
}));
return { success: true, data: entries };
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getTrafficLogStats(): Promise<{
success: boolean;
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
error?: string;
}> {
try {
const stats = TrafficLogsRepo.getStats();
return { success: true, data: stats };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function exportTrafficLogs(
format: 'json' | 'csv',
): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const rows = TrafficLogsRepo.getAllForExport();
if (format === 'json') {
return { success: true, data: JSON.stringify(rows, null, 2) };
}
// CSV format
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
const rowsCsv = rows.map((e) =>
[
new Date(e.timestamp).toISOString(),
e.direction,
e.requestId,
e.endpoint,
e.method || '',
e.status != null ? e.status : '',
e.durationMs != null ? e.durationMs : '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
);
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
} catch (error) {
logger.error('Failed to export traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getParityCounters(): Promise<{
success: boolean;
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
return {
success: true,
data: tokenManager.getParityCounters(),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getCircuitBreakerStatus(): Promise<{
success: boolean;
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
const rawStatus = tokenManager.getCircuitBreakerStatus();
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
for (const [accountId, entry] of Object.entries(rawStatus)) {
states[accountId] = {
state: entry.state,
failures: entry.failureCount,
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
};
}
return {
success: true,
data: {
states,
globalEnabled: true,
},
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
avgLatency: number;
errorRate: number;
requestsPerMinute: number;
cacheHitRate: number;
};
error?: string;
}> {
try {
const proxyMetrics = getServiceOrThrow('proxyMetrics');
const metrics = proxyMetrics.getMetrics();
return {
success: true,
data: {
uptimeSeconds: Math.floor(process.uptime()),
totalRequests: metrics.totalRequests,
activeConnections: metrics.activeConnections,
avgLatency: metrics.avgLatency,
errorRate: metrics.errorRate,
requestsPerMinute: metrics.requestsPerMinute,
cacheHitRate: metrics.cacheHitRate,
},
};
} catch (error) {
logger.error('Failed to get proxy metrics', error);
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
return {
success: true,
data: proxyReplay.getRecentRequests(20),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function replayRequest(
requestId: string,
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const result = await proxyReplay.replayRequest(requestId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export interface ModelCapabilityEntry {
id: string;
object: string;
displayName?: string;
capabilities: {
vision: boolean;
thinking: boolean;
streaming: boolean;
jsonMode: boolean;
recommended: boolean;
};
limits: {
maxTokens?: number;
maxOutputTokens?: number;
thinkingBudget?: number;
};
}
export async function getModelCapabilities(): Promise<{
success: boolean;
data?: ModelCapabilityEntry[];
error?: string;
}> {
try {
const accounts = await CloudAccountRepo.getAccounts();
const modelMap = new Map<string, ModelCapabilityEntry>();
for (const account of accounts) {
if (!account.quota || !account.quota.models) {
continue;
}
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
if (modelMap.has(modelId)) {
continue;
}
modelMap.set(modelId, {
id: modelId,
object: 'model',
displayName: modelInfo.display_name,
capabilities: {
vision: modelInfo.supports_images ?? false,
thinking: modelInfo.supports_thinking ?? false,
streaming: true,
jsonMode: true,
recommended: modelInfo.recommended ?? false,
},
limits: {
maxTokens: modelInfo.max_tokens,
maxOutputTokens: modelInfo.max_output_tokens,
thinkingBudget: modelInfo.thinking_budget,
},
});
}
}
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
return { success: true, data };
} catch (error) {
logger.error('Failed to get model capabilities', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function mapIdeConfigResult(
result: IdeConfigResult,
): {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
} {
switch (result.ide) {
case 'vscode':
return {
name: 'VS Code',
settingsPath: 'settings.json',
proxySetting: 'openai.url',
apiKeySetting: 'openai.apiKey',
instructions: 'Add these settings to your VS Code settings.json',
};
case 'cursor':
return {
name: 'Cursor',
settingsPath: 'settings.json',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Set these environment variables for Cursor',
};
case 'claude-code':
return {
name: 'Claude Code',
settingsPath: 'Shell environment',
proxySetting: 'ANTHROPIC_BASE_URL',
apiKeySetting: 'ANTHROPIC_API_KEY',
instructions: 'Run these export commands before starting Claude Code',
};
case 'jetbrains':
return {
name: 'JetBrains',
settingsPath: 'IDE Settings',
proxySetting: 'Base URL',
apiKeySetting: 'API Key',
instructions: String(result.content),
};
case 'opencide':
return {
name: 'Opencide',
settingsPath: '.env',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Add these lines to your .env file',
};
default: {
const exhaustiveCheck: never = result.ide;
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
export async function generateIdeConfig(
ide: string,
): Promise<{
success: boolean;
data?: {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
};
error?: string;
}> {
try {
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
if (!supportedIdes.includes(normalizedIde)) {
return { success: false, error: `Unsupported IDE: ${ide}` };
}
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
if (!result) {
return { success: false, error: 'Failed to generate IDE config: server not configured' };
}
return {
success: true,
data: mapIdeConfigResult(result),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
+223 -223
View File
@@ -1,223 +1,223 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import {
getRecentTrafficLogs,
getTrafficLogStats,
exportTrafficLogs,
getParityCounters,
getCircuitBreakerStatus,
getProxyMetrics,
getRecentRequests,
generateIdeConfig,
replayRequest,
getModelCapabilities,
} from './handler';
const TrafficLogEntrySchema = z.object({
timestamp: z.string(),
direction: z.string(),
requestId: z.string(),
endpoint: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.unknown()).optional(),
body: z.unknown().optional(),
chunk: z.unknown().optional(),
durationMs: z.number().optional(),
error: z
.object({
message: z.string(),
stack: z.string().optional(),
status: z.number().optional(),
body: z.unknown().optional(),
})
.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const TrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(TrafficLogEntrySchema).optional(),
error: z.string().optional(),
});
const TrafficLogStatsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
byDirection: z.record(z.string(), z.number()),
byEndpoint: z.record(z.string(), z.number()),
total: z.number(),
})
.optional(),
error: z.string().optional(),
});
const ExportTrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.string().optional(),
error: z.string().optional(),
});
const ParityCountersResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
totalRequests: z.number(),
matchedRequests: z.number(),
parityViolations: z.number(),
lastUpdated: z.string(),
})
.optional(),
error: z.string().optional(),
});
const CircuitBreakerStatusResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
states: z.record(
z.string(),
z.object({
state: z.string(),
failures: z.number(),
lastFailure: z.string().nullable(),
}),
),
globalEnabled: z.boolean(),
})
.optional(),
error: z.string().optional(),
});
const ProxyMetricsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
uptimeSeconds: z.number(),
totalRequests: z.number(),
activeConnections: z.number(),
avgLatency: z.number(),
errorRate: z.number(),
requestsPerMinute: z.number(),
cacheHitRate: z.number(),
})
.optional(),
error: z.string().optional(),
});
const RecentRequestsResponseSchema = z.object({
success: z.boolean(),
data: z.array(z.unknown()).optional(),
error: z.string().optional(),
});
const ReplayRequestResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
original: z.unknown(),
newResponse: z.unknown(),
})
.optional(),
error: z.string().optional(),
});
const ModelCapabilitiesResponseSchema = z.object({
success: z.boolean(),
data: z
.array(
z.object({
id: z.string(),
object: z.string(),
displayName: z.string().optional(),
capabilities: z.object({
vision: z.boolean(),
thinking: z.boolean(),
streaming: z.boolean(),
jsonMode: z.boolean(),
recommended: z.boolean(),
}),
limits: z.object({
maxTokens: z.number().optional(),
maxOutputTokens: z.number().optional(),
thinkingBudget: z.number().optional(),
}),
}),
)
.optional(),
error: z.string().optional(),
});
const IdeConfigResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
})
.optional(),
error: z.string().optional(),
});
export const proxyAdvancedRouter = os.router({
getRecentTrafficLogs: os
.input(
z.object({
limit: z.number().int().min(1).max(1000).optional(),
direction: z.string().optional(),
}),
)
.output(TrafficLogsResponseSchema)
.handler(async ({ input }) => {
return getRecentTrafficLogs(input.limit, input.direction);
}),
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
return getTrafficLogStats();
}),
exportTrafficLogs: os
.input(z.object({ format: z.enum(['json', 'csv']) }))
.output(ExportTrafficLogsResponseSchema)
.handler(async ({ input }) => {
return exportTrafficLogs(input.format);
}),
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
return getParityCounters();
}),
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
return getCircuitBreakerStatus();
}),
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
return getProxyMetrics();
}),
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
return getRecentRequests();
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
}),
replayRequest: os
.input(z.object({ requestId: z.string() }))
.output(ReplayRequestResponseSchema)
.handler(async ({ input }) => {
return replayRequest(input.requestId);
}),
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
return getModelCapabilities();
}),
});
import { z } from 'zod';
import { os } from '@orpc/server';
import {
getRecentTrafficLogs,
getTrafficLogStats,
exportTrafficLogs,
getParityCounters,
getCircuitBreakerStatus,
getProxyMetrics,
getRecentRequests,
generateIdeConfig,
replayRequest,
getModelCapabilities,
} from './handler';
const TrafficLogEntrySchema = z.object({
timestamp: z.string(),
direction: z.string(),
requestId: z.string(),
endpoint: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.unknown()).optional(),
body: z.unknown().optional(),
chunk: z.unknown().optional(),
durationMs: z.number().optional(),
error: z
.object({
message: z.string(),
stack: z.string().optional(),
status: z.number().optional(),
body: z.unknown().optional(),
})
.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const TrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(TrafficLogEntrySchema).optional(),
error: z.string().optional(),
});
const TrafficLogStatsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
byDirection: z.record(z.string(), z.number()),
byEndpoint: z.record(z.string(), z.number()),
total: z.number(),
})
.optional(),
error: z.string().optional(),
});
const ExportTrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.string().optional(),
error: z.string().optional(),
});
const ParityCountersResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
totalRequests: z.number(),
matchedRequests: z.number(),
parityViolations: z.number(),
lastUpdated: z.string(),
})
.optional(),
error: z.string().optional(),
});
const CircuitBreakerStatusResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
states: z.record(
z.string(),
z.object({
state: z.string(),
failures: z.number(),
lastFailure: z.string().nullable(),
}),
),
globalEnabled: z.boolean(),
})
.optional(),
error: z.string().optional(),
});
const ProxyMetricsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
uptimeSeconds: z.number(),
totalRequests: z.number(),
activeConnections: z.number(),
avgLatency: z.number(),
errorRate: z.number(),
requestsPerMinute: z.number(),
cacheHitRate: z.number(),
})
.optional(),
error: z.string().optional(),
});
const RecentRequestsResponseSchema = z.object({
success: z.boolean(),
data: z.array(z.unknown()).optional(),
error: z.string().optional(),
});
const ReplayRequestResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
original: z.unknown(),
newResponse: z.unknown(),
})
.optional(),
error: z.string().optional(),
});
const ModelCapabilitiesResponseSchema = z.object({
success: z.boolean(),
data: z
.array(
z.object({
id: z.string(),
object: z.string(),
displayName: z.string().optional(),
capabilities: z.object({
vision: z.boolean(),
thinking: z.boolean(),
streaming: z.boolean(),
jsonMode: z.boolean(),
recommended: z.boolean(),
}),
limits: z.object({
maxTokens: z.number().optional(),
maxOutputTokens: z.number().optional(),
thinkingBudget: z.number().optional(),
}),
}),
)
.optional(),
error: z.string().optional(),
});
const IdeConfigResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
})
.optional(),
error: z.string().optional(),
});
export const proxyAdvancedRouter = os.router({
getRecentTrafficLogs: os
.input(
z.object({
limit: z.number().int().min(1).max(1000).optional(),
direction: z.string().optional(),
}),
)
.output(TrafficLogsResponseSchema)
.handler(async ({ input }) => {
return getRecentTrafficLogs(input.limit, input.direction);
}),
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
return getTrafficLogStats();
}),
exportTrafficLogs: os
.input(z.object({ format: z.enum(['json', 'csv']) }))
.output(ExportTrafficLogsResponseSchema)
.handler(async ({ input }) => {
return exportTrafficLogs(input.format);
}),
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
return getParityCounters();
}),
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
return getCircuitBreakerStatus();
}),
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
return getProxyMetrics();
}),
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
return getRecentRequests();
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
}),
replayRequest: os
.input(z.object({ requestId: z.string() }))
.output(ReplayRequestResponseSchema)
.handler(async ({ input }) => {
return replayRequest(input.requestId);
}),
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
return getModelCapabilities();
}),
});
+39 -39
View File
@@ -1,39 +1,39 @@
/**
* Service registry for proxy-advanced IPC handlers to access
* NestJS service instances without DI coupling.
*
* Services auto-register on construction.
*/
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
interface ProxyAdvancedRegistry {
tokenManager: TokenManagerService | null;
proxyMetrics: ProxyMetricsService | null;
proxyReplay: ProxyReplayService | null;
proxyIdeConfig: ProxyIdeConfigService | null;
}
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
tokenManager: null,
proxyMetrics: null,
proxyReplay: null,
proxyIdeConfig: null,
};
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
key: K,
instance: ProxyAdvancedRegistry[K],
): void {
proxyAdvancedRegistry[key] = instance;
}
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
key: K,
): NonNullable<ProxyAdvancedRegistry[K]> {
const svc = proxyAdvancedRegistry[key];
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
/**
* Service registry for proxy-advanced IPC handlers to access
* NestJS service instances without DI coupling.
*
* Services auto-register on construction.
*/
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
interface ProxyAdvancedRegistry {
tokenManager: TokenManagerService | null;
proxyMetrics: ProxyMetricsService | null;
proxyReplay: ProxyReplayService | null;
proxyIdeConfig: ProxyIdeConfigService | null;
}
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
tokenManager: null,
proxyMetrics: null,
proxyReplay: null,
proxyIdeConfig: null,
};
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
key: K,
instance: ProxyAdvancedRegistry[K],
): void {
proxyAdvancedRegistry[key] = instance;
}
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
key: K,
): NonNullable<ProxyAdvancedRegistry[K]> {
const svc = proxyAdvancedRegistry[key];
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
+61 -61
View File
@@ -1,61 +1,61 @@
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 { usageRouter } from './usage/router';
import { proxyAdvancedRouter } from './proxy-advanced/router';
import { os } from '@orpc/server';
import { z } from 'zod';
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
import { systemHandler } from './system/handler';
import { logger } from '../utils/logger';
// 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) {
logger.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();
}),
closeGeminiNexus: os.output(z.void()).handler(async () => {
await closeGeminiNexus();
}),
startGeminiNexus: os.output(z.void()).handler(async () => {
await startGeminiNexus();
}),
}),
account: accountRouter,
cloud: cloudRouter,
config: configRouter,
gateway: gatewayRouter,
usage: usageRouter,
proxyAdvanced: proxyAdvancedRouter,
system: systemHandler,
});
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 { usageRouter } from './usage/router';
import { proxyAdvancedRouter } from './proxy-advanced/router';
import { os } from '@orpc/server';
import { z } from 'zod';
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
import { systemHandler } from './system/handler';
import { logger } from '../utils/logger';
// 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) {
logger.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();
}),
closeGeminiNexus: os.output(z.void()).handler(async () => {
await closeGeminiNexus();
}),
startGeminiNexus: os.output(z.void()).handler(async () => {
await startGeminiNexus();
}),
}),
account: accountRouter,
cloud: cloudRouter,
config: configRouter,
gateway: gatewayRouter,
usage: usageRouter,
proxyAdvanced: proxyAdvancedRouter,
system: systemHandler,
});
+89 -89
View File
@@ -1,89 +1,89 @@
import { type DeviceProfile } from '../types/account';
import { logger } from '../utils/logger';
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
import { applyDeviceProfile } from './device/handler';
import {
type SwitchFailureReason,
recordSwitchFailure,
recordSwitchSuccess,
} from './switchMetrics';
export interface SwitchFlowOptions {
scope: 'local' | 'cloud';
targetProfile: DeviceProfile | null;
applyFingerprint: boolean;
processExitTimeoutMs: number;
performSwitch: () => Promise<void>;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReason {
if (stage === 'close') {
return 'process_close_failed';
}
if (stage === 'missing_profile') {
return 'missing_bound_profile';
}
if (stage === 'apply') {
return 'apply_device_profile_failed';
}
if (stage === 'switch') {
return 'perform_switch_failed';
}
if (stage === 'start') {
return 'start_process_failed';
}
// Keep legacy compatibility with reason encoded in thrown errors.
if (error instanceof Error && error.message.includes('missing bound device profile')) {
return 'missing_bound_profile';
}
if (error instanceof Error && error.message.includes('device_apply_failed')) {
return 'apply_device_profile_failed';
}
return 'unknown';
}
export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<void> {
const { scope, targetProfile, applyFingerprint, processExitTimeoutMs, performSwitch } = options;
let stage = 'close';
try {
await closeGeminiNexus();
try {
await _waitForProcessExit(processExitTimeoutMs);
} catch (error) {
logger.warn('Process did not exit cleanly within timeout, but proceeding...', error);
}
stage = 'apply';
if (applyFingerprint) {
if (!targetProfile) {
stage = 'missing_profile';
throw new Error('Account has no bound identity profile');
}
applyDeviceProfile(targetProfile);
} else {
logger.warn(
'Identity profile apply is disabled by CRACK_IDENTITY_PROFILE_APPLY_ENABLED / CRACK_DEVICE_FINGERPRINT_ENABLED',
);
}
stage = 'switch';
await performSwitch();
stage = 'start';
await startGeminiNexus();
recordSwitchSuccess(scope);
} catch (error) {
const reason = toSwitchFailureReason(stage, error);
const message = getErrorMessage(error);
recordSwitchFailure(scope, reason, message);
throw error;
}
}
import { type DeviceProfile } from '../types/account';
import { logger } from '../utils/logger';
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
import { applyDeviceProfile } from './device/handler';
import {
type SwitchFailureReason,
recordSwitchFailure,
recordSwitchSuccess,
} from './switchMetrics';
export interface SwitchFlowOptions {
scope: 'local' | 'cloud';
targetProfile: DeviceProfile | null;
applyFingerprint: boolean;
processExitTimeoutMs: number;
performSwitch: () => Promise<void>;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReason {
if (stage === 'close') {
return 'process_close_failed';
}
if (stage === 'missing_profile') {
return 'missing_bound_profile';
}
if (stage === 'apply') {
return 'apply_device_profile_failed';
}
if (stage === 'switch') {
return 'perform_switch_failed';
}
if (stage === 'start') {
return 'start_process_failed';
}
// Keep legacy compatibility with reason encoded in thrown errors.
if (error instanceof Error && error.message.includes('missing bound device profile')) {
return 'missing_bound_profile';
}
if (error instanceof Error && error.message.includes('device_apply_failed')) {
return 'apply_device_profile_failed';
}
return 'unknown';
}
export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<void> {
const { scope, targetProfile, applyFingerprint, processExitTimeoutMs, performSwitch } = options;
let stage = 'close';
try {
await closeGeminiNexus();
try {
await _waitForProcessExit(processExitTimeoutMs);
} catch (error) {
logger.warn('Process did not exit cleanly within timeout, but proceeding...', error);
}
stage = 'apply';
if (applyFingerprint) {
if (!targetProfile) {
stage = 'missing_profile';
throw new Error('Account has no bound identity profile');
}
applyDeviceProfile(targetProfile);
} else {
logger.warn(
'Identity profile apply is disabled by CRACK_IDENTITY_PROFILE_APPLY_ENABLED / CRACK_DEVICE_FINGERPRINT_ENABLED',
);
}
stage = 'switch';
await performSwitch();
stage = 'start';
await startGeminiNexus();
recordSwitchSuccess(scope);
} catch (error) {
const reason = toSwitchFailureReason(stage, error);
const message = getErrorMessage(error);
recordSwitchFailure(scope, reason, message);
throw error;
}
}
+80 -80
View File
@@ -1,80 +1,80 @@
import { logger } from '../utils/logger';
type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
interface SwitchTask {
owner: SwitchOwner;
action: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
let activeSwitchOwner: SwitchOwner | null = null;
const pendingQueue: SwitchTask[] = [];
let processing = false;
function processQueue(): void {
if (processing) {
return;
}
const nextTask = pendingQueue.shift();
if (!nextTask) {
return;
}
processing = true;
activeSwitchOwner = nextTask.owner;
logger.info(`Acquired switch guard: ${nextTask.owner}`);
Promise.resolve()
.then(nextTask.action)
.then((result) => {
nextTask.resolve(result);
})
.catch((error) => {
nextTask.reject(error);
})
.finally(() => {
logger.info(`Released switch guard: ${nextTask.owner}`);
activeSwitchOwner = null;
processing = false;
processQueue();
});
}
export async function runWithSwitchGuard<T>(
owner: SwitchOwner,
action: () => Promise<T>,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
pendingQueue.push({
owner,
action: async () => await action(),
resolve: (value) => {
resolve(value as T);
},
reject,
});
logger.info(
`Queued switch request: ${owner} (active=${activeSwitchOwner || 'none'}, pending=${pendingQueue.length})`,
);
processQueue();
});
}
export function getActiveSwitchOwner(): SwitchOwner | null {
return activeSwitchOwner;
}
export function getSwitchGuardSnapshot(): {
activeOwner: SwitchOwner | null;
pendingOwners: SwitchOwner[];
pendingCount: number;
} {
return {
activeOwner: activeSwitchOwner,
pendingOwners: pendingQueue.map((item) => item.owner),
pendingCount: pendingQueue.length,
};
}
import { logger } from '../utils/logger';
type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
interface SwitchTask {
owner: SwitchOwner;
action: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
let activeSwitchOwner: SwitchOwner | null = null;
const pendingQueue: SwitchTask[] = [];
let processing = false;
function processQueue(): void {
if (processing) {
return;
}
const nextTask = pendingQueue.shift();
if (!nextTask) {
return;
}
processing = true;
activeSwitchOwner = nextTask.owner;
logger.info(`Acquired switch guard: ${nextTask.owner}`);
Promise.resolve()
.then(nextTask.action)
.then((result) => {
nextTask.resolve(result);
})
.catch((error) => {
nextTask.reject(error);
})
.finally(() => {
logger.info(`Released switch guard: ${nextTask.owner}`);
activeSwitchOwner = null;
processing = false;
processQueue();
});
}
export async function runWithSwitchGuard<T>(
owner: SwitchOwner,
action: () => Promise<T>,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
pendingQueue.push({
owner,
action: async () => await action(),
resolve: (value) => {
resolve(value as T);
},
reject,
});
logger.info(
`Queued switch request: ${owner} (active=${activeSwitchOwner || 'none'}, pending=${pendingQueue.length})`,
);
processQueue();
});
}
export function getActiveSwitchOwner(): SwitchOwner | null {
return activeSwitchOwner;
}
export function getSwitchGuardSnapshot(): {
activeOwner: SwitchOwner | null;
pendingOwners: SwitchOwner[];
pendingCount: number;
} {
return {
activeOwner: activeSwitchOwner,
pendingOwners: pendingQueue.map((item) => item.owner),
pendingCount: pendingQueue.length,
};
}
+113 -113
View File
@@ -1,113 +1,113 @@
import { logger } from '../utils/logger';
type SwitchScope = 'local' | 'cloud';
export type SwitchFailureReason =
| 'unknown'
| 'process_close_failed'
| 'missing_bound_profile'
| 'apply_device_profile_failed'
| 'perform_switch_failed'
| 'start_process_failed';
interface SwitchFailureMetadata {
reason: SwitchFailureReason;
message: string;
occurredAt: number;
}
interface SwitchMetricBucket {
switchSuccess: number;
switchFailure: number;
rollbackAttempt: number;
rollbackSuccess: number;
rollbackFailure: number;
failureReasons: Record<SwitchFailureReason, number>;
lastFailure: SwitchFailureMetadata | null;
}
type SwitchMetricMap = Record<SwitchScope, SwitchMetricBucket>;
const metrics: SwitchMetricMap = {
local: {
switchSuccess: 0,
switchFailure: 0,
rollbackAttempt: 0,
rollbackSuccess: 0,
rollbackFailure: 0,
failureReasons: {
unknown: 0,
process_close_failed: 0,
missing_bound_profile: 0,
apply_device_profile_failed: 0,
perform_switch_failed: 0,
start_process_failed: 0,
},
lastFailure: null,
},
cloud: {
switchSuccess: 0,
switchFailure: 0,
rollbackAttempt: 0,
rollbackSuccess: 0,
rollbackFailure: 0,
failureReasons: {
unknown: 0,
process_close_failed: 0,
missing_bound_profile: 0,
apply_device_profile_failed: 0,
perform_switch_failed: 0,
start_process_failed: 0,
},
lastFailure: null,
},
};
function logSnapshot(scope: SwitchScope, reason: string): void {
logger.info(`[switch-metrics] ${scope} ${reason}`, metrics[scope]);
}
export function recordSwitchSuccess(scope: SwitchScope): void {
metrics[scope].switchSuccess += 1;
logSnapshot(scope, 'switch_success');
}
export function recordSwitchFailure(
scope: SwitchScope,
reason: SwitchFailureReason = 'unknown',
message: string = '',
): void {
metrics[scope].switchFailure += 1;
metrics[scope].failureReasons[reason] += 1;
metrics[scope].lastFailure = {
reason,
message,
occurredAt: Date.now(),
};
logSnapshot(scope, 'switch_failure');
}
export function recordSwitchRollback(scope: SwitchScope, success: boolean): void {
metrics[scope].rollbackAttempt += 1;
if (success) {
metrics[scope].rollbackSuccess += 1;
} else {
metrics[scope].rollbackFailure += 1;
}
logSnapshot(scope, success ? 'rollback_success' : 'rollback_failure');
}
export function getSwitchMetricsSnapshot(): SwitchMetricMap {
return {
local: {
...metrics.local,
failureReasons: { ...metrics.local.failureReasons },
lastFailure: metrics.local.lastFailure ? { ...metrics.local.lastFailure } : null,
},
cloud: {
...metrics.cloud,
failureReasons: { ...metrics.cloud.failureReasons },
lastFailure: metrics.cloud.lastFailure ? { ...metrics.cloud.lastFailure } : null,
},
};
}
import { logger } from '../utils/logger';
type SwitchScope = 'local' | 'cloud';
export type SwitchFailureReason =
| 'unknown'
| 'process_close_failed'
| 'missing_bound_profile'
| 'apply_device_profile_failed'
| 'perform_switch_failed'
| 'start_process_failed';
interface SwitchFailureMetadata {
reason: SwitchFailureReason;
message: string;
occurredAt: number;
}
interface SwitchMetricBucket {
switchSuccess: number;
switchFailure: number;
rollbackAttempt: number;
rollbackSuccess: number;
rollbackFailure: number;
failureReasons: Record<SwitchFailureReason, number>;
lastFailure: SwitchFailureMetadata | null;
}
type SwitchMetricMap = Record<SwitchScope, SwitchMetricBucket>;
const metrics: SwitchMetricMap = {
local: {
switchSuccess: 0,
switchFailure: 0,
rollbackAttempt: 0,
rollbackSuccess: 0,
rollbackFailure: 0,
failureReasons: {
unknown: 0,
process_close_failed: 0,
missing_bound_profile: 0,
apply_device_profile_failed: 0,
perform_switch_failed: 0,
start_process_failed: 0,
},
lastFailure: null,
},
cloud: {
switchSuccess: 0,
switchFailure: 0,
rollbackAttempt: 0,
rollbackSuccess: 0,
rollbackFailure: 0,
failureReasons: {
unknown: 0,
process_close_failed: 0,
missing_bound_profile: 0,
apply_device_profile_failed: 0,
perform_switch_failed: 0,
start_process_failed: 0,
},
lastFailure: null,
},
};
function logSnapshot(scope: SwitchScope, reason: string): void {
logger.info(`[switch-metrics] ${scope} ${reason}`, metrics[scope]);
}
export function recordSwitchSuccess(scope: SwitchScope): void {
metrics[scope].switchSuccess += 1;
logSnapshot(scope, 'switch_success');
}
export function recordSwitchFailure(
scope: SwitchScope,
reason: SwitchFailureReason = 'unknown',
message: string = '',
): void {
metrics[scope].switchFailure += 1;
metrics[scope].failureReasons[reason] += 1;
metrics[scope].lastFailure = {
reason,
message,
occurredAt: Date.now(),
};
logSnapshot(scope, 'switch_failure');
}
export function recordSwitchRollback(scope: SwitchScope, success: boolean): void {
metrics[scope].rollbackAttempt += 1;
if (success) {
metrics[scope].rollbackSuccess += 1;
} else {
metrics[scope].rollbackFailure += 1;
}
logSnapshot(scope, success ? 'rollback_success' : 'rollback_failure');
}
export function getSwitchMetricsSnapshot(): SwitchMetricMap {
return {
local: {
...metrics.local,
failureReasons: { ...metrics.local.failureReasons },
lastFailure: metrics.local.lastFailure ? { ...metrics.local.lastFailure } : null,
},
cloud: {
...metrics.cloud,
failureReasons: { ...metrics.cloud.failureReasons },
lastFailure: metrics.cloud.lastFailure ? { ...metrics.cloud.lastFailure } : null,
},
};
}
+72 -72
View File
@@ -1,72 +1,72 @@
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
import { shell } from 'electron';
import { getAgentDir } from '../../utils/paths';
// Schema for IP info
const IpInfoSchema = z.object({
address: z.string(),
name: z.string(),
isRecommended: z.boolean(),
});
export const systemHandler = os.router({
// Get all available local IPs with their adapter names
get_local_ips: os.output(z.array(IpInfoSchema)).handler(async () => {
const nets = networkInterfaces();
const results: { address: string; name: string; isRecommended: boolean }[] = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
const family = net.family;
const isIPv4 = family === 'IPv4';
if (!isIPv4 || net.internal) {
continue;
}
const addr = net.address;
// Skip non-LAN addresses
if (addr.startsWith('169.254.')) continue; // APIPA
if (addr.startsWith('198.18.')) continue; // CGNAT / VPN
// Determine if this is a recommended (likely real LAN) address
let isRecommended = false;
const lowerName = name.toLowerCase();
if (addr.startsWith('192.168.') || addr.startsWith('10.')) {
if (
lowerName.includes('wlan') ||
lowerName.includes('wi-fi') ||
lowerName.includes('wireless') ||
lowerName === '以太网' ||
lowerName === 'ethernet' ||
lowerName.match(/^eth\d/)
) {
isRecommended = true;
}
}
results.push({ address: addr, name, isRecommended });
}
}
// Sort: recommended first, then by address
results.sort((a, b) => {
if (a.isRecommended !== b.isRecommended) {
return b.isRecommended ? 1 : -1;
}
return a.address.localeCompare(b.address);
});
return results;
}),
// Open log directory in file explorer
openLogDirectory: os.output(z.void()).handler(async () => {
const logDir = getAgentDir();
await shell.openPath(logDir);
}),
});
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
import { shell } from 'electron';
import { getAgentDir } from '../../utils/paths';
// Schema for IP info
const IpInfoSchema = z.object({
address: z.string(),
name: z.string(),
isRecommended: z.boolean(),
});
export const systemHandler = os.router({
// Get all available local IPs with their adapter names
get_local_ips: os.output(z.array(IpInfoSchema)).handler(async () => {
const nets = networkInterfaces();
const results: { address: string; name: string; isRecommended: boolean }[] = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
const family = net.family;
const isIPv4 = family === 'IPv4';
if (!isIPv4 || net.internal) {
continue;
}
const addr = net.address;
// Skip non-LAN addresses
if (addr.startsWith('169.254.')) continue; // APIPA
if (addr.startsWith('198.18.')) continue; // CGNAT / VPN
// Determine if this is a recommended (likely real LAN) address
let isRecommended = false;
const lowerName = name.toLowerCase();
if (addr.startsWith('192.168.') || addr.startsWith('10.')) {
if (
lowerName.includes('wlan') ||
lowerName.includes('wi-fi') ||
lowerName.includes('wireless') ||
lowerName === '以太网' ||
lowerName === 'ethernet' ||
lowerName.match(/^eth\d/)
) {
isRecommended = true;
}
}
results.push({ address: addr, name, isRecommended });
}
}
// Sort: recommended first, then by address
results.sort((a, b) => {
if (a.isRecommended !== b.isRecommended) {
return b.isRecommended ? 1 : -1;
}
return a.address.localeCompare(b.address);
});
return results;
}),
// Open log directory in file explorer
openLogDirectory: os.output(z.void()).handler(async () => {
const logDir = getAgentDir();
await shell.openPath(logDir);
}),
});
+33 -33
View File
@@ -1,33 +1,33 @@
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;
});
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;
});
+8 -8
View File
@@ -1,8 +1,8 @@
import { getCurrentThemeMode, setThemeMode, toggleThemeMode } from './handlers';
import { os } from '@orpc/server';
export const theme = os.router({
getCurrentThemeMode,
setThemeMode,
toggleThemeMode,
});
import { getCurrentThemeMode, setThemeMode, toggleThemeMode } from './handlers';
import { os } from '@orpc/server';
export const theme = os.router({
getCurrentThemeMode,
setThemeMode,
toggleThemeMode,
});
+3 -3
View File
@@ -1,3 +1,3 @@
import { z } from 'zod';
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
import { z } from 'zod';
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
+196 -196
View File
@@ -1,196 +1,196 @@
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;
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy existing tray', e);
}
tray = null;
logger.info('Destroyed existing tray before creating new one');
}
const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
const icon = nativeImage.createFromPath(iconPath);
// Verify icon is valid before creating tray
if (icon.isEmpty()) {
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
return;
}
tray = new Tray(icon);
tray.setToolTip('Gemini Nexus');
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);
}
export function destroyTray() {
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy tray', e);
}
tray = null;
logger.info('Tray destroyed');
}
}
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;
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy existing tray', e);
}
tray = null;
logger.info('Destroyed existing tray before creating new one');
}
const inDevelopment = process.env.NODE_ENV === 'development';
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
const iconPath = inDevelopment
? path.join(process.cwd(), 'src/assets/tray.png')
: path.join(process.resourcesPath, 'assets', 'tray.png');
logger.info(
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
);
const icon = nativeImage.createFromPath(iconPath);
// Verify icon is valid before creating tray
if (icon.isEmpty()) {
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
return;
}
tray = new Tray(icon);
tray.setToolTip('Gemini Nexus');
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);
}
export function destroyTray() {
if (tray) {
try {
tray.destroy();
} catch (e) {
logger.error('Failed to destroy tray', e);
}
tray = null;
logger.info('Tray destroyed');
}
}
+72 -72
View File
@@ -1,72 +1,72 @@
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: '账号已被禁用',
};
const ru: TrayTexts = {
current: 'Текущий',
quota: 'Квота',
switch_next: 'Переключить на следующий аккаунт',
refresh_current: 'Обновить текущую квоту',
show_window: 'Показать главное окно',
quit: 'Выйти из приложения',
no_account: 'Нет аккаунта',
unknown_quota: 'Неизвестно',
forbidden: 'Аккаунт заблокирован',
};
const vi: TrayTexts = {
current: 'Hiện tại',
quota: 'Quota',
switch_next: 'Chuyển sang tài khoản tiếp theo',
refresh_current: 'Làm mới quota hiện tại',
show_window: 'Hiện cửa sổ chính',
quit: 'Thoát ứng dụng',
no_account: 'Không có tài khoản',
unknown_quota: 'Không rõ',
forbidden: 'Tài khoản bị cấm',
};
export function getTrayTexts(lang: string = 'en'): TrayTexts {
if (lang.startsWith('zh')) {
return zh;
}
if (lang.startsWith('ru')) {
return ru;
}
if (lang.startsWith('vi')) {
return vi;
}
return en;
}
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: '账号已被禁用',
};
const ru: TrayTexts = {
current: 'Текущий',
quota: 'Квота',
switch_next: 'Переключить на следующий аккаунт',
refresh_current: 'Обновить текущую квоту',
show_window: 'Показать главное окно',
quit: 'Выйти из приложения',
no_account: 'Нет аккаунта',
unknown_quota: 'Неизвестно',
forbidden: 'Аккаунт заблокирован',
};
const vi: TrayTexts = {
current: 'Hiện tại',
quota: 'Quota',
switch_next: 'Chuyển sang tài khoản tiếp theo',
refresh_current: 'Làm mới quota hiện tại',
show_window: 'Hiện cửa sổ chính',
quit: 'Thoát ứng dụng',
no_account: 'Không có tài khoản',
unknown_quota: 'Không rõ',
forbidden: 'Tài khoản bị cấm',
};
export function getTrayTexts(lang: string = 'en'): TrayTexts {
if (lang.startsWith('zh')) {
return zh;
}
if (lang.startsWith('ru')) {
return ru;
}
if (lang.startsWith('vi')) {
return vi;
}
return en;
}
+70 -70
View File
@@ -1,70 +1,70 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import { TokenUsageRepo } from '../database/usageHandler';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
const RecordUsageInputSchema = z.object({
accountId: z.string(),
model: z.string(),
promptTokens: z.number().int().min(0),
completionTokens: z.number().int().min(0),
totalTokens: z.number().int().min(0),
timestamp: z.number().int(),
requestType: z.string().optional(),
});
const UsageQueryInputSchema = z.object({
accountId: z.string().optional(),
start: z.number().int().optional(),
end: z.number().int().optional(),
});
export const usageRouter = os.router({
recordUsage: os
.input(RecordUsageInputSchema)
.output(z.void())
.handler(async ({ input }) => {
TokenUsageRepo.recordUsage(input);
}),
getUsageByHour: os
.input(UsageQueryInputSchema)
.output(z.array(HourlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
}),
getUsageByDay: os
.input(UsageQueryInputSchema)
.output(z.array(DailyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
}),
getUsageByWeek: os
.input(UsageQueryInputSchema)
.output(z.array(WeeklyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
}),
getUsageByMonth: os
.input(UsageQueryInputSchema)
.output(z.array(MonthlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
}),
getUsageByModel: os
.input(UsageQueryInputSchema)
.output(z.array(ModelUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
}),
});
import { z } from 'zod';
import { os } from '@orpc/server';
import { TokenUsageRepo } from '../database/usageHandler';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
const RecordUsageInputSchema = z.object({
accountId: z.string(),
model: z.string(),
promptTokens: z.number().int().min(0),
completionTokens: z.number().int().min(0),
totalTokens: z.number().int().min(0),
timestamp: z.number().int(),
requestType: z.string().optional(),
});
const UsageQueryInputSchema = z.object({
accountId: z.string().optional(),
start: z.number().int().optional(),
end: z.number().int().optional(),
});
export const usageRouter = os.router({
recordUsage: os
.input(RecordUsageInputSchema)
.output(z.void())
.handler(async ({ input }) => {
TokenUsageRepo.recordUsage(input);
}),
getUsageByHour: os
.input(UsageQueryInputSchema)
.output(z.array(HourlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
}),
getUsageByDay: os
.input(UsageQueryInputSchema)
.output(z.array(DailyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
}),
getUsageByWeek: os
.input(UsageQueryInputSchema)
.output(z.array(WeeklyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
}),
getUsageByMonth: os
.input(UsageQueryInputSchema)
.output(z.array(MonthlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
}),
getUsageByModel: os
.input(UsageQueryInputSchema)
.output(z.array(ModelUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
}),
});
+26 -26
View File
@@ -1,26 +1,26 @@
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();
});
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();
});
+8 -8
View File
@@ -1,8 +1,8 @@
import { closeWindow, maximizeWindow, minimizeWindow } from './handlers';
import { os } from '@orpc/server';
export const window = os.router({
minimizeWindow,
maximizeWindow,
closeWindow,
});
import { closeWindow, maximizeWindow, minimizeWindow } from './handlers';
import { os } from '@orpc/server';
export const window = os.router({
minimizeWindow,
maximizeWindow,
closeWindow,
});