commit 43e2a2f78f1c34a393a287b2ec5be51475c98fb5 Author: admin Date: Fri May 22 23:20:10 2026 +0400 AG X v2.0.3 - Antigravity fork with multi-provider support Features: - Welcome screen on first run (provider choice before LS starts) - 15+ AI providers (Google Gemini, OpenAI, Anthropic, DeepSeek, Ollama, etc.) - Provider config syncs to endpoints.json for translation proxy - Built-in Node.js translation proxy for non-native backends - Auto-update support, tray integration, URI scheme handler diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af67310 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.DS_Store +Thumbs.db +*.map +releases/*.deb diff --git a/README.md b/README.md new file mode 100644 index 0000000..aee4ab9 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# AG X (Antigravity fork) v2.0.3 + +AI-Powered Code Intelligence โ€” agentic desktop application with multi-provider AI support. + +## What's New in v2.0.3 + +### ๐Ÿ› Critical Fix: Provider Selection Flow +- **Welcome screen now shows BEFORE the Language Server starts** โ€” on first run, the user picks a provider before the LS launches, preventing the Google OAuth prompt from appearing for non-Google providers. +- **Provider config syncs to `~/.codex/endpoints.json`** โ€” the provider choice from the welcome screen and settings panel is properly synced to the endpoints config that the translation proxy reads. +- **"Another AI Provider" flow fixed** โ€” when user picks a custom provider, the settings panel opens and the app waits for the user to configure and save their provider before starting the Language Server. + +### Changes +| File | Change | +|---|---| +| `main.js` | Restructured startup: welcome screen before LS start | +| `main.js` | Added `syncProviderToEndpoints()` function | +| `main.js` | Google Gemini handler sets active endpoint in `endpoints.json` | +| `main.js` | Custom provider handler waits for settings save before continuing | +| `providerSettings.js` | Save handler syncs to `endpoints.json` and emits `provider:settings-saved` | +| `providerSettings.js` | Added close-settings IPC listener | +| `settings.html` | Save handler shows restart notification and auto-closes | + +## Features +- ๐Ÿ”ฎ Google Gemini (OAuth) โ€” zero config +- ๐Ÿ”Œ 15+ AI Providers (OpenAI, Anthropic, DeepSeek, Ollama, etc.) +- ๐Ÿง  Built-in translation proxy for non-native backends +- ๐Ÿ”„ Auto-update support +- ๐Ÿ–ฅ๏ธ System tray integration + +## Installation +```bash +sudo dpkg -i ag-x_2.0.3_amd64.deb +``` + +## First Run +On first launch, AG X shows a welcome screen where you choose: +1. **Google Gemini** โ€” Sign in with Google (OAuth, no API key needed) +2. **Another AI Provider** โ€” Pick from 15+ providers and enter your API key + +## Configuration +- Provider settings: `~/.ag-x/ag-x/provider_config.json` +- Proxy endpoints: `~/.codex/endpoints.json` +- Menu โ†’ AI Provider Settings to change provider anytime diff --git a/dist/__mocks__/electron-updater.js b/dist/__mocks__/electron-updater.js new file mode 100644 index 0000000..2a044f2 --- /dev/null +++ b/dist/__mocks__/electron-updater.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.autoUpdater = exports.autoUpdaterEvents = void 0; +/** + * Shared electron-updater mock for all test files. + * + * This file is automatically used by Vitest when a test calls + * `vi.mock('electron-updater')` without a factory argument. + * + * The `autoUpdaterEvents` export allows tests to trigger event callbacks + * that were registered via `autoUpdater.on(event, callback)`. + */ +const vitest_1 = require("vitest"); +exports.autoUpdaterEvents = {}; +exports.autoUpdater = { + autoDownload: false, + autoInstallOnAppQuit: false, + forceDevUpdateConfig: false, + updateConfigPath: '', + on: vitest_1.vi.fn().mockImplementation((event, cb) => { + exports.autoUpdaterEvents[event] = cb; + }), + checkForUpdates: vitest_1.vi.fn().mockResolvedValue(undefined), + quitAndInstall: vitest_1.vi.fn(), +}; diff --git a/dist/__mocks__/electron.js b/dist/__mocks__/electron.js new file mode 100644 index 0000000..dc39d33 --- /dev/null +++ b/dist/__mocks__/electron.js @@ -0,0 +1,145 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ipcRenderer = exports.contextBridge = exports.shell = exports.Notification = exports.Menu = exports.MenuItem = exports.Tray = exports.nativeImage = exports.protocol = exports.ipcMain = exports.dialog = exports.WebContentsView = exports.BrowserWindow = exports.app = void 0; +/** + * Shared Electron mock for all test files. + * + * This file is automatically used by Vitest when a test calls + * `vi.mock('electron')` without a factory argument, because it lives + * in the `__mocks__` directory. + * + * Individual tests can still override specific properties on these + * mock objects in their `beforeEach` or `vi.hoisted` blocks. + */ +const vitest_1 = require("vitest"); +exports.app = { + whenReady: vitest_1.vi.fn().mockReturnValue({ + then: vitest_1.vi.fn().mockImplementation(function (cb) { + this.cb = cb; + return { catch: vitest_1.vi.fn() }; + }), + }), + on: vitest_1.vi.fn(), + quit: vitest_1.vi.fn(), + isPackaged: true, + getAppPath: vitest_1.vi.fn().mockReturnValue('/mock/path'), + getPath: vitest_1.vi.fn().mockReturnValue('/mock/user/data'), + getVersion: vitest_1.vi.fn().mockReturnValue('1.0.0'), + getName: vitest_1.vi.fn().mockReturnValue('App'), + commandLine: { + appendSwitch: vitest_1.vi.fn(), + hasSwitch: vitest_1.vi.fn().mockReturnValue(false), + }, + dock: { + show: vitest_1.vi.fn(), + setIcon: vitest_1.vi.fn(), + setMenu: vitest_1.vi.fn(), + }, + requestSingleInstanceLock: vitest_1.vi.fn().mockReturnValue(true), + isDefaultProtocolClient: vitest_1.vi.fn().mockReturnValue(false), + setAsDefaultProtocolClient: vitest_1.vi.fn().mockReturnValue(true), + isReady: vitest_1.vi.fn().mockReturnValue(true), + focus: vitest_1.vi.fn(), +}; +const _mockBrowserWindowInstance = { + loadURL: vitest_1.vi.fn(), + once: vitest_1.vi.fn(), + close: vitest_1.vi.fn(), + show: vitest_1.vi.fn(), + on: vitest_1.vi.fn(), + off: vitest_1.vi.fn(), + isDestroyed: vitest_1.vi.fn().mockReturnValue(false), + getContentSize: vitest_1.vi.fn().mockReturnValue([1000, 800]), + contentView: { + addChildView: vitest_1.vi.fn(), + removeChildView: vitest_1.vi.fn(), + }, + webContents: { + send: vitest_1.vi.fn(), + on: vitest_1.vi.fn(), + once: vitest_1.vi.fn(), + setWindowOpenHandler: vitest_1.vi.fn(), + }, +}; +exports.BrowserWindow = Object.assign(vitest_1.vi.fn().mockImplementation(function () { + return _mockBrowserWindowInstance; +}), { + getAllWindows: vitest_1.vi.fn().mockReturnValue([_mockBrowserWindowInstance]), + getFocusedWindow: vitest_1.vi.fn().mockReturnValue(_mockBrowserWindowInstance), +}); +exports.WebContentsView = vitest_1.vi.fn().mockImplementation(function () { + return { + webContents: { + loadURL: vitest_1.vi.fn(), + }, + setBounds: vitest_1.vi.fn(), + }; +}); +exports.dialog = { + showErrorBox: vitest_1.vi.fn(), + showMessageBox: vitest_1.vi.fn(), +}; +exports.ipcMain = { + handle: vitest_1.vi.fn(), + removeHandler: vitest_1.vi.fn(), +}; +exports.protocol = { + registerSchemesAsPrivileged: vitest_1.vi.fn(), + handle: vitest_1.vi.fn(), +}; +const _mockNativeImage = { + setTemplateImage: vitest_1.vi.fn(), +}; +exports.nativeImage = { + createFromPath: vitest_1.vi.fn().mockReturnValue(_mockNativeImage), +}; +exports.Tray = vitest_1.vi.fn().mockImplementation(function () { + return { + setToolTip: vitest_1.vi.fn(), + setContextMenu: vitest_1.vi.fn(), + on: vitest_1.vi.fn(), + destroy: vitest_1.vi.fn(), + }; +}); +exports.MenuItem = vitest_1.vi.fn().mockImplementation(function (options) { + Object.assign(this, options); +}); +const _mockMenuInstance = { + items: [ + { + label: 'File', + submenu: { + insert: vitest_1.vi.fn(), + }, + }, + ], + getMenuItemById: vitest_1.vi.fn().mockReturnValue({ label: '' }), +}; +exports.Menu = { + buildFromTemplate: vitest_1.vi.fn().mockReturnValue(_mockMenuInstance), + getApplicationMenu: vitest_1.vi.fn().mockReturnValue(_mockMenuInstance), + setApplicationMenu: vitest_1.vi.fn(), +}; +const _mockNotificationInstance = { + show: vitest_1.vi.fn(), + on: vitest_1.vi.fn(), +}; +exports.Notification = Object.assign(vitest_1.vi.fn().mockImplementation(function () { + return _mockNotificationInstance; +}), { + // Static method + isSupported: vitest_1.vi.fn().mockReturnValue(true), + // Expose the mock instance for assertions + _mockInstance: _mockNotificationInstance, +}); +exports.shell = { + openExternal: vitest_1.vi.fn().mockResolvedValue(undefined), +}; +exports.contextBridge = { + exposeInMainWorld: vitest_1.vi.fn(), +}; +exports.ipcRenderer = { + invoke: vitest_1.vi.fn(), + on: vitest_1.vi.fn(), + removeListener: vitest_1.vi.fn(), +}; diff --git a/dist/constants.js b/dist/constants.js new file mode 100644 index 0000000..2d7e3bb --- /dev/null +++ b/dist/constants.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LS_CERT_FINGERPRINT = exports.WINDOW_ORIGIN = exports.LS_LOG_FILE_NAME = exports.DYNAMIC_PORT = void 0; +/** Pass 0 to the LS so the OS assigns an available port automatically. */ +exports.DYNAMIC_PORT = 0; +exports.LS_LOG_FILE_NAME = 'language_server.log'; +exports.WINDOW_ORIGIN = 'https://127.0.0.1'; +exports.LS_CERT_FINGERPRINT = 'sha256/sTZpQemOWEytaZqa7P/y/dNXbHMdOAzMvzHEhUwHZXw='; diff --git a/dist/customScheme.js b/dist/customScheme.js new file mode 100644 index 0000000..f460dc5 --- /dev/null +++ b/dist/customScheme.js @@ -0,0 +1,55 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extensionAuthorities = void 0; +exports.registerCustomSchemes = registerCustomSchemes; +exports.registerCustomSchemeHandlers = registerCustomSchemeHandlers; +const electron_1 = require("electron"); +// A map of extension authority -> original URL (http://localhost:) +// The authority is usually a hash of unique extension identifiers +// like extension ID + port + project ID. An extension running on localhost: +// is then exposed on plugin://. +exports.extensionAuthorities = new Map(); +function registerCustomSchemes() { + electron_1.protocol.registerSchemesAsPrivileged([ + { + scheme: 'plugin', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + allowServiceWorkers: true, + codeCache: true, + }, + }, + ]); +} +function registerCustomSchemeHandlers() { + // Handle custom scheme for UI extensions + electron_1.protocol.handle('plugin', async (request) => { + const url = new URL(request.url); + const authority = url.hostname; + const originalHost = exports.extensionAuthorities.get(authority); + if (!originalHost) { + return new Response(null, { status: 404 }); + } + const targetUrl = new URL(url.pathname + url.search, originalHost); + try { + const fetchOptions = { + method: request.method, + headers: request.headers, + body: request.body, + }; + if (request.body) { + // Required by Electron's net.fetch when the body is a stream + fetchOptions.duplex = 'half'; + } + const response = await electron_1.net.fetch(targetUrl.toString(), fetchOptions); + return response; + } + catch (err) { + console.error(`Failed to proxy request to ${targetUrl}:`, err); + return new Response(null, { status: 500 }); + } + }); +} diff --git a/dist/ideInstall/constants.js b/dist/ideInstall/constants.js new file mode 100644 index 0000000..14ef25b --- /dev/null +++ b/dist/ideInstall/constants.js @@ -0,0 +1,131 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WIZARD_SHOWN_KEY = void 0; +exports.fetchIdeDownloadUrl = fetchIdeDownloadUrl; +exports.getPlatformKey = getPlatformKey; +exports.getIdeInstallPath = getIdeInstallPath; +exports.shouldShowIdeInstallWizard = shouldShowIdeInstallWizard; +/** + * IDE Install โ€” Constants, platform helpers, and condition checks. + */ +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const os = __importStar(require("os")); +const main_1 = __importDefault(require("electron-log/main")); +const paths_1 = require("../paths"); +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +exports.WIZARD_SHOWN_KEY = 'ide-install-wizard-shown'; +/** + * Fetches the latest stable IDE download URL for a given platform. + */ +async function fetchIdeDownloadUrl(platformKey) { + const url = `https://ag-x-ide-auto-updater-974169037036.us-central1.run.app/api/update/${platformKey}/stable/latest`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch IDE download URL: ${response.status} ${response.statusText}`); + } + const data = (await response.json()); + if (!data.url) { + throw new Error(`No download URL found in the auto-updater response for platform: ${platformKey}`); + } + return data.url; +} +// --------------------------------------------------------------------------- +// Platform Helpers +// --------------------------------------------------------------------------- +function getPlatformKey() { + if (process.platform === 'darwin' && process.arch === 'x64') { + return 'darwin'; + } + let suffix = ''; + if (process.platform === 'win32') { + suffix = '-user'; + } + return `${process.platform}-${process.arch}${suffix}`; +} +/** + * Returns the expected installation path for the IDE. + */ +function getIdeInstallPath() { + switch (process.platform) { + case 'darwin': + return '/Applications/AG X IDE.app'; + case 'win32': + return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Programs', 'AG X IDE'); + case 'linux': + return path.join(os.homedir(), '.local', 'share', 'ag-x-ide'); + default: + return path.join(os.homedir(), 'ag-x-ide'); + } +} +// --------------------------------------------------------------------------- +// Condition Checks +// --------------------------------------------------------------------------- +/** + * Determines whether the IDE install wizard should be shown. + * + * Conditions (all must be true): + * 1. Wizard has not been shown before (checked via storage) + * 2. `~/.ag-x/ag-x-ide` does NOT exist + * 3. `~/.ag-x/ag-x` DOES exist + */ +async function shouldShowIdeInstallWizard(storageManager) { + // 1. Already shown? + const items = await storageManager.getItems(); + if (items[exports.WIZARD_SHOWN_KEY] === 'true') { + main_1.default.info('[IDE Wizard] Already shown, skipping.'); + return false; + } + // 1a. If not shown before, then now mark it as shown. + await storageManager.updateItems({ [exports.WIZARD_SHOWN_KEY]: 'true' }); + // 2. IDE already installed separately? + if (fs.existsSync(paths_1.IDE_NEW_DATA_DIR)) { + main_1.default.info(`[IDE Wizard] ${paths_1.IDE_NEW_DATA_DIR} exists โ€” IDE already installed, skipping.`); + return false; + } + // 3. Old IDE data present (user was migrated)? + if (!fs.existsSync(paths_1.IDE_OLD_DATA_DIR)) { + main_1.default.info(`[IDE Wizard] ${paths_1.IDE_OLD_DATA_DIR} not found โ€” user was not migrated, skipping.`); + return false; + } + main_1.default.info('[IDE Wizard] All conditions met โ€” will show wizard.'); + return true; +} diff --git a/dist/ideInstall/index.js b/dist/ideInstall/index.js new file mode 100644 index 0000000..89d2b43 --- /dev/null +++ b/dist/ideInstall/index.js @@ -0,0 +1,29 @@ +"use strict"; +/** + * IDE Install โ€” Public API. + * + * Re-exports the public surface from the sub-modules so consumers + * can simply `import { โ€ฆ } from './ideInstall'`. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.showIdeInstallWizard = exports.maybeShowIdeInstallWizard = exports.downloadAndInstallIde = exports.copyUserData = exports.extractIde = exports.downloadFile = exports.shouldShowIdeInstallWizard = exports.getIdeInstallPath = exports.getPlatformKey = exports.fetchIdeDownloadUrl = exports.WIZARD_SHOWN_KEY = exports.IDE_BACKUP_DATA_DIR = exports.IDE_NEW_DATA_DIR = exports.IDE_OLD_DATA_DIR = void 0; +// Constants, platform helpers, condition checks +var paths_1 = require("../paths"); +Object.defineProperty(exports, "IDE_OLD_DATA_DIR", { enumerable: true, get: function () { return paths_1.IDE_OLD_DATA_DIR; } }); +Object.defineProperty(exports, "IDE_NEW_DATA_DIR", { enumerable: true, get: function () { return paths_1.IDE_NEW_DATA_DIR; } }); +Object.defineProperty(exports, "IDE_BACKUP_DATA_DIR", { enumerable: true, get: function () { return paths_1.IDE_BACKUP_DATA_DIR; } }); +var constants_1 = require("./constants"); +Object.defineProperty(exports, "WIZARD_SHOWN_KEY", { enumerable: true, get: function () { return constants_1.WIZARD_SHOWN_KEY; } }); +Object.defineProperty(exports, "fetchIdeDownloadUrl", { enumerable: true, get: function () { return constants_1.fetchIdeDownloadUrl; } }); +Object.defineProperty(exports, "getPlatformKey", { enumerable: true, get: function () { return constants_1.getPlatformKey; } }); +Object.defineProperty(exports, "getIdeInstallPath", { enumerable: true, get: function () { return constants_1.getIdeInstallPath; } }); +Object.defineProperty(exports, "shouldShowIdeInstallWizard", { enumerable: true, get: function () { return constants_1.shouldShowIdeInstallWizard; } }); +var service_1 = require("./service"); +Object.defineProperty(exports, "downloadFile", { enumerable: true, get: function () { return service_1.downloadFile; } }); +Object.defineProperty(exports, "extractIde", { enumerable: true, get: function () { return service_1.extractIde; } }); +Object.defineProperty(exports, "copyUserData", { enumerable: true, get: function () { return service_1.copyUserData; } }); +Object.defineProperty(exports, "downloadAndInstallIde", { enumerable: true, get: function () { return service_1.downloadAndInstallIde; } }); +// Wizard window +var wizard_1 = require("./wizard"); +Object.defineProperty(exports, "maybeShowIdeInstallWizard", { enumerable: true, get: function () { return wizard_1.maybeShowIdeInstallWizard; } }); +Object.defineProperty(exports, "showIdeInstallWizard", { enumerable: true, get: function () { return wizard_1.showIdeInstallWizard; } }); diff --git a/dist/ideInstall/service.js b/dist/ideInstall/service.js new file mode 100644 index 0000000..45c4948 --- /dev/null +++ b/dist/ideInstall/service.js @@ -0,0 +1,197 @@ +"use strict"; +/** + * IDE Install Service โ€” Download, extract, copy, and launch logic. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.downloadFile = downloadFile; +exports.extractIde = extractIde; +exports.copyUserData = copyUserData; +exports.downloadAndInstallIde = downloadAndInstallIde; +const fs = __importStar(require("fs")); +const fsPromises = __importStar(require("fs/promises")); +const path = __importStar(require("path")); +const os = __importStar(require("os")); +const https = __importStar(require("https")); +const http = __importStar(require("http")); +const main_1 = __importDefault(require("electron-log/main")); +const constants_1 = require("./constants"); +const paths_1 = require("../paths"); +// --------------------------------------------------------------------------- +// Download +// --------------------------------------------------------------------------- +function downloadFile(url, destPath, onProgress, maxRedirects = 5) { + return new Promise((resolve, reject) => { + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + const proto = url.startsWith('https') ? https : http; + const req = proto.get(url, (res) => { + if (res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location) { + const redirectUrl = res.headers.location.startsWith('http') + ? res.headers.location + : new URL(res.headers.location, url).toString(); + downloadFile(redirectUrl, destPath, onProgress, maxRedirects - 1) + .then(resolve) + .catch(reject); + return; + } + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + const totalBytes = parseInt(res.headers['content-length'] || '0', 10); + let downloadedBytes = 0; + const dir = path.dirname(destPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const fileStream = fs.createWriteStream(destPath); + res.on('data', (chunk) => { + downloadedBytes += chunk.length; + if (totalBytes > 0 && onProgress) { + onProgress(Math.round((downloadedBytes / totalBytes) * 100)); + } + }); + res.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + fileStream.on('error', (err) => { + fs.unlinkSync(destPath); + reject(err); + }); + }); + req.on('error', reject); + }); +} +// --------------------------------------------------------------------------- +// Extract +// --------------------------------------------------------------------------- +async function extractIde(archivePath, installPath) { + const { execFile } = await Promise.resolve().then(() => __importStar(require('child_process'))); + const { promisify } = await Promise.resolve().then(() => __importStar(require('util'))); + const execFileAsync = promisify(execFile); + if (!fs.existsSync(path.dirname(installPath))) { + await fsPromises.mkdir(path.dirname(installPath), { recursive: true }); + } + switch (process.platform) { + case 'darwin': { + const tempDir = path.join(os.tmpdir(), 'ag-x-ide-extract'); + if (fs.existsSync(tempDir)) { + await execFileAsync('rm', ['-rf', tempDir]); + } + await fsPromises.mkdir(tempDir, { recursive: true }); + await execFileAsync('unzip', ['-o', '-q', archivePath, '-d', tempDir]); + const entries = await fsPromises.readdir(tempDir); + const appBundle = entries.find((e) => e.endsWith('.app')); + if (!appBundle) { + throw new Error('No .app bundle found in the downloaded archive'); + } + if (fs.existsSync(installPath)) { + await execFileAsync('rm', ['-rf', installPath]); + } + await execFileAsync('mv', [path.join(tempDir, appBundle), installPath]); + if (fs.existsSync(tempDir)) { + await execFileAsync('rm', ['-rf', tempDir]); + } + break; + } + case 'linux': { + if (!fs.existsSync(installPath)) { + await fsPromises.mkdir(installPath, { recursive: true }); + } + await execFileAsync('tar', [ + '-xzf', + archivePath, + '-C', + installPath, + '--strip-components=1', + ]); + break; + } + case 'win32': { + await execFileAsync(archivePath, ['/VERYSILENT', '/MERGETASKS=!runcode']); + break; + } + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} +// --------------------------------------------------------------------------- +// Copy User Data +// --------------------------------------------------------------------------- +async function copyUserData(sourcePath, destPath) { + if (!fs.existsSync(sourcePath)) { + main_1.default.warn(`[IDE Wizard] Source path does not exist: ${sourcePath}`); + return; + } + await fsPromises.cp(sourcePath, destPath, { recursive: true, force: true }); + main_1.default.info(`[IDE Wizard] Copied user data: ${sourcePath} โ†’ ${destPath}`); +} +// --------------------------------------------------------------------------- +// Download & Install (orchestrator) +// --------------------------------------------------------------------------- +async function downloadAndInstallIde() { + const platformKey = (0, constants_1.getPlatformKey)(); + const downloadUrl = await (0, constants_1.fetchIdeDownloadUrl)(platformKey); + const ext = process.platform === 'win32' + ? '.exe' + : process.platform === 'linux' + ? '.tar.gz' + : '.zip'; + const tempFile = path.join(os.tmpdir(), `ag-x-ide-download${ext}`); + main_1.default.info(`[IDE Wizard] Downloading IDE from ${downloadUrl}โ€ฆ`); + await downloadFile(downloadUrl, tempFile); + const installPath = (0, constants_1.getIdeInstallPath)(); + main_1.default.info(`[IDE Wizard] Installing IDE to ${installPath}โ€ฆ`); + await extractIde(tempFile, installPath); + main_1.default.info(`[IDE Wizard] Copying user dataโ€ฆ`); + await copyUserData(paths_1.IDE_OLD_DATA_DIR, paths_1.IDE_NEW_DATA_DIR); + try { + await fsPromises.unlink(tempFile); + } + catch { + /* ignore */ + } +} diff --git a/dist/ideInstall/wizard.js b/dist/ideInstall/wizard.js new file mode 100644 index 0000000..db815a6 --- /dev/null +++ b/dist/ideInstall/wizard.js @@ -0,0 +1,155 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.showIdeInstallWizard = showIdeInstallWizard; +exports.maybeShowIdeInstallWizard = maybeShowIdeInstallWizard; +/** + * IDE Install Wizard โ€” Window orchestration and IPC handlers. + */ +const electron_1 = require("electron"); +const path = __importStar(require("path")); +const fs = __importStar(require("fs")); +const main_1 = __importDefault(require("electron-log/main")); +const constants_1 = require("./constants"); +const paths_1 = require("../paths"); +const service_1 = require("./service"); +const wizardHtml_1 = require("./wizardHtml"); +/** + * Shows the IDE install wizard as a modal window. + * Returns a promise that resolves when the wizard is dismissed. + */ +function showIdeInstallWizard() { + return new Promise((resolve) => { + const wizardWindow = new electron_1.BrowserWindow({ + width: 720, + height: 580, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + titleBarStyle: 'hidden', + trafficLightPosition: { x: 12, y: 12 }, + backgroundColor: '#0D0D0D', + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'wizardPreload.js'), + }, + }); + const iconPath = path.join(__dirname, '..', '..', 'icon.png'); + let iconBase64 = ''; + try { + if (fs.existsSync(iconPath)) { + iconBase64 = fs.readFileSync(iconPath).toString('base64'); + } + else { + main_1.default.warn(`[IDE Wizard] Icon not found at ${iconPath}`); + } + } + catch (e) { + main_1.default.error(`[IDE Wizard] Failed to read icon: ${e}`); + } + const html = (0, wizardHtml_1.getWizardHtml)(iconBase64); + let isResolved = false; + const cleanup = () => { + if (isResolved) { + return; + } + isResolved = true; + electron_1.ipcMain.removeHandler('wizard:complete'); + resolve(); + }; + electron_1.ipcMain.handle('wizard:complete', async (_event, shouldDownload) => { + cleanup(); + wizardWindow.close(); + if (shouldDownload) { + main_1.default.info('[IDE Wizard] Background download requested. Starting installation in background...'); + void (0, service_1.downloadAndInstallIde)().catch((err) => { + main_1.default.error(`[IDE Wizard] Background download/install failed: ${err}`); + }); + } + }); + wizardWindow.on('closed', () => { + cleanup(); + }); + const doSetup = async () => { + // If the old AG X user data directory exists, copy it to the new IDE + // data dir and to a backup directory. + if (fs.existsSync(paths_1.IDE_OLD_DATA_DIR)) { + if (!fs.existsSync(paths_1.IDE_NEW_DATA_DIR)) { + try { + await (0, service_1.copyUserData)(paths_1.IDE_OLD_DATA_DIR, paths_1.IDE_NEW_DATA_DIR); + } + catch (err) { + main_1.default.error(`[IDE Wizard] Failed to copy to new IDE data dir: ${err}`); + } + } + if (!fs.existsSync(paths_1.IDE_BACKUP_DATA_DIR)) { + try { + await (0, service_1.copyUserData)(paths_1.IDE_OLD_DATA_DIR, paths_1.IDE_BACKUP_DATA_DIR); + } + catch (err) { + main_1.default.error(`[IDE Wizard] Failed to copy to backup IDE data dir: ${err}`); + } + } + } + if (!wizardWindow.isDestroyed()) { + wizardWindow.webContents.send('wizard:setup-complete'); + } + }; + wizardWindow.once('ready-to-show', () => { + wizardWindow.show(); + void doSetup(); + }); + void wizardWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + }); +} +/** + * Checks conditions and shows the IDE install wizard if appropriate. + * This should be called early in the app lifecycle, before the LS starts. + * Returns true if the wizard was shown, false otherwise. + */ +async function maybeShowIdeInstallWizard(storageManager) { + const shouldShow = await (0, constants_1.shouldShowIdeInstallWizard)(storageManager); + if (!shouldShow) { + return false; + } + await showIdeInstallWizard(); + return true; +} diff --git a/dist/ideInstall/wizardHtml.js b/dist/ideInstall/wizardHtml.js new file mode 100644 index 0000000..873920b --- /dev/null +++ b/dist/ideInstall/wizardHtml.js @@ -0,0 +1,286 @@ +"use strict"; +/** + * IDE Install Wizard โ€” HTML template for the wizard UI. + * + * This is a self-contained page with all CSS/JS embedded, rendered inline + * in a standalone BrowserWindow. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getWizardHtml = getWizardHtml; +/** + * Returns the inline HTML for the IDE install wizard. + * This is a self-contained page with all CSS/JS embedded. + */ +function getWizardHtml(iconBase64) { + return ` + + + + +Welcome to AG X + + + +
+
+ + +
+
+
+
+
Setting upโ€ฆ
+
+ + +
+
+ AG X Icon +
+

Welcome to the new AG X!

+

AG X has been redesigned to put agents first with new capabilities. If you'd still like a code editor, you can download it as a separate app named AG X IDE.

+ + + +
+ +
+
+ +
+ + + +`; +} diff --git a/dist/ideInstall/wizardPreload.js b/dist/ideInstall/wizardPreload.js new file mode 100644 index 0000000..a4484c1 --- /dev/null +++ b/dist/ideInstall/wizardPreload.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * Preload script for the IDE Install Wizard window. + * + * This is a minimal, self-contained preload that exposes only the APIs + * needed by the wizard's inline HTML UI. It runs in its own + * BrowserWindow, separate from the main app window and its preload. + */ +const electron_1 = require("electron"); +const wizardAPI = { + completeWizard: (shouldDownload) => electron_1.ipcRenderer.invoke('wizard:complete', shouldDownload), + onSetupComplete: (callback) => { + const handler = () => { + callback(); + }; + electron_1.ipcRenderer.on('wizard:setup-complete', handler); + return () => { + electron_1.ipcRenderer.removeListener('wizard:setup-complete', handler); + }; + }, +}; +electron_1.contextBridge.exposeInMainWorld('wizardAPI', wizardAPI); diff --git a/dist/ideInstallService.test.js b/dist/ideInstallService.test.js new file mode 100644 index 0000000..eeeb59b --- /dev/null +++ b/dist/ideInstallService.test.js @@ -0,0 +1,350 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const helpers_1 = require("./test/helpers"); +// Use the shared auto-mocks from __mocks__/ +vitest_1.vi.mock('electron'); +// Mock electron-log +vitest_1.vi.mock('electron-log/main', () => ({ + default: { + info: vitest_1.vi.fn(), + warn: vitest_1.vi.fn(), + error: vitest_1.vi.fn(), + }, +})); +// Mock fs +vitest_1.vi.mock('fs', () => ({ + existsSync: vitest_1.vi.fn(), + mkdirSync: vitest_1.vi.fn(), + createWriteStream: vitest_1.vi.fn(), + unlinkSync: vitest_1.vi.fn(), +})); +// Mock fs/promises +vitest_1.vi.mock('fs/promises', () => ({ + cp: vitest_1.vi.fn().mockResolvedValue(undefined), + mkdir: vitest_1.vi.fn().mockResolvedValue(undefined), + rm: vitest_1.vi.fn().mockResolvedValue(undefined), + rename: vitest_1.vi.fn().mockResolvedValue(undefined), + readdir: vitest_1.vi.fn().mockResolvedValue(['AG X.app']), + unlink: vitest_1.vi.fn().mockResolvedValue(undefined), +})); +// Mock child_process (used by extractIde) +vitest_1.vi.mock('child_process', () => ({ + execFile: vitest_1.vi.fn(), +})); +// Mock storage +const mockStorageManager = { + getItems: vitest_1.vi.fn(), + updateItems: vitest_1.vi.fn(), + onDidChange: vitest_1.vi.fn().mockReturnValue({ dispose: vitest_1.vi.fn() }), +}; +(0, vitest_1.describe)('ideInstallService', () => { + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); + vitest_1.vi.resetModules(); + (0, helpers_1.silenceConsole)(); + }); + (0, vitest_1.afterEach)(() => { + vitest_1.vi.restoreAllMocks(); + }); + (0, vitest_1.describe)('shouldShowIdeInstallWizard', () => { + (0, vitest_1.it)('should return false if wizard was already shown', async () => { + mockStorageManager.getItems.mockResolvedValue({ + 'ide-install-wizard-shown': 'true', + }); + const { shouldShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const result = await shouldShowIdeInstallWizard(mockStorageManager); + (0, vitest_1.expect)(result).toBe(false); + }); + (0, vitest_1.it)('should return false if ~/.ag-x/ag-x-ide already exists', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + mockStorageManager.getItems.mockResolvedValue({}); + // First call: IDE_NEW_DATA_DIR exists + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + const { shouldShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const result = await shouldShowIdeInstallWizard(mockStorageManager); + (0, vitest_1.expect)(result).toBe(false); + }); + (0, vitest_1.it)('should return false if ~/.ag-x/ag-x does not exist', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + mockStorageManager.getItems.mockResolvedValue({}); + // Both dirs don't exist + vitest_1.vi.mocked(existsSync).mockReturnValue(false); + const { shouldShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const result = await shouldShowIdeInstallWizard(mockStorageManager); + (0, vitest_1.expect)(result).toBe(false); + }); + (0, vitest_1.it)('should return true when all conditions are met', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + mockStorageManager.getItems.mockResolvedValue({}); + // IDE_NEW_DATA_DIR does NOT exist (first call), IDE_OLD_DATA_DIR DOES exist (second call) + vitest_1.vi.mocked(existsSync) + .mockReturnValueOnce(false) // IDE_NEW_DATA_DIR + .mockReturnValueOnce(true); // IDE_OLD_DATA_DIR + const { shouldShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const result = await shouldShowIdeInstallWizard(mockStorageManager); + (0, vitest_1.expect)(result).toBe(true); + }); + }); + (0, vitest_1.describe)('getPlatformKey', () => { + let originalPlatform; + let originalArch; + (0, vitest_1.beforeEach)(() => { + originalPlatform = process.platform; + originalArch = process.arch; + }); + (0, vitest_1.afterEach)(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(process, 'arch', { + value: originalArch, + configurable: true, + }); + }); + (0, vitest_1.it)('should return just "darwin" on macOS x64', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + configurable: true, + }); + const { getPlatformKey } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + (0, vitest_1.expect)(getPlatformKey()).toBe('darwin'); + }); + (0, vitest_1.it)('should return "darwin-arm64" on macOS arm64', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + Object.defineProperty(process, 'arch', { + value: 'arm64', + configurable: true, + }); + const { getPlatformKey } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + (0, vitest_1.expect)(getPlatformKey()).toBe('darwin-arm64'); + }); + (0, vitest_1.it)('should return "win32-x64-user" on Windows x64', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + configurable: true, + }); + const { getPlatformKey } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + (0, vitest_1.expect)(getPlatformKey()).toBe('win32-x64-user'); + }); + (0, vitest_1.it)('should return "linux-x64" on Linux x64', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + configurable: true, + }); + const { getPlatformKey } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + (0, vitest_1.expect)(getPlatformKey()).toBe('linux-x64'); + }); + }); + (0, vitest_1.describe)('getIdeInstallPath', () => { + (0, vitest_1.it)('should return a non-empty install path', async () => { + const { getIdeInstallPath } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const installPath = getIdeInstallPath(); + (0, vitest_1.expect)(installPath).toBeTruthy(); + (0, vitest_1.expect)(typeof installPath).toBe('string'); + }); + }); + (0, vitest_1.describe)('copyUserData', () => { + (0, vitest_1.it)('should recursively copy source to destination', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const fsPromises = await Promise.resolve().then(() => __importStar(require('fs/promises'))); + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + const { copyUserData } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + await copyUserData('/source', '/dest'); + (0, vitest_1.expect)(fsPromises.cp).toHaveBeenCalledWith('/source', '/dest', { + recursive: true, + force: true, + }); + }); + (0, vitest_1.it)('should skip copy if source does not exist', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const fsPromises = await Promise.resolve().then(() => __importStar(require('fs/promises'))); + vitest_1.vi.mocked(existsSync).mockReturnValue(false); + const { copyUserData } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + await copyUserData('/nonexistent', '/dest'); + (0, vitest_1.expect)(fsPromises.cp).not.toHaveBeenCalled(); + }); + }); + (0, vitest_1.describe)('maybeShowIdeInstallWizard', () => { + (0, vitest_1.it)('should return false when conditions are not met', async () => { + mockStorageManager.getItems.mockResolvedValue({ + 'ide-install-wizard-shown': 'true', + }); + const { maybeShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const result = await maybeShowIdeInstallWizard(mockStorageManager); + (0, vitest_1.expect)(result).toBe(false); + }); + (0, vitest_1.it)('should copy to NEW and BACKUP dirs if conditions are met and source exists', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const fsPromises = await Promise.resolve().then(() => __importStar(require('fs/promises'))); + mockStorageManager.getItems.mockResolvedValue({}); + vitest_1.vi.mocked(existsSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith('ag-x-ide')) { + return false; + } + if (pathStr.endsWith('ag-x-backup')) { + return false; + } + if (pathStr.endsWith('ag-x')) { + return true; + } + if (pathStr.endsWith('icon.png')) { + return true; + } + return false; + }); + const { maybeShowIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const wizardPromise = maybeShowIdeInstallWizard(mockStorageManager); + // Trigger ready-to-show to run the setup copying logic + const { BrowserWindow } = await Promise.resolve().then(() => __importStar(require('electron'))); + const mockWindowInstance = vitest_1.vi.mocked(BrowserWindow).mock.results[0].value; + const readyToShowHandler = vitest_1.vi.mocked(mockWindowInstance.once).mock.calls.find((call) => call[0] === 'ready-to-show')?.[1]; + if (readyToShowHandler) { + await readyToShowHandler(); + } + // Simulate complete to resolve the promise + const { ipcMain } = await Promise.resolve().then(() => __importStar(require('electron'))); + const completeHandler = vitest_1.vi.mocked(ipcMain.handle).mock.calls.find((call) => call[0] === 'wizard:complete'); + if (completeHandler) { + await completeHandler[1]({}, false); + } + const result = await wizardPromise; + (0, vitest_1.expect)(result).toBe(true); + (0, vitest_1.expect)(fsPromises.cp).toHaveBeenCalledTimes(2); + }); + }); + (0, vitest_1.describe)('fetchIdeDownloadUrl', () => { + (0, vitest_1.it)('should fetch the correct URL from the API', async () => { + // Mock fetch + const mockFetch = vitest_1.vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + url: 'https://edgedl.me.gvt1.com/edgedl/release2/ag-x/darwin-arm/AG X IDE.zip', + }), + }); + global.fetch = mockFetch; + const { fetchIdeDownloadUrl } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const url = await fetchIdeDownloadUrl('darwin-arm64'); + (0, vitest_1.expect)(mockFetch).toHaveBeenCalledWith('https://ag-x-ide-auto-updater-974169037036.us-central1.run.app/api/update/darwin-arm64/stable/latest'); + (0, vitest_1.expect)(url).toBe('https://edgedl.me.gvt1.com/edgedl/release2/ag-x/darwin-arm/AG X IDE.zip'); + }); + (0, vitest_1.it)('should throw an error if the API request fails', async () => { + const mockFetch = vitest_1.vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + global.fetch = mockFetch; + const { fetchIdeDownloadUrl } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + await (0, vitest_1.expect)(fetchIdeDownloadUrl('darwin-arm64')).rejects.toThrow('Failed to fetch IDE download URL: 500 Internal Server Error'); + }); + (0, vitest_1.it)('should throw an error if the API response has no URL', async () => { + const mockFetch = vitest_1.vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + global.fetch = mockFetch; + const { fetchIdeDownloadUrl } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + await (0, vitest_1.expect)(fetchIdeDownloadUrl('darwin-arm64')).rejects.toThrow('No download URL found in the auto-updater response for platform: darwin-arm64'); + }); + }); + (0, vitest_1.describe)('showIdeInstallWizard', () => { + (0, vitest_1.it)('should create a BrowserWindow with correct options', async () => { + const { BrowserWindow } = await Promise.resolve().then(() => __importStar(require('electron'))); + mockStorageManager.getItems.mockResolvedValue({}); + mockStorageManager.updateItems.mockResolvedValue(undefined); + const { showIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + // Start the wizard but don't await โ€” we'll resolve it via the mock + const wizardPromise = showIdeInstallWizard(); + // Verify BrowserWindow was created with expected options + (0, vitest_1.expect)(BrowserWindow).toHaveBeenCalledWith(vitest_1.expect.objectContaining({ + width: 720, + height: 580, + resizable: false, + titleBarStyle: 'hidden', + backgroundColor: '#0D0D0D', + webPreferences: vitest_1.expect.objectContaining({ + nodeIntegration: false, + contextIsolation: true, + }), + })); + // Simulate complete to resolve the promise + const { ipcMain } = await Promise.resolve().then(() => __importStar(require('electron'))); + const completeHandler = vitest_1.vi.mocked(ipcMain.handle).mock.calls.find((call) => call[0] === 'wizard:complete'); + if (completeHandler) { + await completeHandler[1]({}, undefined); + } + const result = await wizardPromise; + (0, vitest_1.expect)(result).toBeUndefined(); + }); + (0, vitest_1.it)('should start background download if shouldDownload is true when skipping', async () => { + mockStorageManager.getItems.mockResolvedValue({}); + mockStorageManager.updateItems.mockResolvedValue(undefined); + const serviceModule = await Promise.resolve().then(() => __importStar(require('./ideInstall/service'))); + const downloadSpy = vitest_1.vi + .spyOn(serviceModule, 'downloadAndInstallIde') + .mockResolvedValue(undefined); + const { showIdeInstallWizard } = await Promise.resolve().then(() => __importStar(require('./ideInstall'))); + const wizardPromise = showIdeInstallWizard(); + // Simulate complete with shouldDownload = true + const { ipcMain } = await Promise.resolve().then(() => __importStar(require('electron'))); + const completeHandler = vitest_1.vi.mocked(ipcMain.handle).mock.calls.find((call) => call[0] === 'wizard:complete'); + if (completeHandler) { + await completeHandler[1]({}, true); + } + const result = await wizardPromise; + (0, vitest_1.expect)(result).toBeUndefined(); + (0, vitest_1.expect)(downloadSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/dist/ipcHandlers.js b/dist/ipcHandlers.js new file mode 100644 index 0000000..ed6db4d --- /dev/null +++ b/dist/ipcHandlers.js @@ -0,0 +1,208 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.registerIpcHandlers = registerIpcHandlers; +const electron_1 = require("electron"); +const electron_updater_1 = require("electron-updater"); +const updater_1 = require("./updater"); +const main_1 = __importDefault(require("electron-log/main")); +const fs = __importStar(require("fs/promises")); +const customScheme_1 = require("./customScheme"); +const tray_1 = require("./tray"); +/** + * Registers all IPC handlers for the main process. + */ +function registerIpcHandlers(storageManager) { + // Dialog + electron_1.ipcMain.handle('dialog:open-workspace', async () => { + const result = await electron_1.dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + title: 'Open workspace', + }); + if (result.canceled || result.filePaths.length === 0) { + return undefined; + } + return result.filePaths[0]; + }); + // Auto-updater + electron_1.ipcMain.handle('updater:apply', async () => { + (0, updater_1.broadcastState)({ type: 'ready' }); + }); + electron_1.ipcMain.handle('updater:quit-and-install', () => { + if (!electron_1.app.isPackaged) { + console.log('[AutoUpdater] Skipping quitAndInstall (requires a packaged app).'); + return; + } + electron_updater_1.autoUpdater.quitAndInstall(); + }); + // Notifications + electron_1.ipcMain.handle('notification:send', (_, options) => { + const notification = new electron_1.Notification({ + title: options.title, + body: options.body, + silent: options.silent ?? false, + }); + notification.on('click', () => { + const win = electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) { + win.restore(); + } + win.show(); + win.focus(); + if (options.payload) { + win.webContents.send('notification:clicked', options.payload); + } + } + }); + notification.show(); + }); + // Note: copied from our desktop AGY implementation: + // vs/platform/nativeNotification/electron-main/electronNotificationService.ts + electron_1.ipcMain.handle('notification:open-system-preferences', async () => { + if (process.platform === 'darwin') { + void electron_1.shell.openExternal('x-apple.systempreferences:com.apple.preference.notifications'); + } + else if (process.platform === 'win32') { + void electron_1.shell.openExternal('ms-settings:notifications'); + } + else if (process.platform === 'linux') { + const { exec } = await Promise.resolve().then(() => __importStar(require('child_process'))); + const commands = [ + 'gnome-control-center notifications', + 'systemsettings kcm_notifications', + 'xfce4-notifyd-config', + 'gnome-control-center', + 'systemsettings', + ]; + for (const command of commands) { + try { + exec(command); + return; // If one command executes without immediate error, assume success for now + } + catch { + // Try next + } + } + } + }); + // Storage + electron_1.ipcMain.handle('storage:get-items', async () => { + return storageManager.getItems(); + }); + electron_1.ipcMain.handle('storage:update-items', async (_event, changes) => { + await storageManager.updateItems(changes); + }); + // Logs + electron_1.ipcMain.handle('logs:electron', async () => { + try { + const logPath = main_1.default.transports.file.getFile().path; + const contents = await fs.readFile(logPath, 'utf-8'); + return contents; + } + catch (err) { + return `Failed to read logs: ${String(err)}`; + } + }); + // Sidecar extension custom scheme + electron_1.ipcMain.handle('extensions:send-authorities', async (_event, authorities) => { + customScheme_1.extensionAuthorities.clear(); + for (const [key, value] of Object.entries(authorities)) { + customScheme_1.extensionAuthorities.set(key, value); + } + }); + // Agent + electron_1.ipcMain.handle('agent:update-active-count', async (_event, count) => { + (0, tray_1.updateTrayAgentCount)(count); + }); + // Window + electron_1.ipcMain.handle('window:set-title-bar-overlay', async (_event, options) => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win && process.platform === 'win32') { + win.setTitleBarOverlay({ + color: options.color, + symbolColor: options.symbolColor, + height: 30, + }); + } + }); + electron_1.ipcMain.handle('window:minimize', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + win.minimize(); + } + }); + electron_1.ipcMain.handle('window:maximize', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + win.maximize(); + } + }); + electron_1.ipcMain.handle('window:unmaximize', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + win.unmaximize(); + } + }); + electron_1.ipcMain.handle('window:is-maximized', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + return win ? win.isMaximized() : false; + }); + electron_1.ipcMain.handle('window:close', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + win.close(); + } + }); + electron_1.ipcMain.handle('window:toggle-devtools', async () => { + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + if (win) { + win.webContents.toggleDevTools(); + } + }); + // Auto-updater manual check + electron_1.ipcMain.handle('updater:check-for-updates', () => { + (0, updater_1.checkForUpdates)(true); + }); + // Safe external shell launch + electron_1.ipcMain.handle('shell:open-external', async (_event, url) => { + if (url.startsWith('https://') || url.startsWith('http://')) { + await electron_1.shell.openExternal(url); + } + }); +} diff --git a/dist/ipcHandlers.test.js b/dist/ipcHandlers.test.js new file mode 100644 index 0000000..79d722c --- /dev/null +++ b/dist/ipcHandlers.test.js @@ -0,0 +1,88 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const electron_1 = require("./__mocks__/electron"); +vitest_1.vi.mock('electron'); +vitest_1.vi.mock('electron-updater'); +// Capture registered handlers so we can invoke them in tests +const handlers = new Map(); +vitest_1.vi.mocked(electron_1.ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler); +}); +// Import after mocks are in place +const ipcHandlers_1 = require("./ipcHandlers"); +(0, vitest_1.describe)('ipcHandlers โ€” notifications', () => { + (0, vitest_1.beforeEach)(() => { + handlers.clear(); + vitest_1.vi.clearAllMocks(); + (0, ipcHandlers_1.registerIpcHandlers)({}); + }); + (0, vitest_1.describe)('notification:send', () => { + (0, vitest_1.it)('should create and show a Notification with the given options', () => { + const handler = handlers.get('notification:send'); + const options = { + id: 'test-1', + title: 'Test Title', + body: 'Test Body', + silent: true, + }; + handler({ sender: { send: vitest_1.vi.fn() } }, options); + (0, vitest_1.expect)(electron_1.Notification).toHaveBeenCalledWith({ + title: 'Test Title', + body: 'Test Body', + silent: true, + }); + (0, vitest_1.expect)(electron_1.Notification._mockInstance.show).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should default silent to false when not specified', () => { + const handler = handlers.get('notification:send'); + handler({ sender: { send: vitest_1.vi.fn() } }, { id: 'test-2', title: 'T', body: 'B' }); + (0, vitest_1.expect)(electron_1.Notification).toHaveBeenCalledWith({ + title: 'T', + body: 'B', + silent: false, + }); + }); + (0, vitest_1.it)('should register a click handler that focuses the window', () => { + const handler = handlers.get('notification:send'); + handler({ sender: { send: vitest_1.vi.fn() } }, { id: 'test-3', title: 'T', body: 'B' }); + // Verify 'click' listener was registered + (0, vitest_1.expect)(electron_1.Notification._mockInstance.on).toHaveBeenCalledWith('click', vitest_1.expect.any(Function)); + // Simulate click + const clickHandler = vitest_1.vi.mocked(electron_1.Notification._mockInstance.on).mock + .calls[0][1]; + const mockWin = electron_1.BrowserWindow.getAllWindows()[0]; + Object.assign(mockWin, { + isMinimized: vitest_1.vi.fn().mockReturnValue(true), + restore: vitest_1.vi.fn(), + show: vitest_1.vi.fn(), + focus: vitest_1.vi.fn(), + }); + clickHandler(); + (0, vitest_1.expect)(mockWin.isMinimized).toHaveBeenCalled(); + (0, vitest_1.expect)(mockWin.restore).toHaveBeenCalled(); + (0, vitest_1.expect)(mockWin.show).toHaveBeenCalled(); + (0, vitest_1.expect)(mockWin.focus).toHaveBeenCalled(); + }); + }); + (0, vitest_1.describe)('notification:open-system-preferences', () => { + (0, vitest_1.it)('should call shell.openExternal on macOS', () => { + // Override platform for this test + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const handler = handlers.get('notification:open-system-preferences'); + handler({}); + (0, vitest_1.expect)(electron_1.shell.openExternal).toHaveBeenCalledWith('x-apple.systempreferences:com.apple.preference.notifications'); + // Restore + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + (0, vitest_1.it)('should not call shell.openExternal on non-macOS', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + const handler = handlers.get('notification:open-system-preferences'); + handler({}); + (0, vitest_1.expect)(electron_1.shell.openExternal).not.toHaveBeenCalled(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); +}); diff --git a/dist/keybindings.js b/dist/keybindings.js new file mode 100644 index 0000000..1b3a0f2 --- /dev/null +++ b/dist/keybindings.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.registerKeybindings = registerKeybindings; +const utils_1 = require("./utils"); +function registerKeybindings(win, actions) { + win.webContents.on('before-input-event', (event, input) => { + if (input.type === 'keyDown') { + const isCmdOrCtrl = (0, utils_1.isMacOS)() ? input.meta : input.control; + if (isCmdOrCtrl && input.shift && input.key.toLowerCase() === 'n') { + actions.createNewWindow(); + event.preventDefault(); + } + if (isCmdOrCtrl && input.key.toLowerCase() === 'q') { + actions.onQuitRequested(); + event.preventDefault(); + } + } + }); +} diff --git a/dist/languageServer.js b/dist/languageServer.js new file mode 100644 index 0000000..fd3e54b --- /dev/null +++ b/dist/languageServer.js @@ -0,0 +1,509 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LS_BINARY = void 0; +exports.getLsCL = getLsCL; +exports.getLsProcess = getLsProcess; +exports.getLsPort = getLsPort; +exports.clearLsProcess = clearLsProcess; +exports.extractCrashStackTrace = extractCrashStackTrace; +exports.startLanguageServer = startLanguageServer; +exports.setIntentionalTermination = setIntentionalTermination; +exports.startAndMonitorLanguageServer = startAndMonitorLanguageServer; +exports.killLanguageServer = killLanguageServer; +exports.setupLocalCertTrust = setupLocalCertTrust; +const child_process_1 = require("child_process"); +const electron_1 = require("electron"); +const shell_env_1 = require("shell-env"); +const fs = __importStar(require("fs")); +const path_1 = __importDefault(require("path")); +const readline = __importStar(require("readline")); +const stream_1 = require("stream"); +const paths_1 = require("./paths"); +const constants_1 = require("./constants"); +const utils_1 = require("./utils"); +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const LS_STARTUP_TIMEOUT_MS = 60000; +// --------------------------------------------------------------------------- +// Crash Monitoring Constants +// --------------------------------------------------------------------------- +const RESTART_WINDOW_MS = 60000; +const MAX_RESTARTS = 3; +const RESTART_COOLDOWN_MS = 2000; +const MAX_STDERR_BUFFER = 100000; +const CRASH_TRIGGER_PHRASES = [ + 'panic:', + 'fatal error:', + 'unexpected fault address', + 'runtime:', + 'running GoogleExitFunction', + 'panic serving', +]; +const isWindows = process.platform === 'win32'; +const binName = isWindows ? 'language_server.exe' : 'language_server'; +exports.LS_BINARY = electron_1.app.isPackaged + ? path_1.default.join(process.resourcesPath, 'bin', binName) + : process.env.CODEIUM_LANGUAGE_SERVER_BIN || + path_1.default.join(__dirname, '..', 'bin', binName); +/** + * Gets the build CL of the language server by running it with --stamp. + */ +function getLsCL() { + return new Promise((resolve) => { + (0, child_process_1.execFile)(exports.LS_BINARY, ['--stamp'], (error, stdout, _stderr) => { + if (error) { + console.error('Failed to get LS stamp:', error); + resolve(''); + return; + } + const match = /Built at CL: (\d+)/.exec(stdout); + if (match) { + resolve(match[1]); + } + else { + resolve(''); + } + }); + }); +} +// Pattern: "listening on port at for HTTP or HTTPS" +const PORT_PATTERN = /listening on \w+ port at (\d+) for HTTP(S)?\b/i; +// Pattern: OAuth authorization URL +const AUTH_URL_PATTERN = /https:\/\/accounts\.google\.com\/o\/oauth2\/auth\S+/; +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let _lsProcess = null; +let _proxyProcess = null; +let _lsPort = 0; +let _intentionalTermination = false; +let _restartCount = 0; +let _lastRestartTime = 0; +/** Returns the active language server process, or null if not running. */ +function getLsProcess() { + return _lsProcess; +} +/** Returns the active language server port, or 0 if not running. */ +function getLsPort() { + return _lsPort; +} +/** Clears the language server process reference (call after killing it). */ +function clearLsProcess() { + _lsProcess = null; +} +// --------------------------------------------------------------------------- +// Crash log extraction +// --------------------------------------------------------------------------- +/** + * Extract lines after a crash trigger phrase from a list of stderr lines. + * Returns all lines from the first trigger phrase onwards. + */ +function getLinesAfterCrash(lines) { + const crashLines = []; + let foundTrigger = false; + for (const line of lines) { + if (CRASH_TRIGGER_PHRASES.some((phrase) => line.includes(phrase))) { + foundTrigger = true; + } + if (foundTrigger) { + crashLines.push(line); + } + } + return crashLines; +} +/** + * Best-effort extraction of the crash stack trace from buffered stderr. + * Returns the stack trace string, or undefined if no trigger phrase was found. + */ +function extractCrashStackTrace(stderr) { + const lines = stderr.split('\n'); + const crashLines = getLinesAfterCrash(lines); + return crashLines.length > 0 ? crashLines.join('\n') : undefined; +} +/** + * Sets environment variables for bundled node modules so the language + * server can find them. + * + * NOTE: If you add a new module that needs to be executed this way: + * 1. Add it to `asarUnpack` in `package.json` so it is available on the filesystem. + * 2. Add it to `modules` in the callsite of setupNodeModules. + */ +function setupNodeModules(env, modules) { + for (const mod of modules) { + let entryPoint = ''; + if (!electron_1.app.isPackaged) { + entryPoint = path_1.default.join(__dirname, '..', 'node_modules', mod.name, ...mod.relativePath); + } + else { + entryPoint = path_1.default.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', mod.name, ...mod.relativePath); + } + env[mod.envVar] = entryPoint; + } +} +/** + * Spawn the language server and resolve with a LanguageServerHandle once + * the LS reports its HTTP port. Rejects on timeout or unexpected exit + * during startup. + * + * After resolving, callers should monitor `handle.exitPromise` to detect + * crashes that occur after startup. + */ +const os = require('os'); +function ensureProxyStarted() { + return new Promise((resolve) => { + if (_proxyProcess) { + resolve(); + return; + } + try { + const home = os.homedir(); + const endpointsPath = path_1.default.join(home, '.codex', 'endpoints.json'); + const activePath = path_1.default.join(home, '.codex', '.active-endpoint.json'); + const proxyConfigDir = path_1.default.join(home, '.cache', 'codex-proxy'); + const activeConfigPath = path_1.default.join(proxyConfigDir, 'proxy-active.json'); + if (!fs.existsSync(proxyConfigDir)) { + fs.mkdirSync(proxyConfigDir, { recursive: true }); + } + let endpointsData = { default: "", endpoints: [] }; + if (fs.existsSync(endpointsPath)) { + endpointsData = JSON.parse(fs.readFileSync(endpointsPath, 'utf8')); + } + let activeName = ""; + if (fs.existsSync(activePath)) { + try { + const activeData = JSON.parse(fs.readFileSync(activePath, 'utf8')); + activeName = activeData.active; + } catch (e) {} + } + if (!activeName) { + activeName = endpointsData.default || (endpointsData.endpoints[0] && endpointsData.endpoints[0].name) || ""; + } + const endpoint = endpointsData.endpoints.find(e => e.name === activeName) || endpointsData.endpoints[0]; + if (!endpoint) { + console.log('[Codex] No active endpoint configured in ~/.codex/endpoints.json'); + resolve(); + return; + } + const modelList = endpoint.models || []; + const pcfg = { + port: 48080, + backend_type: endpoint.backend_type, + target_url: endpoint.base_url, + api_key: endpoint.api_key, + cc_version: endpoint.cc_version || "", + oauth_provider: endpoint.oauth_provider || "", + reasoning_enabled: endpoint.reasoning_enabled !== undefined ? endpoint.reasoning_enabled : true, + reasoning_effort: endpoint.reasoning_effort || "medium", + models: modelList.map(m => ({ id: m, object: "model", created: 1700000000, owned_by: endpoint.name })) + }; + fs.writeFileSync(activeConfigPath, JSON.stringify(pcfg, null, 2), 'utf8'); + console.log('[AG X] Starting built-in Node.js translation proxy for:', endpoint.name); + // Use Electron's built-in Node.js to run our translation proxy + const proxyScript = path_1.default.join(__dirname, 'services', 'translationProxy.js'); + const nodeBin = process.execPath; + _proxyProcess = (0, child_process_1.spawn)(nodeBin, [proxyScript], { + stdio: 'ignore', + detached: true, + env: { ...process.env } + }); + _proxyProcess.unref(); + setTimeout(() => { + resolve(); + }, 500); + } catch (err) { + console.error('[AG X] Failed to auto-start translation proxy:', err); + resolve(); + } + }); +} + +function startLanguageServer(port, csrf, headless) { + return new Promise(async (resolve, reject) => { + await ensureProxyStarted(); + process.env.ANTIGRAVITY_API_SERVER_URL = 'http://127.0.0.1:48080'; + process.env.ANTIGRAVITY_CLOUD_CODE_ENDPOINT = 'http://127.0.0.1:48080'; + const logStream = fs.createWriteStream((0, paths_1.getLsLogPath)(), { flags: 'w' }); + // We need to pass the override flags because the LS is running in standalone mode + const args = [ + '--standalone', + '--override_ide_name', + 'ag-x', + '--subclient_type', + 'hub', + '--override_ide_version', + electron_1.app.getVersion(), + '--override_user_agent_name', + 'ag-x', + '--https_server_port', + String(port), + '--csrf_token', + csrf, + '--app_data_dir', + (0, paths_1.getAppDataDirName)(), + '--api_server_url', + process.env.ANTIGRAVITY_API_SERVER_URL || 'https://generativelanguage.googleapis.com', + '--cloud_code_endpoint', + process.env.ANTIGRAVITY_CLOUD_CODE_ENDPOINT || 'https://daily-cloudcode-pa.googleapis.com', + '--enable_sidecars', + ]; + if (headless) { + args.push('--headless'); + } + console.log(`\nSpawning: ${exports.LS_BINARY} ${args.join(' ')}\n`); + // Electron apps don't inherit shell environment variables when they are not launched through the terminal. + // We need to load the shell env explicitly so the language server can discover tools in the user's environment. + const env = { ...process.env, ...(0, shell_env_1.shellEnvSync)() }; + // We don't read the file to avoid adding start up latency. + // LS will read when browser recording encoder is invoked. + env['AGY_BROWSER_ACTIVE_PORT_FILE'] = (0, paths_1.getActivePortFilePath)(); + (0, utils_1.setupNodeWrapper)(env); + setupNodeModules(env, [ + { + name: 'chrome-devtools-mcp', + envVar: 'CHROME_DEVTOOLS_MCP_JS', + relativePath: ['build', 'src', 'bin', 'chrome-devtools-mcp.js'], + }, + ]); + _lsProcess = (0, child_process_1.spawn)(exports.LS_BINARY, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); + if (!headless) { + // Close stdin immediately โ€” the LS may block waiting for metadata on stdin. + _lsProcess.stdin.end(); + } + const combined = new stream_1.PassThrough(); + _lsProcess.stdout.pipe(combined, { end: false }); + _lsProcess.stderr.pipe(combined, { end: false }); + // Buffer stderr for crash log extraction (ring buffer) + const stderrChunks = []; + let stderrLength = 0; + _lsProcess.stderr.on('data', (data) => { + const str = data.toString(); + stderrChunks.push(str); + stderrLength += str.length; + while (stderrChunks.length > 0 && stderrLength > MAX_STDERR_BUFFER) { + stderrLength -= stderrChunks.shift().length; + } + }); + let resolved = false; + let logStreamEnded = false; + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error(`Timeout: language server did not report its port within ${LS_STARTUP_TIMEOUT_MS / 1000}s`)); + } + }, LS_STARTUP_TIMEOUT_MS); + const rl = readline.createInterface({ input: combined, crlfDelay: Infinity }); + rl.on('close', () => { + if (!logStreamEnded) { + logStreamEnded = true; + logStream.end(); + } + }); + rl.on('line', (line) => { + if (!logStreamEnded) { + logStream.write(line + '\n'); + } + if (!resolved) { + const m = PORT_PATTERN.exec(line); + if (m) { + resolved = true; + clearTimeout(timer); + const actualPort = parseInt(m[1], 10); + _lsPort = actualPort; + resolve({ + port: actualPort, + process: _lsProcess, + exitPromise, + }); + } + } + const authMatch = AUTH_URL_PATTERN.exec(line); + if (authMatch) { + console.log('\n' + '='.repeat(60)); + console.log(' Please visit the following URL to authorize.'); + console.log(' After authorizing, paste the authorization code below.'); + console.log(` ${authMatch[0]}`); + console.log('='.repeat(60) + '\n'); + } + }); + // Exit promise โ€” resolves whenever the process exits (whether during + // startup or after). Includes crash stack trace extraction. + const exitPromise = new Promise((exitResolve) => { + _lsProcess.on('exit', (code, signal) => { + if (!logStreamEnded) { + logStreamEnded = true; + logStream.end(); + } + const fullStderr = stderrChunks.join(''); + const crashStackTrace = extractCrashStackTrace(fullStderr); + // If we haven't resolved the startup promise yet, reject it. + if (!resolved) { + resolved = true; + clearTimeout(timer); + reject(new Error(`Language server exited unexpectedly (code=${code}, signal=${signal})`)); + } + exitResolve({ code, signal, crashStackTrace }); + }); + }); + }); +} +/** Sets whether the termination was intentional (suppresses crash reports). */ +function setIntentionalTermination(value) { + _intentionalTermination = value; +} +/** + * Start the language server AND set up the restart monitoring loop. + * Resolves with the handle on first successful startup. + */ +async function startAndMonitorLanguageServer(port, csrf, options = {}) { + setIntentionalTermination(false); // Reset + const handle = await startLanguageServer(port, csrf, options.headless); + _lsPort = handle.port; + if (options.onPortChanged) { + options.onPortChanged(_lsPort); + } + monitorLsCrashInternal(handle, port, csrf, options); + return handle; +} +function monitorLsCrashInternal(handle, port, csrf, options) { + void handle.exitPromise.then(async (exitInfo) => { + clearLsProcess(); + if (_intentionalTermination) { + return; + } + const { code, signal, crashStackTrace } = exitInfo; + const summary = signal + ? `killed by signal ${signal}` + : `exited with code ${code}`; + console.error(`\nLanguage server crashed: ${summary}`); + if (crashStackTrace) { + console.error('--- Crash Stack Trace ---'); + console.error(crashStackTrace); + console.error('--- End Crash Stack trace ---'); + } + const now = Date.now(); + if (now - _lastRestartTime > RESTART_WINDOW_MS) { + _restartCount = 0; + } + _lastRestartTime = now; + if (_restartCount >= MAX_RESTARTS) { + const msg = `Language server crashed ${MAX_RESTARTS} times in a row. Giving up.`; + console.error(msg); + return; + } + _restartCount++; + console.log(`Attempting restart ${_restartCount}/${MAX_RESTARTS} in ${RESTART_COOLDOWN_MS / 1000}s...`); + await sleep(RESTART_COOLDOWN_MS); + if (_intentionalTermination) { + return; + } + try { + const newHandle = await startLanguageServer(port, csrf); + _lsPort = newHandle.port; + if (options.onPortChanged) { + options.onPortChanged(_lsPort); + } + // Recurse + monitorLsCrashInternal(newHandle, port, csrf, options); + } + catch (err) { + console.error(`Failed to restart language server: ${err.message}`); + } + }); +} +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +async function killLanguageServer() { + setIntentionalTermination(true); + const proc = getLsProcess(); + if (proc) { + const pid = proc.pid; + console.log('Shutting down language serverโ€ฆ'); + const exitPromise = new Promise((resolve) => { + proc.once('exit', () => { + resolve(); + }); + }); + proc.kill('SIGTERM'); + const result = await Promise.race([ + exitPromise.then(() => 'exited'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 5000)), + ]); + if (result === 'timeout' && pid !== undefined) { + console.warn(`Language server (PID ${pid}) did not exit gracefully within 5s. Sending SIGKILL.`); + try { + process.kill(pid, 'SIGKILL'); + } + catch { + // Process already dead or exited + } + } + clearLsProcess(); + } + if (_proxyProcess) { + console.log('[Codex] Stopping background translation proxyโ€ฆ'); + try { + _proxyProcess.kill('SIGTERM'); + } + catch (e) {} + _proxyProcess = null; + } +} +/** + * Sets up certificate verification in Electron to trust the local self-signed cert + * used by the language server. It verifies that the certificate fingerprint matches + * the hardcoded `LS_CERT_FINGERPRINT`. + * + * TODO: Generate the cert.pem file dynamically + */ +function setupLocalCertTrust() { + electron_1.session.defaultSession.setCertificateVerifyProc((request, callback) => { + if ((request.hostname === '127.0.0.1' || request.hostname === 'localhost') && + request.certificate.fingerprint === constants_1.LS_CERT_FINGERPRINT) { + callback(0); // Accept + } + else { + callback(-3); // Default validation + } + }); +} diff --git a/dist/languageServer.test.js b/dist/languageServer.test.js new file mode 100644 index 0000000..62d2112 --- /dev/null +++ b/dist/languageServer.test.js @@ -0,0 +1,81 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const child_process_1 = require("child_process"); +vitest_1.vi.mock('electron', () => ({ + app: { + isPackaged: false, + }, +})); +vitest_1.vi.mock('child_process', async () => { + return { + execFile: vitest_1.vi.fn(), + spawn: vitest_1.vi.fn(), + }; +}); +const languageServer_1 = require("./languageServer"); +(0, vitest_1.describe)('extractCrashStackTrace', () => { + (0, vitest_1.it)('should extract lines after "running GoogleExitFunction"', () => { + const stderr = [ + 'INFO: server starting', + 'INFO: listening on port 5387', + 'running GoogleExitFunction', + 'goroutine 1 [running]:', + 'main.main()', + ].join('\n'); + const result = (0, languageServer_1.extractCrashStackTrace)(stderr); + (0, vitest_1.expect)(result).toContain('running GoogleExitFunction'); + (0, vitest_1.expect)(result).toContain('goroutine 1 [running]:'); + (0, vitest_1.expect)(result).toContain('main.main()'); + (0, vitest_1.expect)(result).not.toContain('server starting'); + }); + (0, vitest_1.it)('should extract lines after "http2: panic serving"', () => { + const stderr = [ + 'INFO: normal log', + 'http2: panic serving 127.0.0.1:443', + 'runtime error: invalid memory address', + ].join('\n'); + const result = (0, languageServer_1.extractCrashStackTrace)(stderr); + (0, vitest_1.expect)(result).toContain('http2: panic serving'); + (0, vitest_1.expect)(result).toContain('runtime error'); + (0, vitest_1.expect)(result).not.toContain('normal log'); + }); + (0, vitest_1.it)('should return undefined when no crash trigger is found', () => { + const stderr = [ + 'INFO: server starting', + 'INFO: listening on port 5387', + 'INFO: server shutting down', + ].join('\n'); + const result = (0, languageServer_1.extractCrashStackTrace)(stderr); + (0, vitest_1.expect)(result).toBeUndefined(); + }); + (0, vitest_1.it)('should return undefined for empty stderr', () => { + (0, vitest_1.expect)((0, languageServer_1.extractCrashStackTrace)('')).toBeUndefined(); + }); +}); +(0, vitest_1.describe)('getLsCL', () => { + (0, vitest_1.it)('should return CL number when stamp output contains it', async () => { + const mockExecFile = child_process_1.execFile; + mockExecFile.mockImplementation((file, args, callback) => { + callback(null, 'Built at CL: 12345\n', ''); + }); + const cl = await (0, languageServer_1.getLsCL)(); + (0, vitest_1.expect)(cl).toBe('12345'); + }); + (0, vitest_1.it)('should return empty string when stamp output does not contain CL', async () => { + const mockExecFile = child_process_1.execFile; + mockExecFile.mockImplementation((file, args, callback) => { + callback(null, 'Built on: today\n', ''); + }); + const cl = await (0, languageServer_1.getLsCL)(); + (0, vitest_1.expect)(cl).toBe(''); + }); + (0, vitest_1.it)('should return empty string when execFile fails', async () => { + const mockExecFile = child_process_1.execFile; + mockExecFile.mockImplementation((file, args, callback) => { + callback(new Error('fail'), '', ''); + }); + const cl = await (0, languageServer_1.getLsCL)(); + (0, vitest_1.expect)(cl).toBe(''); + }); +}); diff --git a/dist/loadingOverlay.js b/dist/loadingOverlay.js new file mode 100644 index 0000000..8c43164 --- /dev/null +++ b/dist/loadingOverlay.js @@ -0,0 +1,100 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.attachLoadingOverlay = attachLoadingOverlay; +const electron_1 = require("electron"); +/** + * Generates the HTML content for the initial loading screen overlay. + * This is injected into a WebContentsView and shown to the user before + * the main application bundle finishes loading. + * + * @param foregroundColor - The text and loader animation color (hex or CSS color string). + * @param backgroundColor - The background color of the loading view. + */ +function getLoadingHtml(foregroundColor, backgroundColor) { + return ` + + + + + + +
+
+
+
Loading AG X
+ + + `; +} +/** + * Attaches a temporary WebContentsView overlay that shows a loading animation. + * It is automatically removed when the window's main content finishes loading. + */ +function attachLoadingOverlay(win, foregroundColor, backgroundColor) { + const view = new electron_1.WebContentsView({ + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + const html = getLoadingHtml(foregroundColor, backgroundColor); + void view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + win.contentView.addChildView(view); + const updateBounds = () => { + const [width, height] = win.getContentSize(); + view.setBounds({ x: 0, y: 0, width, height }); + }; + updateBounds(); + win.on('resize', updateBounds); + win.webContents.once('did-finish-load', () => { + try { + win.contentView.removeChildView(view); + } + catch (_) { + // In case window was closed quickly + } + win.off('resize', updateBounds); + }); +} diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..7412fc2 --- /dev/null +++ b/dist/main.js @@ -0,0 +1,587 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const electron_1 = require("electron"); +const main_1 = __importDefault(require("electron-log/main")); +const ipcHandlers_1 = require("./ipcHandlers"); +const fs = __importStar(require("fs")); +const crypto = __importStar(require("crypto")); +const readline = __importStar(require("readline")); +const utils_1 = require("./utils"); +const languageServer_1 = require("./languageServer"); +const updater_1 = require("./updater"); +const constants_1 = require("./constants"); +const tray_1 = require("./tray"); +const storage_1 = require("./storage"); +const paths_1 = require("./paths"); +const menu_1 = require("./menu"); +const customScheme_1 = require("./customScheme"); +const settingsService_1 = require("./services/settingsService"); +const ideInstall_1 = require("./ideInstall"); +const providerService_1 = require("./services/providerService"); +const apiProxy_1 = require("./services/apiProxy"); +const providerSettings_1 = require("./providerSettings"); +const path_2 = require("path"); +const fs_1 = require("fs"); +const gotTheLock = electron_1.app.requestSingleInstanceLock(); +if (!gotTheLock) { + electron_1.app.quit(); + process.exit(0); +} +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let storageManager; +let settingsService; +let hasStartedMainApplication = false; +let isQuitting = false; +let providerService; +let apiProxy; +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +// Driven by ELECTRON_OZONE_PLATFORM_HINT=headless env var. +// This single env var both prevents GTK from crashing (Electron 33+) +// and tells our code to skip createWindow(). +const HEADLESS = process.env.ELECTRON_OZONE_PLATFORM_HINT === 'headless'; +// When set, skip LS startup and load this URL directly (for dev iteration). +const DEV_URL = process.env.DEV_URL; +if (HEADLESS) { + electron_1.app.commandLine.appendSwitch('ozone-platform', 'headless'); + electron_1.app.commandLine.appendSwitch('headless'); + electron_1.app.commandLine.appendSwitch('disable-gpu'); + electron_1.app.commandLine.appendSwitch('no-sandbox'); +} +if (!electron_1.app.commandLine.hasSwitch('remote-debugging-port')) { + electron_1.app.commandLine.appendSwitch('remote-debugging-port', '0'); +} +// --------------------------------------------------------------------------- +// Application Lifecycle +// --------------------------------------------------------------------------- +let pendingDeepLink = null; +function handleDeepLink(url) { + const wins = electron_1.BrowserWindow.getAllWindows(); + // This block handles deep links when windows are already open. + if (wins.length > 0) { + if (wins[0].isMinimized()) { + wins[0].restore(); + } + wins[0].show(); + wins[0].focus(); + electron_1.app.focus({ steal: true }); + wins[0].webContents.send('deep-link', url); + } + else { + pendingDeepLink = url; + } +} +electron_1.app.on('second-instance', (event, commandLine) => { + const wins = electron_1.BrowserWindow.getAllWindows(); + if (wins.length > 0) { + if (wins[0].isMinimized()) { + wins[0].restore(); + } + wins[0].show(); + wins[0].focus(); + electron_1.app.focus({ steal: true }); + } + const url = commandLine.find((arg) => arg.startsWith('ag-x://')); + if (url) { + handleDeepLink(url); + } +}); +(0, customScheme_1.registerCustomSchemes)(); +// Register as default protocol client for deep linking +const PROTOCOL = 'ag-x'; +if (!electron_1.app.isDefaultProtocolClient(PROTOCOL)) { + electron_1.app.setAsDefaultProtocolClient(PROTOCOL); +} +electron_1.app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); +}); +/** + * App entry point. Runs once Electron has finished initializing. + * Validates the LS binary, frees the port if needed, spawns the LS, + * and opens the initial browser window. + */ +electron_1.app + .whenReady() + .then(async () => { + // Initialize electron-log and override console + main_1.default.initialize(); + Object.assign(console, main_1.default.functions); + const storagePath = (0, paths_1.getAppStoragePath)(); + storageManager = new storage_1.StorageManager(storagePath, settingsService_1.DEFAULTS); + settingsService = new settingsService_1.SettingsService(storageManager); + // Initialize AI Provider Service + providerService = new providerService_1.ProviderService(storageManager); + console.log(`[Provider] Active provider: ${providerService.getActiveProvider()}`); + + // Start API proxy if a non-Gemini provider is active + if (providerService.needsProxy()) { + apiProxy = new apiProxy_1.ApiProxy(providerService); + apiProxy.start(); + console.log(`[Provider] API proxy started for ${providerService.getActiveProvider()}`); + } + + // Handle deep link URL from command line arguments (All platforms) + const deepLinkFromArg = process.argv.find((arg) => arg.startsWith('ag-x://')); + if (deepLinkFromArg) { + console.log('Launched with deep link:', deepLinkFromArg); + pendingDeepLink = deepLinkFromArg; + } + // Register IPC handlers + (0, ipcHandlers_1.registerIpcHandlers)(storageManager); + // Register provider IPC handlers + electron_1.ipcMain.handle('provider:get-active', async () => { + return providerService.getActiveProvider(); + }); + electron_1.ipcMain.handle('provider:get-all', async () => { + return providerService.getAllProviders(); + }); + electron_1.ipcMain.handle('provider:open-settings', async () => { + (0, providerSettings_1.openProviderSettings)(providerService); + }); + + electron_1.ipcMain.handle('deep-link:get-stored', () => { + const link = pendingDeepLink; + pendingDeepLink = null; // Clear after read + return link; + }); + // Handle requests coming from custom schemes + (0, customScheme_1.registerCustomSchemeHandlers)(); + // Set About panel options with LS CL + const cl = await (0, languageServer_1.getLsCL)(); + electron_1.app.setAboutPanelOptions({ + applicationName: 'AG X', + applicationVersion: electron_1.app.getVersion(), + version: cl || undefined, + }); + // Pre-onboarding: check if we should offer to re-install the IDE. + // This runs before the LS starts so we can show a standalone wizard. + if (!HEADLESS) { + await (0, ideInstall_1.maybeShowIdeInstallWizard)(storageManager); + } + if (DEV_URL) { + console.log('Starting in dev mode with URL:', DEV_URL); + (0, utils_1.createWindow)(DEV_URL); + hasStartedMainApplication = true; + return; + } + if (!fs.existsSync(languageServer_1.LS_BINARY)) { + const msg = `language_server binary not found at:\n${languageServer_1.LS_BINARY}\n\nPlease build set a valid location.`; + if (HEADLESS) { + console.error('ERROR:', msg); + } + else { + await electron_1.dialog.showErrorBox('Binary not found', msg); + } + electron_1.app.quit(); + return; + } + const csrf = crypto.randomUUID(); + console.log(`Starting app (v${electron_1.app.getVersion()}) with dynamic portโ€ฆ`); + // Check if first run BEFORE starting LS so we can configure provider first + const isFirstRun = !fs_1.existsSync(providerService.configPath); + if (isFirstRun && !HEADLESS) { + console.log('[Welcome] First run detected โ€” showing provider choice screen'); + await showWelcomeScreen('about:blank'); + console.log('[Welcome] User selected provider:', providerService.getActiveProvider()); + // Sync to endpoints.json (for Google, the handler already set the endpoint; + // for custom, the settings save handler synced. This is a safety sync.) + if (providerService.getActiveProvider() !== 'google_gemini') { + syncProviderToEndpoints(providerService); + } + } + let handle; + const targetPort = Number(process.env.JETSKI_LS_PORT) || constants_1.DYNAMIC_PORT; + try { + handle = await (0, languageServer_1.startAndMonitorLanguageServer)(targetPort, csrf, { + headless: HEADLESS, + onPortChanged: (newPort) => { + const newUrl = `${constants_1.WINDOW_ORIGIN}:${newPort}/`; + console.log(`[Auto-Restart] Port changed! Reloading all windows with URL: ${newUrl}`); + (0, languageServer_1.setupLocalCertTrust)(); + if (!HEADLESS) { + const windows = electron_1.BrowserWindow.getAllWindows(); + for (const win of windows) { + if (win.getTitle() === 'Welcome to AG X' || win.getTitle() === 'AI Provider Settings') continue; + void win.loadURL(newUrl); + } + } + }, + }); + } + catch (err) { + const msg = err.message; + if (HEADLESS) { + console.error('Startup failed:', msg); + } + else { + await electron_1.dialog.showErrorBox('Startup failed', msg); + } + electron_1.app.quit(); + return; + } + const url = `${constants_1.WINDOW_ORIGIN}:${handle.port}/`; + console.log('\n' + '='.repeat(60)); + console.log(` Local: ${url}`); + console.log(` LS Logs: ${(0, paths_1.getLsLogPath)()}`); + console.log(` Electron Logs: ${main_1.default.transports.file.getFile().path}`); + console.log(` Provider: ${providerService.getActiveProvider()}`); + console.log('='.repeat(60) + '\n'); + if (HEADLESS) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.on('line', (line) => { + const lsProc = (0, languageServer_1.getLsProcess)(); + if (lsProc && lsProc.stdin) { + lsProc.stdin.write(line + '\n'); + console.log('-> Forwarded input to Language Server.'); + } + else { + console.log('Language Server process is not running.'); + } + }); + } + // Initial window โ€” opened once after the LS has successfully started. + if (!HEADLESS) { + (0, menu_1.setupApplicationMenu)(url); + // Create the main window (welcome screen already completed above for first run) + (0, utils_1.createWindow)(url); + if (electron_1.app.dock) { + const dockMenu = electron_1.Menu.buildFromTemplate([ + { + label: 'New Window', + click: () => (0, utils_1.createWindow)(url), + }, + ]); + electron_1.app.dock.setMenu(dockMenu); + } + (0, tray_1.createTray)([ + { + id: 'running-agents', + label: 'No agents running', + enabled: false, + }, + { type: 'separator' }, + { + label: `Open ${electron_1.app.getName()}`, + click: () => (0, utils_1.showOrCreateWindow)((0, languageServer_1.getLsPort)()), + }, + { + label: 'AI Provider Settings', + click: () => (0, providerSettings_1.openProviderSettings)(providerService), + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + // Triggers 'before-quit' to run graceful cleanup without confirmation. + electron_1.app.quit(); + }, + }, + ]); + } + // Start checking for app updates. + (0, updater_1.initAutoUpdater)(HEADLESS); + hasStartedMainApplication = true; +}) + .catch(() => { + hasStartedMainApplication = true; +}); + +/** + * Shows the first-run welcome screen for provider selection. + * Returns a Promise that resolves when the user has made their choice. + */ + +// === AG X: Sync provider config to ~/.codex/endpoints.json === +function syncProviderToEndpoints(providerService) { + try { + const os = require('os'); + const fs = require('fs'); + const pathMod = require('path'); + const home = os.homedir(); + const endpointsPath = pathMod.join(home, '.codex', 'endpoints.json'); + const activePath = pathMod.join(home, '.codex', '.active-endpoint.json'); + + const activeProvider = providerService.getActiveProvider(); + const providerConfig = providerService.getProviderConfig(activeProvider); + + if (!providerConfig) { + console.log('[Sync] No provider config to sync'); + return; + } + + let endpointsData = { default: '', endpoints: [] }; + if (fs.existsSync(endpointsPath)) { + try { + endpointsData = JSON.parse(fs.readFileSync(endpointsPath, 'utf8')); + } catch (e) {} + } + + const endpointName = 'AG X: ' + (providerConfig.name || activeProvider); + const existingIdx = endpointsData.endpoints.findIndex(e => e.name === endpointName); + + const endpoint = { + name: endpointName, + backend_type: providerConfig.backendType || 'openai-compat', + base_url: providerConfig.apiUrl || '', + api_key: providerConfig.apiKey || '', + default_model: providerConfig.model || providerConfig.defaultModel || '', + models: providerConfig.models || [], + provider_preset: 'AG X', + }; + + if (existingIdx >= 0) { + endpointsData.endpoints[existingIdx] = endpoint; + } else { + endpointsData.endpoints.push(endpoint); + } + + endpointsData.default = endpointName; + fs.writeFileSync(endpointsPath, JSON.stringify(endpointsData, null, 2), 'utf-8'); + fs.writeFileSync(activePath, JSON.stringify({ active: endpointName }, null, 2), 'utf-8'); + console.log('[Sync] Provider synced to endpoints.json:', endpointName); + } catch (err) { + console.error('[Sync] Failed to sync provider to endpoints:', err); + } +} +// === End sync function === + +function showWelcomeScreen(mainUrl) { + return new Promise((resolve) => { + const welcomeWin = new electron_1.BrowserWindow({ + width: 720, + height: 560, + title: 'Welcome to AG X', + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + backgroundColor: '#0a0a1a', + show: true, + center: true, + frame: false, + titleBarStyle: 'hidden', + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + enableRemoteModule: false, + }, + }); + welcomeWin.loadFile(path_2.join(__dirname, 'provider', 'welcome.html')).catch((err) => { + console.error('[Welcome] Failed to load welcome.html:', err); + }); + welcomeWin.webContents.on('did-finish-load', () => { + console.log('[Welcome] Welcome screen loaded successfully'); + }); + welcomeWin.webContents.on('did-fail-load', (_event, code, desc) => { + console.error('[Welcome] Failed to load:', code, desc); + }); + console.log('[Welcome] Window created, title:', welcomeWin.getTitle()); + welcomeWin.on('closed', () => { + // If closed without choosing, default to Google Gemini + if (!resolved) { + resolved = true; + providerService.setActiveProvider('google_gemini'); + providerService.saveConfig(); + resolve(); + } + }); + let resolved = false; + electron_1.ipcMain.once('welcome:choice', (_event, data) => { + if (resolved) return; + resolved = true; + if (data.provider === 'google_gemini') { + console.log('[Welcome] User chose Google Gemini (OAuth)'); + providerService.setActiveProvider('google_gemini'); + providerService.saveConfig(); + // Set Google as active endpoint in ~/.codex/ + try { + const os = require('os'); + const fs = require('fs'); + const pathMod = require('path'); + const activePath = pathMod.join(os.homedir(), '.codex', '.active-endpoint.json'); + const endpointsPath = pathMod.join(os.homedir(), '.codex', 'endpoints.json'); + let epData = { default: '', endpoints: [] }; + if (fs.existsSync(endpointsPath)) { + try { epData = JSON.parse(fs.readFileSync(endpointsPath, 'utf8')); } catch(e) {} + } + let googleEp = epData.endpoints.find(e => e.name === 'Google Gemini'); + if (!googleEp) { + googleEp = { + name: 'Google Gemini', + backend_type: 'gemini-native', + base_url: 'https://daily-cloudcode-pa.sandbox.googleapis.com', + api_key: '', + default_model: 'gemini-2.5-pro', + models: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'], + provider_preset: 'AG X', + }; + epData.endpoints.push(googleEp); + } + epData.default = 'Google Gemini'; + fs.writeFileSync(endpointsPath, JSON.stringify(epData, null, 2), 'utf-8'); + fs.writeFileSync(activePath, JSON.stringify({ active: 'Google Gemini' }, null, 2), 'utf-8'); + console.log('[Welcome] Google Gemini endpoint set as active'); + } catch(e) { console.error('[Welcome] Failed to set Google endpoint:', e); } + welcomeWin.close(); + // Don't createWindow here โ€” LS hasn't started yet, main window opens after LS starts + } else { + console.log('[Welcome] User chose custom provider โ€” opening settings'); + welcomeWin.close(); + // Open provider settings and wait for user to save + (0, providerSettings_1.openProviderSettings)(providerService); + // Listen for the settings save to complete before continuing + const settingsSaveHandler = async () => { + console.log('[Welcome] Provider settings saved, syncing and continuing startup'); + syncProviderToEndpoints(providerService); + electron_1.ipcMain.removeListener('provider:settings-saved', settingsSaveHandler); + resolve(); + }; + electron_1.ipcMain.on('provider:settings-saved', settingsSaveHandler); + return; // Don't resolve yet - wait for settings save + } + resolve(); + }); + }); +} + +/** + * Fired when all windows have been closed. + * On macOS the app (and LS) stay alive so the user can re-open via the tray. + * On all other platforms, shut down the LS and quit. + */ +electron_1.app.on('window-all-closed', async () => { + if (isQuitting) { + return; + } + if (!hasStartedMainApplication) { + return; + } + // Show provider settings on first launch if no API key configured + const activeProviderType = providerService.getActiveProvider(); + const activeProviderConfig = providerService.getProviderConfig(activeProviderType); + if (activeProviderConfig && activeProviderConfig.requiresApiKey && !activeProviderConfig.apiKey) { + console.log('[Provider] No API key configured for active provider, showing settings...'); + // Delay slightly so the main window renders first + setTimeout(() => { + (0, providerSettings_1.openProviderSettings)(providerService); + }, 2000); + } + + const runInBackground = await settingsService.getSetting(settingsService_1.SettingKey.RUN_IN_BACKGROUND); + if (!runInBackground) { + // Triggers 'before-quit' to run graceful cleanup without confirmation. + electron_1.app.quit(); + } + else { + electron_1.app.dock?.hide(); + } +}); +/** + * Fired just before the app quits (e.g. Cmd+Q on macOS, or after + * window-all-closed on non-macOS). Ensures the LS is terminated even if + * window-all-closed didn't handle it (e.g. on macOS quit via menu). + */ +electron_1.app.on('before-quit', async (event) => { + if (isQuitting) { + return; + } + if (!utils_1.showQuitConfirmation) { + event.preventDefault(); + isQuitting = true; + // Destroy all windows to terminate renderers and release keep-alive sockets + const windows = electron_1.BrowserWindow.getAllWindows(); + for (const win of windows) { + win.destroy(); + } + // Stop API proxy + if (apiProxy) { + apiProxy.stop(); + } + + // Close all active connections and kill the language server in parallel + await Promise.all([ + electron_1.session.defaultSession.closeAllConnections().catch((err) => { + console.error('Failed to close session connections:', err); + }), + (0, languageServer_1.killLanguageServer)(), + ]); + electron_1.app.quit(); + return; + } + // Show a confirmation dialog before quitting + event.preventDefault(); + const win = electron_1.BrowserWindow.getFocusedWindow() || electron_1.BrowserWindow.getAllWindows()[0]; + const options = { + type: 'question', + buttons: ['Cancel', 'Quit'], + defaultId: 1, + cancelId: 0, + title: 'Confirm Quit', + message: 'Are you sure you want to quit?', + detail: 'There may be agents or background tasks running.', + }; + (0, utils_1.setShowQuitConfirmation)(false); + if (win) { + void electron_1.dialog.showMessageBox(win, options).then((result) => { + if (result.response === 1) { + // Quit - this will retrigger 'before-quit' + electron_1.app.quit(); + } + }); + } +}); +/** + * Fired when the app is re-activated (e.g. clicking the dock icon on macOS). + * Re-opens a window if none are currently open. + */ +electron_1.app.on('activate', () => { + // On Mac, re-open a window when the user clicks the dock + // icon and no windows are open. + if (!HEADLESS && electron_1.BrowserWindow.getAllWindows().length === 0) { + const url = DEV_URL ?? `${constants_1.WINDOW_ORIGIN}:${(0, languageServer_1.getLsPort)()}/`; + (0, utils_1.createWindow)(url); + } +}); diff --git a/dist/main.test.js b/dist/main.test.js new file mode 100644 index 0000000..a484fdf --- /dev/null +++ b/dist/main.test.js @@ -0,0 +1,200 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const helpers_1 = require("./test/helpers"); +const constants_1 = require("./constants"); +// Use the shared auto-mocks from __mocks__/ +vitest_1.vi.mock('electron'); +vitest_1.vi.mock('fs', () => ({ + existsSync: vitest_1.vi.fn(), + readFileSync: vitest_1.vi.fn(), +})); +vitest_1.vi.mock('crypto', () => ({ + randomUUID: vitest_1.vi.fn().mockReturnValue('mock-uuid'), +})); +vitest_1.vi.mock('./utils', () => ({ + sleep: vitest_1.vi.fn().mockResolvedValue(undefined), + createWindow: vitest_1.vi.fn(), + showOrCreateWindow: vitest_1.vi.fn(), + ensureAppIsInDock: vitest_1.vi.fn(), + isMacOS: vitest_1.vi.fn().mockReturnValue(true), + showQuitConfirmation: false, + setShowQuitConfirmation: vitest_1.vi.fn(), + SleepBlocker: { + getInstance: vitest_1.vi.fn().mockReturnValue({ + shouldKeepComputerAwake: vitest_1.vi.fn(), + }), + }, +})); +vitest_1.vi.mock('./languageServer', () => ({ + LS_BINARY: '/mock/ls', + getLsProcess: vitest_1.vi.fn(), + clearLsProcess: vitest_1.vi.fn(), + startLanguageServer: vitest_1.vi.fn(), + killLanguageServer: vitest_1.vi.fn(), + startAndMonitorLanguageServer: vitest_1.vi.fn(), + setupLocalCertTrust: vitest_1.vi.fn(), + getLsCL: vitest_1.vi.fn().mockResolvedValue('12345'), +})); +vitest_1.vi.mock('./updater', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initAutoUpdater: vitest_1.vi.fn(), + }; +}); +vitest_1.vi.mock('./ipcHandlers', () => ({ + registerIpcHandlers: vitest_1.vi.fn(), +})); +vitest_1.vi.mock('./tray', () => ({ + createTray: vitest_1.vi.fn(), +})); +vitest_1.vi.mock('./ideInstall', () => ({ + maybeShowIdeInstallWizard: vitest_1.vi.fn().mockResolvedValue('skipped'), +})); +(0, vitest_1.describe)('main', () => { + (0, vitest_1.beforeEach)(async () => { + vitest_1.vi.clearAllMocks(); + vitest_1.vi.resetModules(); + (0, helpers_1.silenceConsole)(); + const { app } = await Promise.resolve().then(() => __importStar(require('electron'))); + app.setAboutPanelOptions = vitest_1.vi.fn(); + }); + (0, vitest_1.it)('should initialize the app correctly on successful startup', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const { createWindow } = await Promise.resolve().then(() => __importStar(require('./utils'))); + const { startAndMonitorLanguageServer } = await Promise.resolve().then(() => __importStar(require('./languageServer'))); + const { initAutoUpdater } = await Promise.resolve().then(() => __importStar(require('./updater'))); + const { createTray } = await Promise.resolve().then(() => __importStar(require('./tray'))); + const { app } = await Promise.resolve().then(() => __importStar(require('electron'))); + const { registerIpcHandlers } = await Promise.resolve().then(() => __importStar(require('./ipcHandlers'))); + const ACTUAL_PORT = 49152; + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + vitest_1.vi.mocked(startAndMonitorLanguageServer).mockResolvedValue({ + port: ACTUAL_PORT, + process: { pid: 1234 }, + exitPromise: new Promise(() => { }), + }); + // Import main to trigger top-level registration + await Promise.resolve().then(() => __importStar(require('./main'))); + // Trigger the whenReady callback + const whenReadyCall = vitest_1.vi.mocked(app.whenReady).mock.results[0].value; + await whenReadyCall.cb(); + (0, vitest_1.expect)(startAndMonitorLanguageServer).toHaveBeenCalledWith(constants_1.DYNAMIC_PORT, 'mock-uuid', vitest_1.expect.objectContaining({ headless: false })); + (0, vitest_1.expect)(registerIpcHandlers).toHaveBeenCalled(); + (0, vitest_1.expect)(createWindow).toHaveBeenCalled(); + (0, vitest_1.expect)(createTray).toHaveBeenCalled(); + (0, vitest_1.expect)(initAutoUpdater).toHaveBeenCalled(); + (0, vitest_1.expect)(app.setAboutPanelOptions).toHaveBeenCalledWith(vitest_1.expect.objectContaining({ + applicationVersion: '1.0.0', + version: '12345', + })); + }); + (0, vitest_1.it)('should quit if language server binary is missing', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const { app, dialog } = await Promise.resolve().then(() => __importStar(require('electron'))); + vitest_1.vi.mocked(existsSync).mockReturnValue(false); + await Promise.resolve().then(() => __importStar(require('./main'))); + const whenReadyCall = vitest_1.vi.mocked(app.whenReady).mock.results[0].value; + await whenReadyCall.cb(); + (0, vitest_1.expect)(dialog.showErrorBox).toHaveBeenCalledWith('Binary not found', vitest_1.expect.stringContaining('language_server binary not found')); + (0, vitest_1.expect)(app.quit).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should use dynamic port assignment (port 0) without port-conflict checks', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const { createWindow } = await Promise.resolve().then(() => __importStar(require('./utils'))); + const { startAndMonitorLanguageServer } = await Promise.resolve().then(() => __importStar(require('./languageServer'))); + const { app } = await Promise.resolve().then(() => __importStar(require('electron'))); + // Simulate the OS assigning a random high port + const OS_ASSIGNED_PORT = 63421; + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + vitest_1.vi.mocked(startAndMonitorLanguageServer).mockResolvedValue({ + port: OS_ASSIGNED_PORT, + process: { pid: 1234 }, + exitPromise: new Promise(() => { }), + }); + await Promise.resolve().then(() => __importStar(require('./main'))); + const whenReadyCall = vitest_1.vi.mocked(app.whenReady).mock.results[0].value; + await whenReadyCall.cb(); + // Verify port 0 (DYNAMIC_PORT) is passed โ€” the OS picks the real port + (0, vitest_1.expect)(startAndMonitorLanguageServer).toHaveBeenCalledWith(constants_1.DYNAMIC_PORT, 'mock-uuid', vitest_1.expect.objectContaining({ headless: false })); + // Window should load the OS-assigned port, not a hardcoded one + (0, vitest_1.expect)(createWindow).toHaveBeenCalledWith(`https://127.0.0.1:${OS_ASSIGNED_PORT}/`); + }); + (0, vitest_1.it)('should quit on language server startup failure', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const { startAndMonitorLanguageServer } = await Promise.resolve().then(() => __importStar(require('./languageServer'))); + const { app, dialog } = await Promise.resolve().then(() => __importStar(require('electron'))); + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + vitest_1.vi.mocked(startAndMonitorLanguageServer).mockRejectedValue(new Error('Timeout: language server did not report its port within 60s')); + await Promise.resolve().then(() => __importStar(require('./main'))); + const whenReadyCall = vitest_1.vi.mocked(app.whenReady).mock.results[0].value; + await whenReadyCall.cb(); + (0, vitest_1.expect)(dialog.showErrorBox).toHaveBeenCalledWith('Startup failed', vitest_1.expect.stringContaining('Timeout')); + (0, vitest_1.expect)(app.quit).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should reload windows when onPortChanged is called', async () => { + const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs'))); + const { startAndMonitorLanguageServer } = await Promise.resolve().then(() => __importStar(require('./languageServer'))); + const { app, BrowserWindow } = await Promise.resolve().then(() => __importStar(require('electron'))); + vitest_1.vi.mocked(existsSync).mockReturnValue(true); + let onPortChangedCallback; + vitest_1.vi.mocked(startAndMonitorLanguageServer).mockImplementation(async (port, csrf, options) => { + onPortChangedCallback = options?.onPortChanged; + return { + port: 49152, + process: { + pid: 1234, + }, + exitPromise: new Promise(() => { }), + }; + }); + await Promise.resolve().then(() => __importStar(require('./main'))); + const whenReadyCall = vitest_1.vi.mocked(app.whenReady).mock.results[0].value; + await whenReadyCall.cb(); + (0, vitest_1.expect)(onPortChangedCallback).toBeDefined(); + // Re-trigger port changed (simulating auto-restart event) + const NEW_PORT = 50000; + if (onPortChangedCallback) { + onPortChangedCallback(NEW_PORT); + } + // Verify BrowserWindow instances are reloaded with new URL + (0, vitest_1.expect)(BrowserWindow.getAllWindows).toHaveBeenCalled(); + const windows = vitest_1.vi.mocked(BrowserWindow.getAllWindows).mock.results[0] + .value; + (0, vitest_1.expect)(windows[0].loadURL).toHaveBeenCalledWith(`https://127.0.0.1:${NEW_PORT}/`); + }); +}); diff --git a/dist/menu.js b/dist/menu.js new file mode 100644 index 0000000..e7337f6 --- /dev/null +++ b/dist/menu.js @@ -0,0 +1,81 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.setupApplicationMenu = setupApplicationMenu; +const electron_1 = require("electron"); +const utils_1 = require("./utils"); +const updater_1 = require("./updater"); +const providerSettings_1 = require("./providerSettings"); +/** + * Applies modifications to the default application menu. + * Adds "New Window", "AI Provider Settings", and other items. + */ +function setupApplicationMenu(url) { + const menu = electron_1.Menu.getApplicationMenu(); + if (!menu) { + return; + } + // Adds a "New Window" item to the top of the existing File menu. + addItemToSubmenu(menu, 'File', 0, new electron_1.MenuItem({ + label: 'New Window', + accelerator: 'CmdOrCtrl+Shift+N', + click: () => { + (0, utils_1.createWindow)(url); + }, + })); + // Add "AI Provider Settings" to the File menu + addItemToSubmenu(menu, 'File', 1, new electron_1.MenuItem({ + label: 'AI Provider Settings', + accelerator: 'CmdOrCtrl+,', + click: () => { + electron_1.BrowserWindow.getAllWindows()[0]?.webContents.send('provider:open-settings'); + electron_1.ipcMain.emit('provider:open-settings-request'); + }, + })); + // Add separator after new items + addItemToSubmenu(menu, 'File', 2, new electron_1.MenuItem({ + type: 'separator', + })); + // Add "Check for Updates" to the application menu on macOS. + if ((0, utils_1.isMacOS)()) { + const appSubmenu = menu.items[0]?.submenu; + if (appSubmenu) { + appSubmenu.insert(1, new electron_1.MenuItem({ + id: 'check-for-updates', + label: updater_1.MenuUpdateStep.CheckForUpdates, + click: (menuItem) => { + const action = updater_1.updateActions[menuItem.label]; + action?.(); + }, + })); + } + } + // Adds Docs, AI Provider Settings, and Toggle Developer Tools to the Help menu + addItemToSubmenu(menu, 'Help', 0, new electron_1.MenuItem({ + label: 'Docs', + click: async () => { + await electron_1.shell.openExternal('https://ag-x.dev/docs'); + }, + })); + addItemToSubmenu(menu, 'Help', 1, new electron_1.MenuItem({ + label: 'AI Provider Settings', + click: () => { + electron_1.BrowserWindow.getAllWindows()[0]?.webContents.send('provider:open-settings'); + electron_1.ipcMain.emit('provider:open-settings-request'); + }, + })); + addItemToSubmenu(menu, 'Help', 2, new electron_1.MenuItem({ + role: 'toggleDevTools', + })); + // Re-apply the menu so the change takes effect. + electron_1.Menu.setApplicationMenu(menu); +} +/** + * Adds a menu item to a submenu of the main application menu. + */ +function addItemToSubmenu(appMenu, submenuLabel, position, item) { + const submenuItem = appMenu.items.find((item) => item.label === submenuLabel); + if (!submenuItem?.submenu) { + return; + } + submenuItem.submenu.insert(position, item); +} diff --git a/dist/paths.js b/dist/paths.js new file mode 100644 index 0000000..5841fe6 --- /dev/null +++ b/dist/paths.js @@ -0,0 +1,49 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.IDE_BACKUP_DATA_DIR = exports.IDE_NEW_DATA_DIR = exports.IDE_OLD_DATA_DIR = void 0; +exports.getAppDataDirName = getAppDataDirName; +exports.getAppDataDir = getAppDataDir; +exports.getSettingsPbPath = getSettingsPbPath; +exports.getAppStoragePath = getAppStoragePath; +exports.getActivePortFilePath = getActivePortFilePath; +exports.getLsLogPath = getLsLogPath; +const electron_1 = require("electron"); +const path_1 = __importDefault(require("path")); +const os_1 = __importDefault(require("os")); +const constants_1 = require("./constants"); +function getAppDataDirName() { + return `ag-x${electron_1.app.isPackaged ? '' : '-dev'}`; +} +function getAppDataDir() { + return path_1.default.join(os_1.default.homedir(), '.ag-x', getAppDataDirName()); +} +function getSettingsPbPath() { + return path_1.default.join(os_1.default.homedir(), '.ag-x', 'config', 'config.json'); +} +/** + * Returns the path to the persistent app storage file. + * This is used to back a lightweight key-value store for UI state, + * and is not used for e.g. settings or other "core" app state. + */ +function getAppStoragePath() { + return path_1.default.join(electron_1.app.getPath('userData'), 'app_storage.json'); +} +/** + * Returns the path to the file used to communicate AGY Hub's remote debugging port. + * Used by recording encoder. + */ +function getActivePortFilePath() { + return path_1.default.join(electron_1.app.getPath('userData'), 'DevToolsActivePort'); +} +function getLsLogPath() { + return path_1.default.join(electron_1.app.getPath('logs'), constants_1.LS_LOG_FILE_NAME); +} +/** User data dir for the old IDE (source for copy). */ +exports.IDE_OLD_DATA_DIR = path_1.default.join(os_1.default.homedir(), '.ag-x', 'ag-x'); +/** User data dir for the separately installed IDE (destination for copy). */ +exports.IDE_NEW_DATA_DIR = path_1.default.join(os_1.default.homedir(), '.ag-x', 'ag-x-ide'); +/** User data dir for backup (destination for backup copy). */ +exports.IDE_BACKUP_DATA_DIR = path_1.default.join(os_1.default.homedir(), '.ag-x', 'ag-x-backup'); diff --git a/dist/preload.js b/dist/preload.js new file mode 100644 index 0000000..2480637 --- /dev/null +++ b/dist/preload.js @@ -0,0 +1,371 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * Preload script โ€” runs in every BrowserWindow before the page loads. + * Exposes a minimal, secure API via contextBridge so the renderer can + * communicate with the main-process auto-updater without nodeIntegration. + */ +const electron_1 = require("electron"); +const updaterAPI = { + onStateChanged: (callback) => { + const handler = (_event, state) => { + callback(state); + }; + electron_1.ipcRenderer.on('updater:state-changed', handler); + // Return unsubscribe function + return () => { + electron_1.ipcRenderer.removeListener('updater:state-changed', handler); + }; + }, + applyUpdate: () => electron_1.ipcRenderer.invoke('updater:apply'), + quitAndInstall: () => electron_1.ipcRenderer.invoke('updater:quit-and-install'), + checkForUpdates: () => electron_1.ipcRenderer.invoke('updater:check-for-updates'), +}; +const dialogAPI = { + showOpenDialog: () => electron_1.ipcRenderer.invoke('dialog:open-workspace'), +}; +const notificationAPI = { + send: (options) => electron_1.ipcRenderer.invoke('notification:send', options), + openSystemPreferences: () => electron_1.ipcRenderer.invoke('notification:open-system-preferences'), + onClicked: (callback) => { + const handler = (_event, payload) => { + callback(payload); + }; + electron_1.ipcRenderer.on('notification:clicked', handler); + return () => { + electron_1.ipcRenderer.removeListener('notification:clicked', handler); + }; + }, +}; +const storageAPI = { + getItems: () => electron_1.ipcRenderer.invoke('storage:get-items'), + updateItems: (changes) => electron_1.ipcRenderer.invoke('storage:update-items', changes), + onChanged: (callback) => { + const handler = (_event, changes) => { + callback(changes); + }; + electron_1.ipcRenderer.on('storage:changed', handler); + return () => { + electron_1.ipcRenderer.removeListener('storage:changed', handler); + }; + }, +}; +const logsAPI = { + getElectronLogs: () => electron_1.ipcRenderer.invoke('logs:electron'), +}; +const extensionsAPI = { + sendAuthorities: (authoritiesMap) => electron_1.ipcRenderer.invoke('extensions:send-authorities', authoritiesMap), +}; +const deepLinkAPI = { + onDeepLink: (callback) => { + const handler = (_event, url) => { + callback(url); + }; + electron_1.ipcRenderer.on('deep-link', handler); + return () => { + electron_1.ipcRenderer.removeListener('deep-link', handler); + }; + }, + getStoredDeepLink: () => electron_1.ipcRenderer.invoke('deep-link:get-stored'), + // Provider API + provider: { + getActive: () => electron_1.ipcRenderer.invoke('provider:get-active'), + getAll: () => electron_1.ipcRenderer.invoke('provider:get-all'), + openSettings: () => electron_1.ipcRenderer.invoke('provider:open-settings'), + onOpenSettings: (handler) => { + electron_1.ipcRenderer.on('provider:open-settings', handler); + return () => electron_1.ipcRenderer.removeListener('provider:open-settings', handler); + }, + }, +}; +const agentAPI = { + updateActiveAgentCount: (count) => electron_1.ipcRenderer.invoke('agent:update-active-count', count), +}; +const electronNativeAPI = { + getZoomLevel: () => electron_1.webFrame.getZoomFactor(), + setTitleBarOverlay: (options) => electron_1.ipcRenderer.invoke('window:set-title-bar-overlay', options), + minimize: () => electron_1.ipcRenderer.invoke('window:minimize'), + maximize: () => electron_1.ipcRenderer.invoke('window:maximize'), + unmaximize: () => electron_1.ipcRenderer.invoke('window:unmaximize'), + isMaximized: () => electron_1.ipcRenderer.invoke('window:is-maximized'), + close: () => electron_1.ipcRenderer.invoke('window:close'), + toggleDevTools: () => electron_1.ipcRenderer.invoke('window:toggle-devtools'), + zoomIn: () => { + const current = electron_1.webFrame.getZoomLevel(); + electron_1.webFrame.setZoomLevel(current + 0.5); + }, + zoomOut: () => { + const current = electron_1.webFrame.getZoomLevel(); + electron_1.webFrame.setZoomLevel(current - 0.5); + }, + resetZoom: () => { + electron_1.webFrame.setZoomLevel(0); + }, + openExternal: (url) => electron_1.ipcRenderer.invoke('shell:open-external', url), +}; +electron_1.contextBridge.exposeInMainWorld('electronUpdater', updaterAPI); +electron_1.contextBridge.exposeInMainWorld('dialog', dialogAPI); +electron_1.contextBridge.exposeInMainWorld('nativeNotifications', notificationAPI); +electron_1.contextBridge.exposeInMainWorld('nativeStorage', storageAPI); +electron_1.contextBridge.exposeInMainWorld('logs', logsAPI); +electron_1.contextBridge.exposeInMainWorld('extensions', extensionsAPI); +electron_1.contextBridge.exposeInMainWorld('deepLink', deepLinkAPI); +electron_1.contextBridge.exposeInMainWorld('agent', agentAPI); +electron_1.contextBridge.exposeInMainWorld('electronNative', electronNativeAPI); + +// --------------------------------------------------------------------------- +// Native In-UI AI Provider Switcher +// --------------------------------------------------------------------------- +async function fetchEndpoints() { + try { + const res = await fetch('http://127.0.0.1:48080/codex/list-endpoints'); + const data = await res.json(); + return data; + } catch (e) { + console.error('Failed to fetch endpoints:', e); + return null; + } +} + +async function switchEndpoint(name) { + try { + const res = await fetch('http://127.0.0.1:48080/codex/switch-endpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + const data = await res.json(); + return data.success; + } catch (e) { + console.error('Failed to switch endpoint:', e); + return false; + } +} + +function setupAiProviderSwitcher() { + if (window.self !== window.top) return; + if (document.getElementById('codex-ai-switcher-container')) return; + + setTimeout(async () => { + const data = await fetchEndpoints(); + if (!data || !data.endpoints || data.endpoints.length === 0) { + console.log('[Codex] No endpoints found or proxy not running.'); + return; + } + + const container = document.createElement('div'); + container.id = 'codex-ai-switcher-container'; + container.style.cssText = ` + position: fixed; + top: 8px; + right: 80px; + z-index: 999999; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + user-select: none; + `; + + const style = document.createElement('style'); + style.textContent = ` + #codex-ai-btn { + background: rgba(30, 30, 35, 0.65); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 18px; + padding: 6px 12px; + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + #codex-ai-btn:hover { + background: rgba(40, 40, 45, 0.8); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25); + } + #codex-ai-btn:active { + transform: translateY(0); + } + #codex-ai-indicator { + width: 7px; + height: 7px; + border-radius: 50%; + background: #10b981; + box-shadow: 0 0 8px #10b981; + display: inline-block; + animation: codex-pulse 2s infinite; + } + @keyframes codex-pulse { + 0% { opacity: 0.6; box-shadow: 0 0 4px #10b981; } + 50% { opacity: 1; box-shadow: 0 0 10px #10b981; } + 100% { opacity: 0.6; box-shadow: 0 0 4px #10b981; } + } + #codex-ai-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: rgba(24, 24, 28, 0.95); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + min-width: 200px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + padding: 6px; + opacity: 0; + transform: translateY(-10px) scale(0.95); + pointer-events: none; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + #codex-ai-dropdown.open { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + } + .codex-ai-item { + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + color: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .codex-ai-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + } + .codex-ai-item.active { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(79, 70, 229, 0.25)); + border: 1px solid rgba(99, 102, 241, 0.3); + color: #818cf8; + } + .codex-ai-item.active:hover { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(79, 70, 229, 0.35)); + } + #codex-ai-toast { + position: fixed; + bottom: 24px; + right: 24px; + background: rgba(20, 20, 25, 0.85); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 10px 18px; + color: #fff; + font-size: 13px; + font-weight: 500; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: 8px; + transform: translateY(100px); + opacity: 0; + transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275); + z-index: 1000000; + } + #codex-ai-toast.show { + transform: translateY(0); + opacity: 1; + } + #codex-ai-arrow { + border: solid rgba(255, 255, 255, 0.6); + border-width: 0 1.5px 1.5px 0; + display: inline-block; + padding: 2.5px; + transform: rotate(45deg); + margin-left: 2px; + transition: transform 0.2s; + } + #codex-ai-btn.open-arrow #codex-ai-arrow { + transform: rotate(-135deg); + } + `; + document.head.appendChild(style); + + const btn = document.createElement('div'); + btn.id = 'codex-ai-btn'; + btn.innerHTML = `${data.active}`; + + const dropdown = document.createElement('div'); + dropdown.id = 'codex-ai-dropdown'; + + const updateDropdownItems = (activeName) => { + dropdown.innerHTML = ''; + data.endpoints.forEach(ep => { + const item = document.createElement('div'); + item.className = 'codex-ai-item' + (ep.name === activeName ? ' active' : ''); + item.innerHTML = ` + ${ep.name} + ${ep.backend_type} + `; + item.addEventListener('click', async (e) => { + e.stopPropagation(); + dropdown.classList.remove('open'); + btn.classList.remove('open-arrow'); + + if (ep.name === activeName) return; + + const success = await switchEndpoint(ep.name); + if (success) { + activeName = ep.name; + document.getElementById('codex-ai-btn-text').textContent = ep.name; + updateDropdownItems(ep.name); + showToast(`Switched provider to ${ep.name}`); + } else { + showToast('Failed to switch AI provider'); + } + }); + dropdown.appendChild(item); + }); + }; + + updateDropdownItems(data.active); + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = dropdown.classList.toggle('open'); + btn.classList.toggle('open-arrow', isOpen); + }); + + document.addEventListener('click', () => { + dropdown.classList.remove('open'); + btn.classList.remove('open-arrow'); + }); + + container.appendChild(btn); + container.appendChild(dropdown); + document.body.appendChild(container); + + const toast = document.createElement('div'); + toast.id = 'codex-ai-toast'; + document.body.appendChild(toast); + + let toastTimeout = null; + function showToast(message) { + toast.textContent = message; + toast.classList.add('show'); + if (toastTimeout) clearTimeout(toastTimeout); + toastTimeout = setTimeout(() => { + toast.classList.remove('show'); + }, 3000); + } + + console.log('[Codex] Seamless AI provider switcher injected successfully.'); + }, 1500); +} + +window.addEventListener('DOMContentLoaded', () => { + setupAiProviderSwitcher(); +}); diff --git a/dist/provider/settings.html b/dist/provider/settings.html new file mode 100644 index 0000000..6bf7d9e --- /dev/null +++ b/dist/provider/settings.html @@ -0,0 +1,696 @@ + + + + + +AI Provider Settings + + + +
+
+

AI Provider Settings

+
Choose and configure your AI provider โ€” AG X supports 20+ providers
+
+ v3.7 +
+ +
+
+ + + +
+ + +
+
+
โšก Select AI Provider
+
+
+ +
+
๐Ÿ”ง Provider Configuration
+
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ +
+ + +
+
+ + +
+
+
๐Ÿ› ๏ธ Advanced Settings
+
+ + +
Port for the local API proxy (default: 9876). Restart required.
+
+
+ +
โœ… Built-in Node.js translation proxy โ€” no external tools needed
+
Translation proxy is built into AG X for all provider types.
+
+
+ + +
x-command-code-version header value (for Command Code provider only).
+
+
+
+ +
+
+ + +
+
+
โ„น๏ธ About Provider System
+

+ AG X supports multiple AI providers through a modular provider system.

+ Provider Types:
+ โ€ข Native โ€” Direct connection to the provider API (Google Gemini, OpenAI)
+ โ€ข OpenAI-Compatible โ€” Any provider with a Chat Completions endpoint
+ โ€ข Anthropic โ€” Claude models via the Messages API
+ โ€ข Command Code โ€” 20+ models via Command Code's /alpha/generate API

+ Translation:
+ Simple providers use a built-in lightweight proxy. Complex providers (Anthropic, Command Code) + uses the built-in Node.js proxy + for full Responses API โ†” provider API translation.

+ Supported Providers:
+ Google Gemini, OpenAI, Anthropic, Z.AI, OpenCode (Zen/Go), Crof.ai, NVIDIA NIM, + Kilo.ai, Command Code, OpenRouter, OpenAdapter, DeepSeek, Ollama, Together AI, Groq, and Custom. +

+
+
+
+ +
+
+ + Loading provider configurationโ€ฆ +
+
โ€”
+
+ +
+ + + + diff --git a/dist/provider/welcome.html b/dist/provider/welcome.html new file mode 100644 index 0000000..74dd9b2 --- /dev/null +++ b/dist/provider/welcome.html @@ -0,0 +1,176 @@ + + + + + +Welcome to AG X + + + + +
+ +
+ +
AG X
+
AI-Powered Code Intelligence
+
+ Choose how you'd like to connect to AI.
+ You can always change this later from the menu. +
+ +
+
+
๐Ÿ”ฎ
+

Google Gemini

+

Sign in with your Google account for instant access to Gemini models. No API key needed.

+ Easiest โ€” zero config +
+
+
๐Ÿ”Œ
+

Another AI Provider

+

Use OpenAI, Anthropic, DeepSeek, Ollama, or 15+ other providers with your own API key.

+ Power users +
+
+ + +
+ + + + diff --git a/dist/providerSettings.js b/dist/providerSettings.js new file mode 100644 index 0000000..fc00aab --- /dev/null +++ b/dist/providerSettings.js @@ -0,0 +1,169 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.openProviderSettings = openProviderSettings; +exports.closeProviderSettings = closeProviderSettings; +const electron_1 = require("electron"); +const path_1 = require("path"); +let settingsWindow = null; +function openProviderSettings(providerService) { + if (settingsWindow) { + settingsWindow.show(); + settingsWindow.focus(); + return; + } + settingsWindow = new electron_1.BrowserWindow({ + width: 820, + height: 720, + title: 'AI Provider Settings', + resizable: true, + minimizable: true, + maximizable: true, + backgroundColor: '#1a1a2e', + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#16213e', + symbolColor: '#e0e0e0', + height: 36, + }, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + enableRemoteModule: false, + }, + }); + settingsWindow.loadFile(path_1.join(__dirname, 'provider', 'settings.html')); + settingsWindow.on('closed', () => { + settingsWindow = null; + }); + // Register IPC handlers for provider settings + electron_1.ipcMain.handle('provider:get-config', async () => { + return { + activeProvider: providerService.getActiveProvider(), + providers: providerService.getAllProviders(), + }; + }); + electron_1.ipcMain.handle('provider:save', async (_event, settings) => { + providerService.setActiveProvider(settings.activeProvider); + providerService.updateProviderConfig(settings.activeProvider, settings.providerConfig); + // Sync to endpoints.json so the LS proxy uses this provider on next restart + try { + const os = require('os'); + const fs = require('fs'); + const pathMod = require('path'); + const home = os.homedir(); + const endpointsPath = pathMod.join(home, '.codex', 'endpoints.json'); + const activePath = pathMod.join(home, '.codex', '.active-endpoint.json'); + const activeProvider = providerService.getActiveProvider(); + const providerConfig = providerService.getProviderConfig(activeProvider); + if (providerConfig) { + let endpointsData = { default: '', endpoints: [] }; + if (fs.existsSync(endpointsPath)) { + try { endpointsData = JSON.parse(fs.readFileSync(endpointsPath, 'utf8')); } catch(e) {} + } + const endpointName = 'AG X: ' + (providerConfig.name || activeProvider); + const existingIdx = endpointsData.endpoints.findIndex(e => e.name === endpointName); + const endpoint = { + name: endpointName, + backend_type: providerConfig.backendType || 'openai-compat', + base_url: providerConfig.apiUrl || '', + api_key: providerConfig.apiKey || '', + default_model: providerConfig.model || providerConfig.defaultModel || '', + models: providerConfig.models || [], + provider_preset: 'AG X', + }; + if (existingIdx >= 0) { + endpointsData.endpoints[existingIdx] = endpoint; + } else { + endpointsData.endpoints.push(endpoint); + } + endpointsData.default = endpointName; + fs.writeFileSync(endpointsPath, JSON.stringify(endpointsData, null, 2), 'utf-8'); + fs.writeFileSync(activePath, JSON.stringify({ active: endpointName }, null, 2), 'utf-8'); + console.log('[Settings] Provider synced to endpoints.json:', endpointName); + } + } catch(e) { console.error('[Settings] Failed to sync:', e); } + // Notify welcome screen (if waiting) that settings are saved + electron_1.ipcMain.emit('provider:settings-saved'); + return { success: true, needsRestart: true }; + }); + electron_1.ipcMain.handle('provider:reset', async () => { + const fs = require("fs"); + const configPath = providerService.configPath; + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + // Reload defaults + providerService.config = providerService.loadConfig(); + return { success: true }; + }); + electron_1.ipcMain.handle('provider:save-advanced', async (_event, settings) => { + // Store advanced settings + const fs = require("fs"); + const advancedPath = providerService.configPath.replace('.json', '_advanced.json'); + fs.writeFileSync(advancedPath, JSON.stringify(settings, null, 2), 'utf-8'); + return { success: true }; + }); + electron_1.ipcMain.on('provider:close-settings', () => { + closeProviderSettings(); + }); + electron_1.ipcMain.handle('provider:test-connection', async (_event, testConfig) => { + return testProviderConnection(testConfig); + }); +} +function closeProviderSettings() { + if (settingsWindow) { + settingsWindow.close(); + settingsWindow = null; + } +} +async function testProviderConnection(testConfig) { + const http = require("http"); + const https = require("https"); + const url = new URL(testConfig.apiUrl || 'https://api.openai.com'); + const isHttps = url.protocol === 'https:'; + const httpModule = isHttps ? https : http; + return new Promise((resolve) => { + const options = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: testConfig.provider === 'ollama' ? '/api/tags' : '/', + method: 'GET', + timeout: 10000, + headers: {}, + }; + if (testConfig.apiKey) { + if (testConfig.provider === 'anthropic') { + options.headers['x-api-key'] = testConfig.apiKey; + options.headers['anthropic-version'] = '2023-06-01'; + } else { + options.headers['Authorization'] = `Bearer ${testConfig.apiKey}`; + } + } + const req = httpModule.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode < 500) { + resolve({ + success: true, + message: `HTTP ${res.statusCode} โ€” Server reachable. Model: ${testConfig.model || 'default'}`, + }); + } + else { + resolve({ + success: false, + error: `HTTP ${res.statusCode}: ${body.substring(0, 200)}`, + }); + } + }); + }); + req.on('error', (err) => { + resolve({ success: false, error: err.message }); + }); + req.on('timeout', () => { + req.destroy(); + resolve({ success: false, error: 'Connection timed out after 10s' }); + }); + req.end(); + }); +} diff --git a/dist/services/apiProxy.js b/dist/services/apiProxy.js new file mode 100644 index 0000000..8805627 --- /dev/null +++ b/dist/services/apiProxy.js @@ -0,0 +1,116 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ApiProxy = void 0; +const http = require("http"); +const https = require("https"); +const child_process = require("child_process"); +const path = require("path"); +const os = require("os"); +const fs = require("fs"); +const translationProxy_1 = require("./translationProxy"); + +/** + * API Proxy for AG X. + * + * All backends now use the built-in Node.js translation proxy. + * No external Python or tools required. + */ +class ApiProxy { + constructor(providerService) { + this.providerService = providerService; + this.server = null; + this.proxyProcess = null; + this.port = providerService.getProxyPort(); + } + start() { + if (this.server || this.proxyProcess) { + return; + } + const backendType = this.providerService.getActiveBackendType(); + if (backendType === 'gemini-native' || backendType === 'native') { + console.log('[ApiProxy] Native backend, no proxy needed'); + return; + } + this.startInternalProxy(); + } + /** + * Start the built-in Node.js translation proxy as a child process + */ + startInternalProxy() { + const activeProvider = this.providerService.getActiveProvider(); + const config = this.providerService.getProviderConfig(activeProvider); + if (!config) { + console.error('[ApiProxy] No provider config found'); + return; + } + // Prepare the proxy config + const configDir = path.join(os.homedir(), '.cache', 'ag-x-proxy'); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + const models = (config.models || []).map((m) => { + if (typeof m === 'string') { + return { id: m, object: "model", created: 1700000000, owned_by: activeProvider }; + } + return m; + }); + const proxyConfig = { + port: this.port, + backend_type: config.backendType || 'openai-compat', + target_url: config.apiUrl || '', + api_key: config.apiKey || '', + cc_version: config.ccVersion || '0.26.8', + oauth_provider: config.oauthProvider || '', + reasoning_enabled: config.reasoningEnabled !== undefined ? config.reasoningEnabled : true, + reasoning_effort: config.reasoningEffort || 'medium', + models: models, + }; + const configPath = path.join(configDir, `ag-x-proxy-${this.port}.json`); + fs.writeFileSync(configPath, JSON.stringify(proxyConfig, null, 2), 'utf-8'); + console.log(`[ApiProxy] Starting built-in Node.js translation proxy on port ${this.port}`); + // Use Electron's Node.js binary to run our translation proxy + const proxyScript = path.join(__dirname, 'translationProxy.js'); + const nodeBin = process.execPath; + this.proxyProcess = child_process.spawn(nodeBin, [proxyScript], { + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + env: { ...process.env }, + }); + this.proxyProcess.stdout?.on('data', (data) => { + const lines = data.toString().trim().split('\n'); + for (const line of lines) { + console.log(`[Proxy] ${line}`); + } + }); + this.proxyProcess.stderr?.on('data', (data) => { + const lines = data.toString().trim().split('\n'); + for (const line of lines) { + console.log(`[Proxy] ${line}`); + } + }); + this.proxyProcess.on('error', (err) => { + console.error('[ApiProxy] Proxy error:', err); + }); + this.proxyProcess.on('exit', (code, signal) => { + console.log(`[ApiProxy] Proxy exited with code ${code}, signal ${signal}`); + this.proxyProcess = null; + }); + this.proxyProcess.unref(); + console.log(`[ApiProxy] Built-in Node.js proxy spawned for ${config.backendType} on port ${this.port}`); + } + stop() { + if (this.proxyProcess) { + try { + this.proxyProcess.kill(); + } + catch (e) { /* ignore */ } + this.proxyProcess = null; + } + if (this.server) { + this.server.close(); + this.server = null; + console.log('[ApiProxy] Stopped'); + } + } +} +exports.ApiProxy = ApiProxy; diff --git a/dist/services/providerService.js b/dist/services/providerService.js new file mode 100644 index 0000000..66d5716 --- /dev/null +++ b/dist/services/providerService.js @@ -0,0 +1,358 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ProviderService = exports.ProviderType = void 0; +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +var ProviderType; +(function (ProviderType) { + ProviderType["GOOGLE_GEMINI"] = "google_gemini"; + ProviderType["OPENAI"] = "openai"; + ProviderType["ANTHROPIC"] = "anthropic"; + ProviderType["Z_AI"] = "z_ai"; + ProviderType["OPENCODE_ZEN"] = "opencode_zen"; + ProviderType["OPENCODE_GO"] = "opencode_go"; + ProviderType["OPENCODE_ZEN_ANTHROPIC"] = "opencode_zen_anthropic"; + ProviderType["OPENCODE_GO_ANTHROPIC"] = "opencode_go_anthropic"; + ProviderType["CROF_AI"] = "crof_ai"; + ProviderType["NVIDIA_NIM"] = "nvidia_nim"; + ProviderType["KILO_AI"] = "kilo_ai"; + ProviderType["COMMAND_CODE"] = "command_code"; + ProviderType["OPENROUTER"] = "openrouter"; + ProviderType["OPENADAPTER"] = "openadapter"; + ProviderType["DEEPSEEK"] = "deepseek"; + ProviderType["OLLAMA"] = "ollama"; + ProviderType["TOGETHER"] = "together"; + ProviderType["GROQ"] = "groq"; + ProviderType["CUSTOM"] = "custom"; +})(ProviderType = exports.ProviderType || (exports.ProviderType = {})); + +const PREDEFINED_PROVIDERS = { + [ProviderType.GOOGLE_GEMINI]: { + name: 'Google Gemini (OAuth)', + icon: '๐Ÿ”ฎ', + description: 'Built-in Gemini via Google OAuth โ€” zero config', + apiUrl: 'https://daily-cloudcode-pa.sandbox.googleapis.com', + backendType: 'gemini-native', + requiresApiKey: false, + apiKeyHint: '', + models: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-3-flash-preview', 'gemini-3-pro-preview'], + defaultModel: 'gemini-2.5-pro', + }, + [ProviderType.OPENAI]: { + name: 'OpenAI', + icon: '๐ŸŸข', + description: 'GPT models via Responses API', + apiUrl: 'https://api.openai.com/v1', + backendType: 'native', + requiresApiKey: true, + apiKeyHint: 'OpenAI API Key (sk-...)', + models: ['gpt-4o', 'gpt-4o-mini', 'o1', 'o1-mini', 'o3', 'o3-mini'], + defaultModel: 'gpt-4o', + }, + [ProviderType.ANTHROPIC]: { + name: 'Anthropic', + icon: '๐ŸŸฃ', + description: 'Claude models via Messages API', + apiUrl: 'https://api.anthropic.com', + backendType: 'anthropic', + requiresApiKey: true, + apiKeyHint: 'Anthropic API Key (sk-ant-...)', + models: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'], + defaultModel: 'claude-sonnet-4-20250514', + }, + [ProviderType.Z_AI]: { + name: 'Z.AI Coding', + icon: '๐Ÿ…ฉ', + description: 'GLM & Z models via Z.AI', + apiUrl: 'https://api.z.ai/api/coding/paas/v4', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'Z.AI API Key', + models: ['glm-5.1', 'glm-4.7', 'GLM-4-Plus', 'GLM-4-Long', 'GLM-4-Flash', 'GLM-4-FlashX', 'GLM-Z1-Flash'], + defaultModel: 'glm-5.1', + }, + [ProviderType.OPENCODE_ZEN]: { + name: 'OpenCode Zen', + icon: '๐Ÿง˜', + description: 'Multi-model via OpenCode Zen (OpenAI-compat)', + apiUrl: 'https://opencode.ai/zen/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'OpenCode API Key', + models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'kimi-k2.6', 'minimax-m2.7', 'minimax-m2.5', 'minimax-m2.5-free', 'deepseek-v4-flash-free', 'nemotron-3-super-free', 'qwen3.6-plus', 'qwen3.5-plus', 'big-pickle'], + defaultModel: 'glm-5.1', + }, + [ProviderType.OPENCODE_GO]: { + name: 'OpenCode Go', + icon: '๐Ÿš€', + description: 'Multi-model via OpenCode Go (OpenAI-compat)', + apiUrl: 'https://opencode.ai/zen/go/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'OpenCode API Key', + models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'kimi-k2.6', 'mimo-v2.5', 'mimo-v2.5-pro', 'minimax-m2.7', 'minimax-m2.5', 'qwen3.6-plus', 'qwen3.5-plus', 'deepseek-v4-pro', 'deepseek-v4-flash'], + defaultModel: 'glm-5.1', + }, + [ProviderType.OPENCODE_ZEN_ANTHROPIC]: { + name: 'OpenCode Zen (Anthropic)', + icon: '๐Ÿง˜', + description: 'Claude models via OpenCode Zen', + apiUrl: 'https://opencode.ai/zen/v1', + backendType: 'anthropic', + requiresApiKey: true, + apiKeyHint: 'OpenCode API Key', + models: ['claude-opus-4-7', 'claude-opus-4-6', 'claude-opus-4-5', 'claude-opus-4-1', 'claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-sonnet-4', 'claude-haiku-4-5', 'claude-3-5-haiku'], + defaultModel: 'claude-sonnet-4-6', + }, + [ProviderType.OPENCODE_GO_ANTHROPIC]: { + name: 'OpenCode Go (Anthropic)', + icon: '๐Ÿš€', + description: 'Claude models via OpenCode Go', + apiUrl: 'https://opencode.ai/zen/go/v1', + backendType: 'anthropic', + requiresApiKey: true, + apiKeyHint: 'OpenCode API Key', + models: ['minimax-m2.7', 'minimax-m2.5'], + defaultModel: 'minimax-m2.7', + }, + [ProviderType.CROF_AI]: { + name: 'Crof.ai', + icon: '๐ŸŒ', + description: 'OpenAI-compatible models via Crof.ai', + apiUrl: 'https://crof.ai/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'Crof.ai API Key', + models: [], + defaultModel: '', + }, + [ProviderType.NVIDIA_NIM]: { + name: 'NVIDIA NIM', + icon: '๐Ÿ’š', + description: 'NVIDIA accelerated inference models', + apiUrl: 'https://integrate.api.nvidia.com/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'NVIDIA API Key (nvapi-...)', + models: [], + defaultModel: '', + }, + [ProviderType.KILO_AI]: { + name: 'Kilo.ai Gateway', + icon: 'โš–๏ธ', + description: 'Multi-provider via Kilo.ai', + apiUrl: 'https://api.kilo.ai/api/gateway', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'Kilo.ai API Key', + models: [], + defaultModel: '', + }, + [ProviderType.COMMAND_CODE]: { + name: 'Command Code', + icon: 'โŒ˜', + description: '20+ models via Command Code API', + apiUrl: 'https://api.commandcode.ai', + backendType: 'command-code', + requiresApiKey: true, + apiKeyHint: 'Command Code API Key', + ccVersion: '0.26.8', + models: [ + 'deepseek/deepseek-v4-flash', 'deepseek/deepseek-v4-pro', + 'anthropic:claude-sonnet-4-6', 'anthropic:claude-haiku-4-5-20251001', + 'anthropic:claude-opus-4-7', 'anthropic:claude-opus-4-6', + 'openai:gpt-5.5', 'openai:gpt-5.4', 'openai:gpt-5.4-mini', 'openai:gpt-5.3-codex', + 'moonshotai/Kimi-K2.6', 'moonshotai/Kimi-K2.5', + 'zai-org/GLM-5.1', 'zai-org/GLM-5', + 'MiniMaxAI/MiniMax-M2.7', 'MiniMaxAI/MiniMax-M2.5', + 'Qwen/Qwen3.6-Max-Preview', 'Qwen/Qwen3.6-Plus', + 'stepfun/Step-3.5-Flash', 'google/gemini-3.1-flash-lite', + ], + defaultModel: 'deepseek/deepseek-v4-flash', + }, + [ProviderType.OPENROUTER]: { + name: 'OpenRouter', + icon: '๐Ÿ”€', + description: 'Route to hundreds of models via OpenRouter', + apiUrl: 'https://openrouter.ai/api/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'OpenRouter API Key (sk-or-...)', + models: [], + defaultModel: '', + }, + [ProviderType.OPENADAPTER]: { + name: 'OpenAdapter', + icon: '๐Ÿ”Œ', + description: 'Free/proxy models via OpenAdapter', + apiUrl: 'https://api.openadapter.in/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'OpenAdapter API Key', + models: ['0G-DeepSeek-V3', '0G-DeepSeek-v4-Pro', '0G-GLM-5', '0G-GLM-5.1', '0G-Qwen3.6', '0G-Qwen-VL'], + defaultModel: '0G-DeepSeek-v4-Pro', + }, + [ProviderType.DEEPSEEK]: { + name: 'DeepSeek', + icon: '๐Ÿ”', + description: 'DeepSeek models directly', + apiUrl: 'https://api.deepseek.com/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'DeepSeek API Key', + models: ['deepseek-chat', 'deepseek-reasoner'], + defaultModel: 'deepseek-chat', + }, + [ProviderType.OLLAMA]: { + name: 'Ollama (Local)', + icon: '๐Ÿฆ™', + description: 'Run models locally with Ollama', + apiUrl: 'http://127.0.0.1:11434', + backendType: 'openai-compat', + requiresApiKey: false, + apiKeyHint: '', + models: ['llama3.1', 'llama3', 'codellama', 'mistral', 'mixtral', 'deepseek-coder', 'qwen2.5-coder'], + defaultModel: 'llama3.1', + }, + [ProviderType.TOGETHER]: { + name: 'Together AI', + icon: '๐Ÿค', + description: 'Open-source models via Together', + apiUrl: 'https://api.together.xyz/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'Together API Key', + models: [], + defaultModel: '', + }, + [ProviderType.GROQ]: { + name: 'Groq', + icon: 'โšก', + description: 'Ultra-fast inference via Groq', + apiUrl: 'https://api.groq.com/openai/v1', + backendType: 'openai-compat', + requiresApiKey: true, + apiKeyHint: 'Groq API Key (gsk_...)', + models: [], + defaultModel: '', + }, + [ProviderType.CUSTOM]: { + name: 'Custom Provider', + icon: 'โš™๏ธ', + description: 'Any OpenAI-compatible endpoint', + apiUrl: '', + backendType: 'openai-compat', + requiresApiKey: false, + apiKeyHint: 'API Key (if required)', + models: [], + defaultModel: '', + }, +}; + +class ProviderService { + constructor(storageManager) { + this.storageManager = storageManager; + this.configPath = path.join(os.homedir(), '.ag-x', 'ag-x', 'provider_config.json'); + this.config = this.loadConfig(); + } + loadConfig() { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf-8'); + return JSON.parse(content); + } + } + catch (e) { + console.error('Error loading provider config:', e); + } + // Build defaults from all predefined providers + const providers = {}; + for (const [key, preset] of Object.entries(PREDEFINED_PROVIDERS)) { + providers[key] = { + ...preset, + apiKey: '', + model: preset.defaultModel, + enabled: key === ProviderType.GOOGLE_GEMINI, + }; + } + return { + activeProvider: ProviderType.GOOGLE_GEMINI, + providers, + }; + } + saveConfig() { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8'); + } + catch (e) { + console.error('Error saving provider config:', e); + } + } + getActiveProvider() { + return this.config.activeProvider; + } + setActiveProvider(providerType) { + this.config.activeProvider = providerType; + this.saveConfig(); + } + getProviderConfig(providerType) { + return this.config.providers[providerType] || null; + } + updateProviderConfig(providerType, updates) { + if (this.config.providers[providerType]) { + Object.assign(this.config.providers[providerType], updates); + this.saveConfig(); + } + } + getAllProviders() { + return this.config.providers; + } + getPredefinedProviders() { + return PREDEFINED_PROVIDERS; + } + getActiveProviderApiUrl() { + const provider = this.config.providers[this.config.activeProvider]; + return provider?.apiUrl || 'https://daily-cloudcode-pa.sandbox.googleapis.com'; + } + getActiveProviderApiKey() { + const provider = this.config.providers[this.config.activeProvider]; + return provider?.apiKey || ''; + } + getActiveProviderModel() { + const provider = this.config.providers[this.config.activeProvider]; + return provider?.model || ''; + } + getActiveBackendType() { + const provider = this.config.providers[this.config.activeProvider]; + return provider?.backendType || 'gemini-native'; + } + getActiveCcVersion() { + const provider = this.config.providers[this.config.activeProvider]; + return provider?.ccVersion || '0.26.8'; + } + /** + * Does this provider need the built-in translation proxy? + * gemini-native and native (OpenAI) go direct; everything else needs proxy. + */ + needsProxy() { + const bt = this.getActiveBackendType(); + return bt !== 'gemini-native' && bt !== 'native'; + } + /** + * Does this provider need the full translation proxy (anthropic, command-code)? + * command-code and anthropic need the full Python proxy for full compatibility. + */ + needsTranslateProxy() { + const bt = this.getActiveBackendType(); + return bt === 'command-code' || bt === 'anthropic'; + } + getProxyPort() { + return 9876; + } +} +exports.ProviderService = ProviderService; diff --git a/dist/services/settingsService.js b/dist/services/settingsService.js new file mode 100644 index 0000000..c77e21d --- /dev/null +++ b/dist/services/settingsService.js @@ -0,0 +1,49 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SettingsService = exports.DEFAULTS = exports.SettingKey = void 0; +const utils_1 = require("../utils"); +// Setting keys +var SettingKey; +(function (SettingKey) { + SettingKey["RUN_IN_BACKGROUND"] = "runInBackground"; + SettingKey["KEEP_COMPUTER_AWAKE"] = "keepComputerAwake"; +})(SettingKey || (exports.SettingKey = SettingKey = {})); +// Default values +exports.DEFAULTS = new Map([ + // The following setting is off by default for windows because the app + // icon is not as discoverable in the bottom right corner menu bar as + // it is on macOS and linux. + [SettingKey.RUN_IN_BACKGROUND, process.platform !== 'win32'], + [SettingKey.KEEP_COMPUTER_AWAKE, false], +]); +/** + * A thin wrapper around StorageManager to listen for changes + * in settings and apply their side effects. + */ +class SettingsService { + constructor(storageManager) { + this.storageManager = storageManager; + this.storageManager.onDidChange((changes) => { + this.applySideEffects(changes); + }); + void this.initialize(); + } + async initialize() { + const items = await this.storageManager.getItems(); + this.applySideEffects(items); + } + applySideEffects(settings) { + const val = settings[SettingKey.KEEP_COMPUTER_AWAKE]; + if (val !== undefined) { + const preventSleep = val === null + ? exports.DEFAULTS.get(SettingKey.KEEP_COMPUTER_AWAKE) + : val === 'true'; + utils_1.SleepBlocker.getInstance().shouldKeepComputerAwake(preventSleep); + } + } + async getSetting(key) { + const items = await this.storageManager.getItems(); + return items[key] === 'true'; + } +} +exports.SettingsService = SettingsService; diff --git a/dist/services/settingsService.test.js b/dist/services/settingsService.test.js new file mode 100644 index 0000000..79939f2 --- /dev/null +++ b/dist/services/settingsService.test.js @@ -0,0 +1,65 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const settingsService_1 = require("./settingsService"); +const utils_1 = require("../utils"); +vitest_1.vi.mock('../storage'); +vitest_1.vi.mock('../utils'); +(0, vitest_1.describe)('SettingsService', () => { + let settingsService; + let mockStorageManager; + let mockSleepBlocker; + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); + mockStorageManager = { + getItems: vitest_1.vi.fn().mockResolvedValue({ + runInBackground: String(process.platform !== 'win32'), + keepComputerAwake: 'false', + }), + onDidChange: vitest_1.vi.fn().mockReturnValue({ dispose: vitest_1.vi.fn() }), + }; + mockSleepBlocker = { + shouldKeepComputerAwake: vitest_1.vi.fn(), + }; + vitest_1.vi.mocked(utils_1.SleepBlocker.getInstance).mockReturnValue(mockSleepBlocker); + settingsService = new settingsService_1.SettingsService(mockStorageManager); + }); + (0, vitest_1.it)('should return defaults when storage is empty', async () => { + (0, vitest_1.expect)(await settingsService.getSetting(settingsService_1.SettingKey.RUN_IN_BACKGROUND)).toBe(true); + (0, vitest_1.expect)(await settingsService.getSetting(settingsService_1.SettingKey.KEEP_COMPUTER_AWAKE)).toBe(false); + }); + (0, vitest_1.it)('should return values from storage', async () => { + mockStorageManager.getItems.mockResolvedValue({ + runInBackground: 'false', + keepComputerAwake: 'true', + }); + (0, vitest_1.expect)(await settingsService.getSetting(settingsService_1.SettingKey.RUN_IN_BACKGROUND)).toBe(false); + (0, vitest_1.expect)(await settingsService.getSetting(settingsService_1.SettingKey.KEEP_COMPUTER_AWAKE)).toBe(true); + }); + (0, vitest_1.it)('should return updated value after storage change', async () => { + mockStorageManager.getItems.mockResolvedValue({ + [settingsService_1.SettingKey.RUN_IN_BACKGROUND]: 'false', + }); + (0, vitest_1.expect)(await settingsService.getSetting(settingsService_1.SettingKey.RUN_IN_BACKGROUND)).toBe(false); + }); + (0, vitest_1.it)('should trigger SleepBlocker on keepComputerAwake change', async () => { + let changeListener; + mockStorageManager.onDidChange.mockImplementation((listener) => { + changeListener = listener; + return { dispose: vitest_1.vi.fn() }; + }); + // Instantiate again to trigger constructor with the new mock + settingsService = new settingsService_1.SettingsService(mockStorageManager); + // Simulate change + changeListener({ keepComputerAwake: 'true' }); + (0, vitest_1.expect)(mockSleepBlocker.shouldKeepComputerAwake).toHaveBeenCalledWith(true); + }); + (0, vitest_1.it)('should trigger initial SleepBlocker state', async () => { + mockStorageManager.getItems.mockResolvedValue({ + keepComputerAwake: 'true', + }); + settingsService = new settingsService_1.SettingsService(mockStorageManager); + await new Promise(process.nextTick); + (0, vitest_1.expect)(mockSleepBlocker.shouldKeepComputerAwake).toHaveBeenCalledWith(true); + }); +}); diff --git a/dist/services/translationProxy.js b/dist/services/translationProxy.js new file mode 100644 index 0000000..395400c --- /dev/null +++ b/dist/services/translationProxy.js @@ -0,0 +1,1191 @@ +"use strict"; +/** + * translationProxy.js โ€” Self-contained Node.js translation proxy for AG X. + * + * Replaces the Python translate-proxy.py entirely. + * Supports: openai-compat, anthropic, command-code backends. + * Handles the AG X language server's Gemini-format requests and + * translates them to the appropriate backend API format. + * + * Routes: + * GET /codex/list-endpoints โ€” list configured providers + * POST /codex/switch-endpoint โ€” switch active provider at runtime + * GET /v1/models โ€” list models + * GET /health โ€” health check + * POST /v1/responses โ€” Responses API (translated to backend) + * POST /v1internal:* โ€” Gemini internal format (translated) + * GET /v1internal:fetchAvailableModels โ€” model list in Gemini format + */ + +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const url = require("url"); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let BACKEND = "openai-compat"; +let TARGET_URL = "http://localhost:11434/v1"; +let API_KEY = ""; +let OAUTH_PROVIDER = ""; +let MODELS = []; +let CC_VERSION = ""; +let REASONING_ENABLED = true; +let REASONING_EFFORT = "medium"; +let PORT = 48080; + +const CONFIG_DIR = path.join(os.homedir(), ".cache", "codex-proxy"); +const ENDPOINTS_PATH = path.join(os.homedir(), ".codex", "endpoints.json"); +const ACTIVE_PATH = path.join(os.homedir(), ".codex", ".active-endpoint.json"); + +// --------------------------------------------------------------------------- +// Init from config +// --------------------------------------------------------------------------- +function initFromConfig() { + // Try loading from active config file first + const activeConfigPath = path.join(CONFIG_DIR, "proxy-active.json"); + if (fs.existsSync(activeConfigPath)) { + try { + const cfg = JSON.parse(fs.readFileSync(activeConfigPath, "utf8")); + applyConfig(cfg); + console.log("[Proxy] Loaded active config:", cfg.backend_type, cfg.target_url); + return; + } catch (e) { /* fallthrough */ } + } + + // Try loading from endpoints.json + if (fs.existsSync(ENDPOINTS_PATH)) { + try { + const ep = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8")); + let activeName = ""; + if (fs.existsSync(ACTIVE_PATH)) { + try { activeName = JSON.parse(fs.readFileSync(ACTIVE_PATH, "utf8")).active; } catch (e) {} + } + if (!activeName) activeName = ep.default || ""; + const endpoint = ep.endpoints?.find(e => e.name === activeName) || ep.endpoints?.[0]; + if (endpoint) { + applyEndpoint(endpoint); + console.log("[Proxy] Loaded endpoint:", endpoint.name); + } + } catch (e) { /* fallthrough */ } + } +} + +function applyConfig(cfg) { + PORT = cfg.port || 48080; + BACKEND = cfg.backend_type || "openai-compat"; + TARGET_URL = cfg.target_url || "http://localhost:11434/v1"; + API_KEY = cfg.api_key || ""; + OAUTH_PROVIDER = cfg.oauth_provider || ""; + REASONING_ENABLED = cfg.reasoning_enabled !== undefined ? cfg.reasoning_enabled : true; + REASONING_EFFORT = cfg.reasoning_effort || "medium"; + CC_VERSION = cfg.cc_version || ""; + MODELS = cfg.models || []; +} + +function applyEndpoint(endpoint) { + BACKEND = endpoint.backend_type || "openai-compat"; + TARGET_URL = endpoint.base_url || ""; + if (BACKEND === "openai-compat" && !TARGET_URL.endsWith("/v1")) { + TARGET_URL = TARGET_URL.replace(/\/+$/, "") + "/v1"; + } + API_KEY = endpoint.api_key || ""; + OAUTH_PROVIDER = endpoint.oauth_provider || ""; + REASONING_ENABLED = endpoint.reasoning_enabled !== undefined ? endpoint.reasoning_enabled : true; + REASONING_EFFORT = endpoint.reasoning_effort || "medium"; + CC_VERSION = endpoint.cc_version || ""; + MODELS = (endpoint.models || []).map(m => ({ + id: typeof m === "string" ? m : m.id, + object: "model", + created: 1700000000, + owned_by: endpoint.name || "custom" + })); +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- +function jsonResponse(res, statusCode, data) { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString())); + req.on("error", reject); + }); +} + +function proxyRequest(targetUrl, method, headers, bodyBuffer, res, isStream) { + return new Promise((resolve, reject) => { + const urlObj = new URL(targetUrl); + const isHttps = urlObj.protocol === "https:"; + const mod = isHttps ? https : http; + + const opts = { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: method, + headers: { ...headers }, + }; + // Remove host header to avoid conflicts + delete opts.headers.host; + delete opts.headers["host"]; + // Set correct content-length + if (bodyBuffer) { + opts.headers["content-length"] = Buffer.byteLength(bodyBuffer); + } + + const upstream = mod.request(opts, (upRes) => { + if (isStream && (upRes.headers["content-type"]?.includes("text/event-stream") || upRes.headers["content-type"]?.includes("application/octet-stream"))) { + res.writeHead(upRes.statusCode, upRes.headers); + upRes.pipe(res); + upRes.on("end", resolve); + upRes.on("error", reject); + } else { + const respChunks = []; + upRes.on("data", chunk => respChunks.push(chunk)); + upRes.on("end", () => { + const respBody = Buffer.concat(respChunks).toString(); + if (!res.headersSent) { + res.writeHead(upRes.statusCode, upRes.headers); + } + res.end(respBody); + resolve(respBody); + }); + upRes.on("error", reject); + } + }); + + upstream.on("error", (err) => { + console.error("[Proxy] Upstream error:", err.message); + if (!res.headersSent) { + jsonResponse(res, 502, { error: { message: `Upstream error: ${err.message}` } }); + } + reject(err); + }); + + if (bodyBuffer) upstream.write(bodyBuffer); + upstream.end(); + }); +} + +// --------------------------------------------------------------------------- +// Translation: Responses API โ†’ OpenAI Chat Completions +// --------------------------------------------------------------------------- +function responsesToChatCompletions(body) { + const parsed = typeof body === "string" ? JSON.parse(body) : body; + const messages = []; + + // System instructions + if (parsed.instructions) { + messages.push({ role: "system", content: parsed.instructions }); + } + + // Input items + if (parsed.input) { + const inputItems = Array.isArray(parsed.input) ? parsed.input : [{ role: "user", content: parsed.input }]; + for (const item of inputItems) { + if (typeof item === "string") { + messages.push({ role: "user", content: item }); + } else if (item.type === "message") { + const content = item.content; + if (Array.isArray(content)) { + const textParts = content.filter(c => c.type === "input_text" || c.type === "text").map(c => c.text).join("\n"); + if (textParts) messages.push({ role: item.role || "user", content: textParts }); + } else if (typeof content === "string") { + messages.push({ role: item.role || "user", content }); + } + } else if (item.type === "function_call_output" || item.type === "function_call_output") { + messages.push({ role: "tool", content: item.output || "", tool_call_id: item.call_id || item.id || "" }); + } else if (item.type === "function_call") { + // Add assistant message with tool call + const lastMsg = messages[messages.length - 1]; + if (lastMsg && lastMsg.role === "assistant" && lastMsg.tool_calls) { + lastMsg.tool_calls.push({ + id: item.call_id || item.id || "", + type: "function", + function: { name: item.name || "", arguments: item.arguments || "{}" } + }); + } else { + messages.push({ + role: "assistant", + content: null, + tool_calls: [{ + id: item.call_id || item.id || "", + type: "function", + function: { name: item.name || "", arguments: item.arguments || "{}" } + }] + }); + } + } else if (item.role) { + messages.push({ role: item.role, content: item.content || "" }); + } + } + } + + // Tools + const tools = []; + if (parsed.tools) { + for (const t of parsed.tools) { + if (t.type === "function" && t.function) { + tools.push({ type: "function", function: t.function }); + } + } + } + + const result = { + model: parsed.model || "gpt-4o", + messages, + stream: parsed.stream || false, + }; + if (tools.length > 0) result.tools = tools; + if (parsed.temperature !== undefined) result.temperature = parsed.temperature; + if (parsed.max_output_tokens !== undefined) result.max_tokens = parsed.max_output_tokens; + if (parsed.top_p !== undefined) result.top_p = parsed.top_p; + + return result; +} + +// --------------------------------------------------------------------------- +// Translation: Chat Completions response โ†’ Responses API +// --------------------------------------------------------------------------- +function chatToResponses(chatResp, reqModel) { + const choice = chatResp.choices?.[0]; + if (!choice) { + return { + id: "resp_" + Date.now(), + object: "response", + model: reqModel || chatResp.model || "unknown", + created: Math.floor(Date.now() / 1000), + status: "failed", + output: [] + }; + } + + const output = []; + const msg = choice.message || {}; + + if (msg.content) { + output.push({ + type: "message", + id: "msg_" + Date.now(), + role: "assistant", + content: [{ type: "output_text", text: msg.content }] + }); + } + + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + output.push({ + type: "function_call", + id: tc.id || "fc_" + Date.now(), + call_id: tc.id || "fc_" + Date.now(), + name: tc.function?.name || "", + arguments: tc.function?.arguments || "{}" + }); + } + } + + return { + id: "resp_" + Date.now(), + object: "response", + model: reqModel || chatResp.model || "unknown", + created: Math.floor(Date.now() / 1000), + status: "completed", + output, + usage: { + input_tokens: chatResp.usage?.prompt_tokens || 0, + output_tokens: chatResp.usage?.completion_tokens || 0, + total_tokens: chatResp.usage?.total_tokens || 0, + } + }; +} + +// --------------------------------------------------------------------------- +// Translation: Responses API โ†’ Anthropic Messages +// --------------------------------------------------------------------------- +function responsesToAnthropic(body) { + const parsed = typeof body === "string" ? JSON.parse(body) : body; + const messages = []; + let systemPrompt = ""; + + if (parsed.instructions) { + systemPrompt = parsed.instructions; + } + + if (parsed.input) { + const inputItems = Array.isArray(parsed.input) ? parsed.input : [{ role: "user", content: parsed.input }]; + for (const item of inputItems) { + if (typeof item === "string") { + messages.push({ role: "user", content: item }); + } else if (item.type === "message") { + const content = item.content; + if (Array.isArray(content)) { + const textParts = content.filter(c => c.type === "input_text" || c.type === "text").map(c => c.text).join("\n"); + if (textParts) messages.push({ role: item.role === "assistant" ? "assistant" : "user", content: textParts }); + } else if (typeof content === "string") { + messages.push({ role: item.role === "assistant" ? "assistant" : "user", content }); + } + } else if (item.type === "function_call") { + messages.push({ + role: "assistant", + content: [{ type: "tool_use", id: item.call_id || item.id || "", name: item.name || "", input: JSON.parse(item.arguments || "{}") }] + }); + } else if (item.type === "function_call_output") { + messages.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: item.call_id || item.id || "", content: item.output || "" }] + }); + } else if (item.role) { + messages.push({ role: item.role === "assistant" ? "assistant" : "user", content: item.content || "" }); + } + } + } + + const tools = []; + if (parsed.tools) { + for (const t of parsed.tools) { + if (t.type === "function" && t.function) { + tools.push({ + name: t.function.name, + description: t.function.description || "", + input_schema: t.function.parameters || { type: "object", properties: {} } + }); + } + } + } + + const result = { + model: parsed.model || "claude-sonnet-4-20250514", + messages, + max_tokens: parsed.max_output_tokens || 16384, + stream: parsed.stream || false, + }; + if (systemPrompt) result.system = systemPrompt; + if (tools.length > 0) result.tools = tools; + if (parsed.temperature !== undefined) result.temperature = parsed.temperature; + if (parsed.top_p !== undefined) result.top_p = parsed.top_p; + + return result; +} + +// --------------------------------------------------------------------------- +// Translation: Anthropic response โ†’ Responses API +// --------------------------------------------------------------------------- +function anthropicToResponses(anthroResp, reqModel) { + const output = []; + const content = anthroResp.content || []; + + for (const block of content) { + if (block.type === "text") { + output.push({ + type: "message", + id: "msg_" + Date.now(), + role: "assistant", + content: [{ type: "output_text", text: block.text }] + }); + } else if (block.type === "tool_use") { + output.push({ + type: "function_call", + id: block.id || "fc_" + Date.now(), + call_id: block.id || "fc_" + Date.now(), + name: block.name || "", + arguments: JSON.stringify(block.input || {}) + }); + } + } + + return { + id: "resp_" + Date.now(), + object: "response", + model: reqModel || anthroResp.model || "unknown", + created: Math.floor(Date.now() / 1000), + status: anthroResp.stop_reason === "end_turn" || anthroResp.stop_reason === "stop" ? "completed" : "incomplete", + output, + usage: { + input_tokens: anthroResp.usage?.input_tokens || 0, + output_tokens: anthroResp.usage?.output_tokens || 0, + total_tokens: (anthroResp.usage?.input_tokens || 0) + (anthroResp.usage?.output_tokens || 0), + } + }; +} + +// --------------------------------------------------------------------------- +// Gemini internal format โ†’ Responses API +// --------------------------------------------------------------------------- +function geminiToResponses(geminiReq, stream) { + const parts = geminiReq.contents || []; + const messages = []; + + for (const part of parts) { + const role = part.role === "model" ? "assistant" : "user"; + const contentParts = part.parts || []; + for (const cp of contentParts) { + if (cp.text) { + messages.push({ type: "message", role, content: [{ type: "input_text", text: cp.text }] }); + } else if (cp.functionCall) { + messages.push({ + type: "function_call", + id: cp.functionCall.name + "_" + Date.now(), + call_id: cp.functionCall.name + "_" + Date.now(), + name: cp.functionCall.name, + arguments: JSON.stringify(cp.functionCall.args || {}) + }); + } else if (cp.functionResponse) { + messages.push({ + type: "function_call_output", + id: cp.functionResponse.name + "_" + Date.now(), + call_id: cp.functionResponse.name + "_" + Date.now(), + output: JSON.stringify(cp.functionResponse.response?.result || cp.functionResponse.response || "") + }); + } + } + } + + // Convert tools + const tools = []; + if (geminiReq.tools) { + for (const t of geminiReq.tools) { + if (t.functionDeclarations) { + for (const fd of t.functionDeclarations) { + tools.push({ + type: "function", + function: { + name: fd.name, + description: fd.description || "", + parameters: fd.parameters || { type: "object", properties: {} } + } + }); + } + } + } + } + + return { + model: (MODELS[0]?.id) || "default", + input: messages, + tools: tools.length > 0 ? tools : undefined, + stream, + }; +} + +// --------------------------------------------------------------------------- +// SSE streaming helpers +// --------------------------------------------------------------------------- +function parseSSE(data) { + const events = []; + let currentEvent = { event: "", data: "" }; + for (const line of data.split("\n")) { + if (line.startsWith("event: ")) { + currentEvent.event = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + currentEvent.data = line.slice(6); + events.push({ ...currentEvent }); + currentEvent = { event: "", data: "" }; + } else if (line.trim() === "" && currentEvent.data) { + events.push({ ...currentEvent }); + currentEvent = { event: "", data: "" }; + } + } + return events; +} + +function writeSSE(res, event, data) { + if (!res.writableEnded && !res.destroyed) { + res.write(`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}\n\n`); + } +} + +// --------------------------------------------------------------------------- +// Stream translation: OpenAI SSE โ†’ Responses API SSE +// --------------------------------------------------------------------------- +async function handleOpenAIStream(req, res, body, config) { + const chatBody = responsesToChatCompletions(body); + chatBody.stream = true; + const reqModel = chatBody.model; + + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + let targetPath = "/v1/chat/completions"; + const targetUrl = baseUrl + targetPath; + + const headers = { "Content-Type": "application/json" }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + + const bodyStr = JSON.stringify(chatBody); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + + writeSSE(res, "response.created", { + id: "resp_" + Date.now(), object: "response", model: reqModel, + created: Math.floor(Date.now() / 1000), status: "in_progress", output: [] + }); + + const urlObj = new URL(targetUrl); + const mod = urlObj.protocol === "https:" ? https : http; + let contentAccum = ""; + let toolCallsAccum = {}; + let respId = "resp_" + Date.now(); + + return new Promise((resolve) => { + const upstream = mod.request({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr) }, + }, (upRes) => { + let buffer = ""; + upRes.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice(6).trim(); + if (dataStr === "[DONE]") { + // Flush any remaining tool calls + for (const [id, tc] of Object.entries(toolCallsAccum)) { + writeSSE(res, "response.function_call_arguments.done", { + item_id: id, arguments: tc.args + }); + } + writeSSE(res, "response.completed", { + id: respId, object: "response", model: reqModel, + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }] + }); + res.end(); + resolve(); + return; + } + try { + const chunk2 = JSON.parse(dataStr); + const delta = chunk2.choices?.[0]?.delta; + if (delta?.content) { + contentAccum += delta.content; + writeSSE(res, "response.output_text.delta", { delta: delta.content }); + } + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + if (!toolCallsAccum[tc.id || "tc_0"]) { + toolCallsAccum[tc.id || "tc_0"] = { name: tc.function?.name || "", args: "" }; + writeSSE(res, "response.output_item.added", { + item: { type: "function_call", id: tc.id || "tc_0", call_id: tc.id || "tc_0", name: tc.function?.name || "" } + }); + } + if (tc.function?.arguments) { + toolCallsAccum[tc.id || "tc_0"].args += tc.function.arguments; + writeSSE(res, "response.function_call_arguments.delta", { + item_id: tc.id || "tc_0", delta: tc.function.arguments + }); + } + } + } + } catch (e) { /* skip unparseable chunks */ } + } + } + }); + upRes.on("end", () => { + if (!res.writableEnded) { + writeSSE(res, "response.completed", { + id: respId, object: "response", model: reqModel, + status: "completed", output: [] + }); + res.end(); + } + resolve(); + }); + upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); }); + }); + + upstream.on("error", (err) => { + console.error("[Proxy] Stream upstream error:", err.message); + writeSSE(res, "response.completed", { + id: respId, status: "failed", + error: { message: err.message } + }); + if (!res.writableEnded) res.end(); + resolve(); + }); + + upstream.write(bodyStr); + upstream.end(); + }); +} + +// --------------------------------------------------------------------------- +// Stream translation: Anthropic SSE โ†’ Responses API SSE +// --------------------------------------------------------------------------- +async function handleAnthropicStream(req, res, body, config) { + const antBody = responsesToAnthropic(body); + antBody.stream = true; + const reqModel = antBody.model; + + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/v1/messages"; + + const headers = { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + }; + if (API_KEY) headers["x-api-key"] = API_KEY; + + const bodyStr = JSON.stringify(antBody); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + + const respId = "resp_" + Date.now(); + writeSSE(res, "response.created", { + id: respId, object: "response", model: reqModel, + created: Math.floor(Date.now() / 1000), status: "in_progress", output: [] + }); + + const urlObj = new URL(targetUrl); + const mod = urlObj.protocol === "https:" ? https : http; + let contentAccum = ""; + let currentToolId = null; + let currentToolName = ""; + let currentToolArgs = ""; + + return new Promise((resolve) => { + const upstream = mod.request({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr) }, + }, (upRes) => { + let buffer = ""; + upRes.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice(6).trim(); + try { + const evt = JSON.parse(dataStr); + if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta") { + contentAccum += evt.delta.text; + writeSSE(res, "response.output_text.delta", { delta: evt.delta.text }); + } else if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use") { + currentToolId = evt.content_block.id; + currentToolName = evt.content_block.name; + currentToolArgs = ""; + writeSSE(res, "response.output_item.added", { + item: { type: "function_call", id: currentToolId, call_id: currentToolId, name: currentToolName } + }); + } else if (evt.type === "input_json_delta" && evt.partial_json) { + currentToolArgs += evt.partial_json; + writeSSE(res, "response.function_call_arguments.delta", { + item_id: currentToolId, delta: evt.partial_json + }); + } else if (evt.type === "message_stop") { + if (currentToolId) { + writeSSE(res, "response.function_call_arguments.done", { + item_id: currentToolId, arguments: currentToolArgs + }); + } + writeSSE(res, "response.completed", { + id: respId, object: "response", model: reqModel, + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }] + }); + res.end(); + resolve(); + return; + } + } catch (e) { /* skip */ } + } + } + }); + upRes.on("end", () => { + if (!res.writableEnded) { + writeSSE(res, "response.completed", { id: respId, status: "completed", output: [] }); + res.end(); + } + resolve(); + }); + upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); }); + }); + + upstream.on("error", (err) => { + writeSSE(res, "response.completed", { id: respId, status: "failed", error: { message: err.message } }); + if (!res.writableEnded) res.end(); + resolve(); + }); + + upstream.write(bodyStr); + upstream.end(); + }); +} + +// --------------------------------------------------------------------------- +// Command-Code backend (passthrough with headers) +// --------------------------------------------------------------------------- +async function handleCommandCode(req, res, bodyStr) { + const parsed = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr; + const isStream = parsed.stream || false; + + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/alpha/generate"; + + const headers = { + "Content-Type": "application/json", + "x-command-code-version": CC_VERSION || "0.26.8", + }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + + if (isStream) { + // SSE passthrough with Responses API wrapping + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + + const respId = "resp_" + Date.now(); + const reqModel = parsed.model || MODELS[0]?.id || "default"; + writeSSE(res, "response.created", { + id: respId, object: "response", model: reqModel, + created: Math.floor(Date.now() / 1000), status: "in_progress", output: [] + }); + + const bodyData = JSON.stringify(parsed); + const urlObj = new URL(targetUrl); + const mod = urlObj.protocol === "https:" ? https : http; + let contentAccum = ""; + + return new Promise((resolve) => { + const upstream = mod.request({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) }, + }, (upRes) => { + let buffer = ""; + upRes.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice(6).trim(); + if (dataStr === "[DONE]") { + writeSSE(res, "response.completed", { + id: respId, object: "response", model: reqModel, + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: contentAccum }] }] + }); + res.end(); + resolve(); + return; + } + try { + const chunk2 = JSON.parse(dataStr); + // CC streaming format varies, try common patterns + const delta = chunk2.choices?.[0]?.delta?.content || chunk2.text || chunk2.delta || chunk2.content || ""; + if (delta) { + contentAccum += delta; + writeSSE(res, "response.output_text.delta", { delta }); + } + } catch (e) { /* skip */ } + } + } + }); + upRes.on("end", () => { + if (!res.writableEnded) { + writeSSE(res, "response.completed", { id: respId, status: "completed", output: [] }); + res.end(); + } + resolve(); + }); + upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); }); + }); + + upstream.on("error", (err) => { + writeSSE(res, "response.completed", { id: respId, status: "failed", error: { message: err.message } }); + if (!res.writableEnded) res.end(); + resolve(); + }); + + upstream.write(bodyData); + upstream.end(); + }); + } else { + // Non-stream: forward and wrap response + const bodyData = JSON.stringify(parsed); + const urlObj = new URL(targetUrl); + const mod = urlObj.protocol === "https:" ? https : http; + + return new Promise((resolve) => { + const upstream = mod.request({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) }, + }, (upRes) => { + const chunks = []; + upRes.on("data", chunk => chunks.push(chunk)); + upRes.on("end", () => { + const respBody = Buffer.concat(chunks).toString(); + try { + const ccResp = JSON.parse(respBody); + // Wrap in Responses API format + const text = ccResp.choices?.[0]?.message?.content || ccResp.content || ccResp.text || ccResp.output || respBody; + const result = { + id: "resp_" + Date.now(), + object: "response", + model: parsed.model || "unknown", + created: Math.floor(Date.now() / 1000), + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], + }; + jsonResponse(res, upRes.statusCode, result); + } catch (e) { + jsonResponse(res, upRes.statusCode, { + id: "resp_" + Date.now(), status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: respBody }] }] + }); + } + resolve(); + }); + upRes.on("error", () => { resolve(); }); + }); + + upstream.on("error", (err) => { + jsonResponse(res, 502, { error: { message: err.message } }); + resolve(); + }); + + upstream.write(bodyData); + upstream.end(); + }); + } +} + +// --------------------------------------------------------------------------- +// Gemini internal โ†’ backend translation +// --------------------------------------------------------------------------- +async function handleGeminiInternal(req, res) { + const isStream = req.url?.includes("streamGenerateContent") || false; + const bodyStr = await readBody(req); + let geminiReq; + try { geminiReq = JSON.parse(bodyStr); } catch (e) { return jsonResponse(res, 400, { error: { message: "Invalid JSON" } }); } + + const responsesBody = geminiToResponses(geminiReq, isStream); + + if (BACKEND === "openai-compat") { + if (isStream) { + return await handleOpenAIStream(req, res, JSON.stringify(responsesBody)); + } + const chatBody = responsesToChatCompletions(responsesBody); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/chat/completions"; + const headers = { "Content-Type": "application/json" }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(chatBody), res, false); + try { + const chatResp = JSON.parse(resp); + // Convert back to Gemini format + const text = chatResp.choices?.[0]?.message?.content || ""; + const geminiResp = { + candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }] + }; + jsonResponse(res, 200, geminiResp); + } catch (e) { /* already sent */ } + } else if (BACKEND === "anthropic") { + if (isStream) { + return await handleAnthropicStream(req, res, JSON.stringify(responsesBody)); + } + const antBody = responsesToAnthropic(responsesBody); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/v1/messages"; + const headers = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" }; + if (API_KEY) headers["x-api-key"] = API_KEY; + const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(antBody), res, false); + try { + const antResp = JSON.parse(resp); + const text = antResp.content?.find(c => c.type === "text")?.text || ""; + const geminiResp = { + candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }] + }; + jsonResponse(res, 200, geminiResp); + } catch (e) { /* already sent */ } + } else if (BACKEND === "command-code") { + if (isStream) { + // Need to handle Gemini SSE format + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }); + // Simplified: translate to CC, stream back as Gemini chunks + const bodyData = JSON.stringify(responsesBody); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/alpha/generate"; + const headers = { "Content-Type": "application/json", "x-command-code-version": CC_VERSION || "0.26.8" }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + const urlObj = new URL(targetUrl); + const mod = urlObj.protocol === "https:" ? https : http; + return new Promise((resolve) => { + const upstream = mod.request({ + hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80), + path: urlObj.pathname + urlObj.search, method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(bodyData) }, + }, (upRes) => { + let buffer = ""; + upRes.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice(6).trim(); + if (dataStr === "[DONE]") { res.end(); resolve(); return; } + try { + const c = JSON.parse(dataStr); + const delta = c.choices?.[0]?.delta?.content || c.text || c.delta || c.content || ""; + if (delta) { + const geminiChunk = { candidates: [{ content: { role: "model", parts: [{ text: delta }] }, index: 0 }] }; + res.write(`data: ${JSON.stringify(geminiChunk)}\n\n`); + } + } catch (e) { /* skip */ } + } + } + }); + upRes.on("end", () => { if (!res.writableEnded) res.end(); resolve(); }); + upRes.on("error", () => { if (!res.writableEnded) res.end(); resolve(); }); + }); + upstream.on("error", () => { if (!res.writableEnded) res.end(); resolve(); }); + upstream.write(bodyData); + upstream.end(); + }); + } + // Non-stream CC + const bodyData = JSON.stringify(responsesBody); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/alpha/generate"; + const headers = { "Content-Type": "application/json", "x-command-code-version": CC_VERSION || "0.26.8" }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + const resp = await proxyRequest(targetUrl, "POST", headers, bodyData, res, false); + try { + const ccResp = JSON.parse(resp); + const text = ccResp.choices?.[0]?.message?.content || ccResp.content || ccResp.text || ""; + const geminiResp = { + candidates: [{ content: { role: "model", parts: [{ text }] }, finishReason: "STOP", index: 0 }] + }; + jsonResponse(res, 200, geminiResp); + } catch (e) { /* already sent */ } + } else { + jsonResponse(res, 400, { error: { message: `Unsupported backend: ${BACKEND}` } }); + } +} + +// --------------------------------------------------------------------------- +// Main request handler +// --------------------------------------------------------------------------- +async function handleRequest(req, res) { + const parsedUrl = url.parse(req.url || "/"); + + // CORS + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + try { + // --- Management routes --- + if (parsedUrl.pathname === "/codex/list-endpoints" && req.method === "GET") { + let endpointsData = { default: "", endpoints: [] }; + if (fs.existsSync(ENDPOINTS_PATH)) { + try { endpointsData = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8")); } catch (e) {} + } + let activeName = ""; + if (fs.existsSync(ACTIVE_PATH)) { + try { activeName = JSON.parse(fs.readFileSync(ACTIVE_PATH, "utf8")).active; } catch (e) {} + } + if (!activeName) activeName = endpointsData.default || ""; + return jsonResponse(res, 200, { + endpoints: endpointsData.endpoints || [], + active: activeName + }); + } + + if (parsedUrl.pathname === "/codex/switch-endpoint" && req.method === "POST") { + const body = await readBody(req); + let parsed; + try { parsed = JSON.parse(body); } catch (e) { return jsonResponse(res, 400, { error: { message: "Invalid JSON" } }); } + + const endpointName = parsed.name; + if (!fs.existsSync(ENDPOINTS_PATH)) { + return jsonResponse(res, 404, { error: { message: "endpoints.json not found" } }); + } + let endpointsData; + try { endpointsData = JSON.parse(fs.readFileSync(ENDPOINTS_PATH, "utf8")); } catch (e) { + return jsonResponse(res, 500, { error: { message: "Failed to read endpoints" } }); + } + + const endpoint = endpointsData.endpoints?.find(e => e.name === endpointName); + if (!endpoint) { + return jsonResponse(res, 404, { error: { message: `Endpoint '${endpointName}' not found` } }); + } + + applyEndpoint(endpoint); + + // Save active endpoint + try { + fs.mkdirSync(path.dirname(ACTIVE_PATH), { recursive: true }); + fs.writeFileSync(ACTIVE_PATH, JSON.stringify({ active: endpointName })); + } catch (e) {} + + // Save active config + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(path.join(CONFIG_DIR, "proxy-active.json"), JSON.stringify({ + port: PORT, backend_type: BACKEND, target_url: TARGET_URL, + api_key: API_KEY, cc_version: CC_VERSION, oauth_provider: OAUTH_PROVIDER, + reasoning_enabled: REASONING_ENABLED, reasoning_effort: REASONING_EFFORT, + models: MODELS + }, null, 2)); + } catch (e) {} + + console.log(`[Proxy] Switched to: ${endpointName} (${BACKEND})`); + return jsonResponse(res, 200, { success: true, active: endpointName }); + } + + if (parsedUrl.pathname === "/v1/models" && req.method === "GET") { + return jsonResponse(res, 200, { object: "list", data: MODELS }); + } + + if (parsedUrl.pathname === "/health" && req.method === "GET") { + return jsonResponse(res, 200, { + ok: true, backend: BACKEND, target_url: TARGET_URL, + models: MODELS.map(m => m.id || m) + }); + } + + // --- Gemini internal routes --- + if (parsedUrl.pathname?.startsWith("/v1internal:")) { + if (parsedUrl.pathname.startsWith("/v1internal:fetchAvailableModels")) { + const modelsList = MODELS.map(m => ({ + name: `models/${typeof m === "string" ? m : m.id}`, + version: "1.0", + displayName: typeof m === "string" ? m : m.id, + description: `Model via AG X Proxy`, + supportedGenerationMethods: ["generateContent", "streamGenerateContent"] + })); + return jsonResponse(res, 200, { models: modelsList }); + } + return await handleGeminiInternal(req, res); + } + + // --- Responses API route --- + if (parsedUrl.pathname === "/v1/responses" || parsedUrl.pathname === "/responses") { + const bodyStr = await readBody(req); + let parsed; + try { parsed = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr; } catch (e) { + return jsonResponse(res, 400, { error: { message: "Invalid JSON" } }); + } + + const isStream = parsed.stream || false; + + if (BACKEND === "openai-compat") { + if (isStream) { + return await handleOpenAIStream(req, res, bodyStr); + } + const chatBody = responsesToChatCompletions(bodyStr); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/chat/completions"; + const headers = { "Content-Type": "application/json" }; + if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`; + const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(chatBody), res, false); + try { + const chatResp = JSON.parse(resp); + jsonResponse(res, 200, chatToResponses(chatResp, chatBody.model)); + } catch (e) { /* response already sent */ } + + } else if (BACKEND === "anthropic") { + if (isStream) { + return await handleAnthropicStream(req, res, bodyStr); + } + const antBody = responsesToAnthropic(bodyStr); + const baseUrl = (TARGET_URL || "").replace(/\/+$/, ""); + const targetUrl = baseUrl + "/v1/messages"; + const headers = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" }; + if (API_KEY) headers["x-api-key"] = API_KEY; + const resp = await proxyRequest(targetUrl, "POST", headers, JSON.stringify(antBody), res, false); + try { + const antResp = JSON.parse(resp); + jsonResponse(res, 200, anthropicToResponses(antResp, antBody.model)); + } catch (e) { /* response already sent */ } + + } else if (BACKEND === "command-code") { + return await handleCommandCode(req, res, bodyStr); + } else { + jsonResponse(res, 400, { error: { message: `Unsupported backend: ${BACKEND}` } }); + } + return; + } + + // 404 + jsonResponse(res, 404, { error: { message: "Not found" } }); + } catch (err) { + console.error("[Proxy] Unhandled error:", err); + if (!res.headersSent) { + jsonResponse(res, 500, { error: { message: err.message } }); + } + } +} + +// --------------------------------------------------------------------------- +// Start server +// --------------------------------------------------------------------------- +function start() { + initFromConfig(); + + const server = http.createServer(handleRequest); + server.listen(PORT, "127.0.0.1", () => { + console.log(`[AG X Proxy] Listening on http://127.0.0.1:${PORT}`); + console.log(`[AG X Proxy] Backend: ${BACKEND}, Target: ${TARGET_URL}`); + console.log(`[AG X Proxy] Models: ${MODELS.map(m => typeof m === "string" ? m : m.id).join(", ")}`); + }); + server.on("error", (err) => { + console.error("[AG X Proxy] Server error:", err.message); + process.exit(1); + }); + + process.on("SIGTERM", () => { server.close(); process.exit(0); }); + process.on("SIGINT", () => { server.close(); process.exit(0); }); +} + +// Allow running standalone +if (require.main === module) { + start(); +} + +module.exports = { start, handleRequest, applyEndpoint }; diff --git a/dist/storage.js b/dist/storage.js new file mode 100644 index 0000000..fc72672 --- /dev/null +++ b/dist/storage.js @@ -0,0 +1,128 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StorageManager = void 0; +const fs = __importStar(require("fs/promises")); +const fs_1 = require("fs"); +const path = __importStar(require("path")); +const electron_1 = require("electron"); +const events_1 = require("events"); +/** + * Manages persistent storage for the application. + * Stores key-value pairs. + */ +class StorageManager { + constructor(storagePath, defaults) { + this.storagePath = storagePath; + this.defaults = defaults; + this.emitter = new events_1.EventEmitter(); + this.onDidChange = (listener) => { + this.emitter.on('changed', listener); + return { + dispose: () => this.emitter.off('changed', listener), + }; + }; + } + /** + * Gets raw items from the storage file. + */ + async getRawItems() { + try { + if (!(0, fs_1.existsSync)(this.storagePath)) { + return {}; + } + const content = await fs.readFile(this.storagePath, 'utf-8'); + if (!content || content.trim() === '') { + return {}; + } + return JSON.parse(content); + } + catch (e) { + console.error('Error reading storage items:', e); + return {}; + } + } + /** + * Gets all items from the storage, with defaults applied. + * + * @returns A record of key-value pairs. + */ + async getItems() { + const items = await this.getRawItems(); + const merged = { ...items }; + if (this.defaults) { + for (const [key, value] of this.defaults.entries()) { + if (merged[key] === undefined) { + merged[key] = String(value); + } + } + } + return merged; + } + /** + * Updates items in the storage. + * + * @param changes A record of key-value pairs to update. If a value is null, the key will be deleted. + */ + async updateItems(changes) { + try { + const currentItems = await this.getRawItems(); + for (const [key, value] of Object.entries(changes)) { + if (value === null) { + delete currentItems[key]; + } + else { + currentItems[key] = value; + } + } + // Ensure directory exists + const dir = path.dirname(this.storagePath); + if (!(0, fs_1.existsSync)(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + await fs.writeFile(this.storagePath, JSON.stringify(currentItems, null, 2), 'utf-8'); + const windows = electron_1.BrowserWindow.getAllWindows(); + for (const win of windows) { + win.webContents.send('storage:changed', changes); + } + this.emitter.emit('changed', changes); + } + catch (e) { + console.error('Error updating storage items:', e); + throw e; + } + } +} +exports.StorageManager = StorageManager; diff --git a/dist/storage.test.js b/dist/storage.test.js new file mode 100644 index 0000000..5a46185 --- /dev/null +++ b/dist/storage.test.js @@ -0,0 +1,142 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const storage_1 = require("./storage"); +const fs = __importStar(require("fs/promises")); +const fs_1 = require("fs"); +const settingsService_1 = require("./services/settingsService"); +vitest_1.vi.mock('fs/promises'); +vitest_1.vi.mock('fs'); +vitest_1.vi.mock('electron'); +(0, vitest_1.describe)('StorageManager', () => { + const mockPath = '/fake/path/storage.json'; + let storageManager; + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); + storageManager = new storage_1.StorageManager(mockPath, settingsService_1.DEFAULTS); + }); + (0, vitest_1.describe)('getItems', () => { + (0, vitest_1.it)('should return defaults if file does not exist', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(false); + const items = await storageManager.getItems(); + (0, vitest_1.expect)(items).toEqual({ + runInBackground: String(process.platform !== 'win32'), + keepComputerAwake: 'false', + }); + (0, vitest_1.expect)(fs_1.existsSync).toHaveBeenCalledWith(mockPath); + }); + (0, vitest_1.it)('should return defaults if file is empty', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue(''); + const items = await storageManager.getItems(); + (0, vitest_1.expect)(items).toEqual({ + runInBackground: String(process.platform !== 'win32'), + keepComputerAwake: 'false', + }); + }); + (0, vitest_1.it)('should return parsed JSON object merged with defaults if file contains valid JSON', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('{"key1": "value1"}'); + const items = await storageManager.getItems(); + (0, vitest_1.expect)(items).toEqual({ + key1: 'value1', + runInBackground: String(process.platform !== 'win32'), + keepComputerAwake: 'false', + }); + }); + (0, vitest_1.it)('should handle JSON parse error and return defaults', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('invalid-json'); + const items = await storageManager.getItems(); + (0, vitest_1.expect)(items).toEqual({ + runInBackground: String(process.platform !== 'win32'), + keepComputerAwake: 'false', + }); + }); + }); + (0, vitest_1.describe)('updateItems', () => { + (0, vitest_1.it)('should save updates merge with existing items', async () => { + // Setup: file exists and has some data + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('{"key1": "value1"}'); + vitest_1.vi.mocked(fs.writeFile).mockResolvedValue(undefined); + await storageManager.updateItems({ key2: 'value2', key1: 'newValue1' }); + // Should write merged data + (0, vitest_1.expect)(fs.writeFile).toHaveBeenCalledWith(mockPath, vitest_1.expect.stringContaining('"key1": "newValue1"'), 'utf-8'); + (0, vitest_1.expect)(fs.writeFile).toHaveBeenCalledWith(mockPath, vitest_1.expect.stringContaining('"key2": "value2"'), 'utf-8'); + }); + (0, vitest_1.it)('should create directory if it does not exist before writing', async () => { + // Mock existsSync(file) as true for reading, but mock existsSync(dir) as false for mkdir + vitest_1.vi.mocked(fs_1.existsSync).mockImplementation((path) => { + if (path === mockPath) { + return true; // file exists for read + } + if (path === '/fake/path') { + return false; // dir doesn't exist + } + return false; + }); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('{}'); + vitest_1.vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vitest_1.vi.mocked(fs.writeFile).mockResolvedValue(undefined); + await storageManager.updateItems({ key: 'value' }); + (0, vitest_1.expect)(fs.mkdir).toHaveBeenCalledWith('/fake/path', { recursive: true }); + (0, vitest_1.expect)(fs.writeFile).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should broadcast storage:changed to all windows', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('{"key1": "value1"}'); + vitest_1.vi.mocked(fs.writeFile).mockResolvedValue(undefined); + const { BrowserWindow } = await Promise.resolve().then(() => __importStar(require('electron'))); + const mockWindows = BrowserWindow.getAllWindows(); + const mockWebContents = mockWindows[0].webContents; + await storageManager.updateItems({ key2: 'value2' }); + (0, vitest_1.expect)(BrowserWindow.getAllWindows).toHaveBeenCalled(); + (0, vitest_1.expect)(mockWebContents.send).toHaveBeenCalledWith('storage:changed', { + key2: 'value2', + }); + }); + (0, vitest_1.it)('should emit changed event when items are updated', async () => { + vitest_1.vi.mocked(fs_1.existsSync).mockReturnValue(true); + vitest_1.vi.mocked(fs.readFile).mockResolvedValue('{}'); + vitest_1.vi.mocked(fs.writeFile).mockResolvedValue(undefined); + const listener = vitest_1.vi.fn(); + storageManager.onDidChange(listener); + await storageManager.updateItems({ key: 'value' }); + (0, vitest_1.expect)(listener).toHaveBeenCalledWith({ key: 'value' }); + }); + }); +}); diff --git a/dist/test/helpers.js b/dist/test/helpers.js new file mode 100644 index 0000000..6ae57b1 --- /dev/null +++ b/dist/test/helpers.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_WINDOW_URL = void 0; +exports.silenceConsole = silenceConsole; +/** + * Shared test helpers and utilities. + * + * For module mocks (electron, electron-updater), use the auto-mock files + * in `src/__mocks__/` instead. This file is for runtime helpers that + * are called in beforeEach/afterEach blocks. + */ +const vitest_1 = require("vitest"); +const constants_1 = require("../constants"); +exports.DEFAULT_WINDOW_URL = `${constants_1.WINDOW_ORIGIN}:${constants_1.DYNAMIC_PORT}/`; +/** + * Silence console output during tests. Call in `beforeEach`. + * Restoring is handled by `vi.restoreAllMocks()` in `afterEach`. + */ +function silenceConsole() { + vitest_1.vi.spyOn(console, 'log').mockImplementation(() => { }); + vitest_1.vi.spyOn(console, 'warn').mockImplementation(() => { }); + vitest_1.vi.spyOn(console, 'error').mockImplementation(() => { }); +} diff --git a/dist/tray.js b/dist/tray.js new file mode 100644 index 0000000..429e20a --- /dev/null +++ b/dist/tray.js @@ -0,0 +1,79 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createTray = createTray; +exports.updateTrayAgentCount = updateTrayAgentCount; +const electron_1 = require("electron"); +const path = __importStar(require("path")); +const utils_1 = require("./utils"); +// Keep tray as a global variable to prevent it from being garbage collected. +let tray = null; +let contextMenu = null; +/** + * Creates a system tray icon with a context menu to focus a window or quit the app. + * + * For macOS it uses a template image to automatically handle light/dark mode. + * Other platforms use the normal app icon. + */ +function createTray(actions) { + // On macOS use a template image (auto-inverts for dark/light menu bar). + // Otherwise use a full-color icon since template images are unsupported + // and a solid-black glyph can be invisible on dark panels. + const iconFile = (0, utils_1.isMacOS)() ? 'trayTemplate.png' : 'icon.png'; + const icon = electron_1.nativeImage.createFromPath(path.join(__dirname, '..', iconFile)); + if ((0, utils_1.isMacOS)()) { + icon.setTemplateImage(true); + } + tray = new electron_1.Tray(icon); + tray.setToolTip(electron_1.app.getName()); + contextMenu = electron_1.Menu.buildFromTemplate(actions); + tray.setContextMenu(contextMenu); +} +/** + * Updates the active agents count in the tray menu. + */ +function updateTrayAgentCount(count) { + if (tray && contextMenu) { + const countItem = contextMenu.items.find((item) => item.id === 'running-agents'); + if (countItem) { + countItem.label = + (count > 0 ? `${count}` : 'No') + + ' agent' + + (count === 1 ? '' : 's') + + ' running'; + tray.setContextMenu(contextMenu); + } + } +} diff --git a/dist/tray.test.js b/dist/tray.test.js new file mode 100644 index 0000000..6c702d7 --- /dev/null +++ b/dist/tray.test.js @@ -0,0 +1,87 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +vitest_1.vi.mock('electron'); +(0, vitest_1.describe)('tray', () => { + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); + vitest_1.vi.resetModules(); + }); + (0, vitest_1.it)('should create a tray with a context menu', async () => { + const { Tray, Menu, nativeImage } = await Promise.resolve().then(() => __importStar(require('electron'))); + const { createTray } = await Promise.resolve().then(() => __importStar(require('./tray'))); + createTray([ + { label: 'Open App', click: vitest_1.vi.fn() }, + { type: 'separator' }, + { label: 'Quit', click: vitest_1.vi.fn() }, + ]); + const trayInstance = vitest_1.vi.mocked(Tray).mock.results[0].value; + (0, vitest_1.expect)(nativeImage.createFromPath).toHaveBeenCalled(); + (0, vitest_1.expect)(Tray).toHaveBeenCalled(); + (0, vitest_1.expect)(trayInstance.setToolTip).toHaveBeenCalled(); + (0, vitest_1.expect)(Menu.buildFromTemplate).toHaveBeenCalled(); + (0, vitest_1.expect)(trayInstance.setContextMenu).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should call openApp when Open is clicked', async () => { + const { Menu } = await Promise.resolve().then(() => __importStar(require('electron'))); + const { createTray } = await Promise.resolve().then(() => __importStar(require('./tray'))); + const openApp = vitest_1.vi.fn(); + createTray([ + { label: 'Open App', click: openApp }, + { type: 'separator' }, + { label: 'Quit', click: vitest_1.vi.fn() }, + ]); + const menuTemplate = vitest_1.vi.mocked(Menu.buildFromTemplate).mock.calls[0][0]; + const openItem = menuTemplate[0]; + openItem.click(); + (0, vitest_1.expect)(openApp).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should call quitApp when Quit is clicked', async () => { + const { Menu } = await Promise.resolve().then(() => __importStar(require('electron'))); + const { createTray } = await Promise.resolve().then(() => __importStar(require('./tray'))); + const quitApp = vitest_1.vi.fn(); + createTray([ + { label: 'Open App', click: vitest_1.vi.fn() }, + { type: 'separator' }, + { label: 'Quit', click: quitApp }, + ]); + const menuTemplate = vitest_1.vi.mocked(Menu.buildFromTemplate).mock.calls[0][0]; + // Quit is the third item (after separator) + const quitItem = menuTemplate[2]; + quitItem.click(); + (0, vitest_1.expect)(quitApp).toHaveBeenCalled(); + }); +}); diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/updater.js b/dist/updater.js new file mode 100644 index 0000000..5ba8445 --- /dev/null +++ b/dist/updater.js @@ -0,0 +1,241 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateActions = exports.MenuUpdateStep = void 0; +exports.broadcastState = broadcastState; +exports.initAutoUpdater = initAutoUpdater; +exports.checkForUpdates = checkForUpdates; +exports.quitAndInstall = quitAndInstall; +const electron_updater_1 = require("electron-updater"); +const electron_1 = require("electron"); +const path = __importStar(require("path")); +const child_process_1 = require("child_process"); +var MenuUpdateStep; +(function (MenuUpdateStep) { + MenuUpdateStep["CheckForUpdates"] = "Check for Updates"; + MenuUpdateStep["CheckingForUpdates"] = "Checking for Updates..."; + MenuUpdateStep["DownloadingUpdate"] = "Downloading Update..."; + MenuUpdateStep["RestartToUpdate"] = "Restart to Update"; +})(MenuUpdateStep || (exports.MenuUpdateStep = MenuUpdateStep = {})); +exports.updateActions = { + [MenuUpdateStep.CheckForUpdates]: () => checkForUpdates(true), + [MenuUpdateStep.CheckingForUpdates]: undefined, + [MenuUpdateStep.DownloadingUpdate]: undefined, + [MenuUpdateStep.RestartToUpdate]: () => quitAndInstall(), +}; +// True if the last call to check for updates was from a user click in the menu. +let isManualCheck = false; +// How long to wait after app start before first update check (ms) +const INITIAL_CHECK_DELAY_MS = 10000; // 10 seconds +// How often to re-check for updates after the initial check (ms) +const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +/** Broadcast a state change to every open BrowserWindow. */ +function broadcastState(state) { + for (const win of electron_1.BrowserWindow.getAllWindows()) { + win.webContents.send('updater:state-changed', state); + } +} +/** + * Updates the state of the menu item based on the current step of the updater. + */ +function updateMenuState(step) { + const menu = electron_1.Menu.getApplicationMenu(); + if (menu) { + const item = menu.getMenuItemById('check-for-updates'); + if (item) { + item.label = step; + item.enabled = exports.updateActions[step] !== undefined; + } + } +} +/** + * Initializes the auto-updater and registers IPC handlers. + * Call once after the first window is created. + * + * The updater will: + * 1. Wait INITIAL_CHECK_DELAY_MS ms, then check for updates. + * 2. Re-check every CHECK_INTERVAL_MS ms. + * 3. Download updates automatically in the background. + * 4. Broadcast state to the renderer so AppUpdateButton can display progress. + */ +function initAutoUpdater(isHeadless) { + // In dev mode (npm start), electron-updater skips checks because the app + // isn't packaged. Force it to use the dev config file instead. + if (!electron_1.app.isPackaged) { + electron_updater_1.autoUpdater.forceDevUpdateConfig = true; + electron_updater_1.autoUpdater.updateConfigPath = path.join(electron_1.app.getAppPath(), 'dev-app-update.yml'); + } + // Set the channel based on architecture and OS. + // On Windows, we need to explicitly append '-win' to match the artifact name. + // On macOS and linux, Electron automatically appends the OS to the channel name. + if (process.platform === 'win32') { + electron_updater_1.autoUpdater.channel = `latest-${process.arch}-win`; + } + else { + electron_updater_1.autoUpdater.channel = `latest-${process.arch}`; + } + electron_updater_1.autoUpdater.autoDownload = true; + electron_updater_1.autoUpdater.autoInstallOnAppQuit = electron_1.app.isPackaged; + // Auto-updater event handlers โ†’ broadcast to renderer + electron_updater_1.autoUpdater.on('checking-for-update', () => { + console.log('[AutoUpdater] Checking for updateโ€ฆ'); + broadcastState({ type: 'checking for updates' }); + updateMenuState(MenuUpdateStep.CheckingForUpdates); + }); + electron_updater_1.autoUpdater.on('update-available', (info) => { + console.log(`[AutoUpdater] Update available: ${info.version}`); + broadcastState({ + type: 'available for download', + update: { version: info.version }, + }); + updateMenuState(MenuUpdateStep.DownloadingUpdate); + isManualCheck = false; + }); + electron_updater_1.autoUpdater.on('update-not-available', (info) => { + console.log(`[AutoUpdater] Up to date (${info.version})`); + broadcastState({ type: 'idle' }); + updateMenuState(MenuUpdateStep.CheckForUpdates); + if (isManualCheck && !isHeadless) { + const win = electron_1.BrowserWindow.getFocusedWindow(); + const options = { + type: 'info', + title: 'Check for Updates', + message: 'No updates available', + buttons: ['OK'], + }; + if (win) { + electron_1.dialog.showMessageBox(win, options); + } + else { + electron_1.dialog.showMessageBox(options); + } + } + isManualCheck = false; + }); + electron_updater_1.autoUpdater.on('download-progress', () => { + broadcastState({ type: 'downloading' }); + updateMenuState(MenuUpdateStep.DownloadingUpdate); + }); + electron_updater_1.autoUpdater.on('update-downloaded', (info) => { + console.log(`[AutoUpdater] Update downloaded: ${info.version}`); + if (isHeadless) { + // Proceed to auto install in headless mode + if (electron_1.app.isPackaged) { + if (process.platform === 'linux') { + const downloadedFilePath = info.downloadedFile; + headlessQuitAndInstall(downloadedFilePath); + } + else { + electron_updater_1.autoUpdater.quitAndInstall(); + } + } + else { + console.log('[AutoUpdater] Headless mode: Skipping quitAndInstall (not packaged).'); + } + return; + } + broadcastState({ + type: 'ready', + update: { version: info.version }, + }); + updateMenuState(MenuUpdateStep.RestartToUpdate); + }); + electron_updater_1.autoUpdater.on('error', (err) => { + console.error('[AutoUpdater] Error:', err.message); + broadcastState({ type: 'idle' }); + updateMenuState(MenuUpdateStep.CheckForUpdates); + isManualCheck = false; + }); + // Schedule periodic checks + setTimeout(() => { + checkForUpdates(); + setInterval(checkForUpdates, CHECK_INTERVAL_MS); + }, INITIAL_CHECK_DELAY_MS); +} +function checkForUpdates(isManual = false) { + isManualCheck = isManual; + electron_updater_1.autoUpdater.checkForUpdates().catch((err) => { + console.error('[AutoUpdater] Failed to check for updates:', err.message); + }); +} +function quitAndInstall() { + electron_updater_1.autoUpdater.quitAndInstall(); +} +/** + * Electron native quitAndInstall doesn't relaunch the app with command line arguments. + * This function waits for the app process to quit, manually replaces the executable with + * the downloaded update, and then relaunches it with the right headless flags. + */ +function headlessQuitAndInstall(downloadedFilePath) { + console.log('[AutoUpdater] Headless mode: Scheduling post-quit restart.'); + try { + const currentPid = process.pid; + const appPath = process.env.APPIMAGE || process.execPath; + const args = [ + '--ozone-platform=headless', + '--headless', + '--disable-gpu', + '--no-sandbox', + ]; + let script = ''; + if (downloadedFilePath) { + console.log(`[AutoUpdater] Will manually replace ${appPath} with ${downloadedFilePath}`); + script = ` + while kill -0 ${currentPid} 2>/dev/null; do sleep 0.5; done + cp -f "${downloadedFilePath}" "${appPath}" + chmod +x "${appPath}" + "${appPath}" ${args.join(' ')} + `; + } + else { + console.warn('[AutoUpdater] No downloaded file path found, relaunching without update.'); + script = ` + while kill -0 ${currentPid} 2>/dev/null; do sleep 0.5; done + sleep 3 + "${appPath}" ${args.join(' ')} + `; + } + const child = (0, child_process_1.spawn)('sh', ['-c', script], { + detached: true, + stdio: 'ignore', + env: { ...process.env, ELECTRON_OZONE_PLATFORM_HINT: 'headless' }, + }); + child.unref(); + } + catch (e) { + console.error('[AutoUpdater] Failed to schedule restart:', e); + } + electron_1.app.quit(); +} diff --git a/dist/updater.test.js b/dist/updater.test.js new file mode 100644 index 0000000..450712e --- /dev/null +++ b/dist/updater.test.js @@ -0,0 +1,91 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const updater_1 = require("./updater"); +const electron_updater_1 = require("electron-updater"); +const electron_1 = require("electron"); +const child_process_1 = require("child_process"); +const electron_updater_2 = require("./__mocks__/electron-updater"); +// Use shared auto-mocks from __mocks__/ +vitest_1.vi.mock('electron'); +vitest_1.vi.mock('electron-updater'); +vitest_1.vi.mock('child_process', () => ({ + spawn: vitest_1.vi.fn(() => ({ + unref: vitest_1.vi.fn(), + })), +})); +(0, vitest_1.describe)('updater', () => { + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); // Clear mock history before each test + vitest_1.vi.useFakeTimers(); + }); + (0, vitest_1.it)('should register IPC handlers and updater events on init', () => { + (0, updater_1.initAutoUpdater)(false); + // Verify autoUpdater events were registered + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('checking-for-update', vitest_1.expect.any(Function)); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('update-available', vitest_1.expect.any(Function)); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('update-not-available', vitest_1.expect.any(Function)); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('download-progress', vitest_1.expect.any(Function)); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('update-downloaded', vitest_1.expect.any(Function)); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.on).toHaveBeenCalledWith('error', vitest_1.expect.any(Function)); + }); + (0, vitest_1.it)('should schedule an update check', () => { + (0, updater_1.initAutoUpdater)(false); + // Fast-forward the 10-second delay + vitest_1.vi.advanceTimersByTime(10000); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should auto-install on update-downloaded in headless mode (packaged)', () => { + (0, updater_1.initAutoUpdater)(true); + const callback = electron_updater_2.autoUpdaterEvents['update-downloaded']; + (0, vitest_1.expect)(callback).toBeDefined(); + callback({ version: '1.2.3' }); + if (process.platform === 'linux') { + (0, vitest_1.expect)(child_process_1.spawn).toHaveBeenCalled(); + (0, vitest_1.expect)(electron_1.app.quit).toHaveBeenCalled(); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + } + else { + (0, vitest_1.expect)(electron_updater_1.autoUpdater.quitAndInstall).toHaveBeenCalled(); + } + (0, vitest_1.expect)(electron_1.BrowserWindow.getAllWindows).not.toHaveBeenCalled(); + }); + (0, vitest_1.it)('should prompt the user on update-downloaded in normal mode (packaged)', () => { + (0, updater_1.initAutoUpdater)(false); + const callback = electron_updater_2.autoUpdaterEvents['update-downloaded']; + (0, vitest_1.expect)(callback).toBeDefined(); + callback({ version: '1.2.3' }); + (0, vitest_1.expect)(electron_updater_1.autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + const win = vitest_1.vi.mocked(electron_1.BrowserWindow.getAllWindows).mock.results[0].value[0]; + (0, vitest_1.expect)(win.webContents.send).toHaveBeenCalledWith('updater:state-changed', { + type: 'ready', + update: { version: '1.2.3' }, + }); + }); + (0, vitest_1.it)('should show modal on update-not-available if manual check', () => { + (0, updater_1.initAutoUpdater)(false); + (0, updater_1.checkForUpdates)(true); + const callback = electron_updater_2.autoUpdaterEvents['update-not-available']; + (0, vitest_1.expect)(callback).toBeDefined(); + callback({ version: '1.0.0' }); + (0, vitest_1.expect)(electron_1.dialog.showMessageBox).toHaveBeenCalledWith(vitest_1.expect.anything(), vitest_1.expect.objectContaining({ + message: 'No updates available', + })); + }); + (0, vitest_1.it)('should NOT show modal on update-not-available if periodic check', () => { + (0, updater_1.initAutoUpdater)(false); + (0, updater_1.checkForUpdates)(false); + const callback = electron_updater_2.autoUpdaterEvents['update-not-available']; + (0, vitest_1.expect)(callback).toBeDefined(); + callback({ version: '1.0.0' }); + (0, vitest_1.expect)(electron_1.dialog.showMessageBox).not.toHaveBeenCalled(); + }); + (0, vitest_1.it)('should NOT show modal on update-not-available if manual check in headless mode', () => { + (0, updater_1.initAutoUpdater)(true); + (0, updater_1.checkForUpdates)(true); + const callback = electron_updater_2.autoUpdaterEvents['update-not-available']; + (0, vitest_1.expect)(callback).toBeDefined(); + callback({ version: '1.0.0' }); + (0, vitest_1.expect)(electron_1.dialog.showMessageBox).not.toHaveBeenCalled(); + }); +}); diff --git a/dist/utils.js b/dist/utils.js new file mode 100644 index 0000000..6f84592 --- /dev/null +++ b/dist/utils.js @@ -0,0 +1,269 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SleepBlocker = exports.showOrCreateWindow = exports.showQuitConfirmation = void 0; +exports.setShowQuitConfirmation = setShowQuitConfirmation; +exports.isMacOS = isMacOS; +exports.createWindow = createWindow; +exports.getNodeWrapperPaths = getNodeWrapperPaths; +exports.setupNodeWrapper = setupNodeWrapper; +const electron_1 = require("electron"); +const constants_1 = require("./constants"); +const keybindings_1 = require("./keybindings"); +const path_1 = __importDefault(require("path")); +const fs = __importStar(require("fs")); +const paths_1 = require("./paths"); +const loadingOverlay_1 = require("./loadingOverlay"); +exports.showQuitConfirmation = false; +function setShowQuitConfirmation(value) { + exports.showQuitConfirmation = value; +} +function isMacOS() { + return process.platform === 'darwin'; +} +/** + * Reads the user's theme preference from the settings file. + */ +function getThemeMode() { + try { + const filePath = (0, paths_1.getSettingsPbPath)(); + if (!fs.existsSync(filePath)) { + return 'DARK'; + } + const content = fs.readFileSync(filePath, 'utf-8'); + const config = JSON.parse(content); + const themeMode = config?.userSettings?.themeMode; + if (themeMode && themeMode.includes('INHERIT')) { + return electron_1.nativeTheme.shouldUseDarkColors ? 'DARK' : 'LIGHT'; + } + if (themeMode && themeMode.includes('LIGHT')) { + return 'LIGHT'; + } + return 'DARK'; + } + catch (e) { + console.error('Error reading theme mode:', e); + return 'DARK'; + } +} +/** + * Ensures the app is visible in the dock for MacOS with the icon set. + * When refocusing the app after being hidden in the dock, the icon is sometimes lost. + * This ensures the icon is always visible. + */ +function ensureAppIsInDock() { + void electron_1.app.dock?.show(); + if (isMacOS() && electron_1.app.dock) { + const iconPath = path_1.default.join(__dirname, '..', 'icon.png'); + electron_1.app.dock.setIcon(electron_1.nativeImage.createFromPath(iconPath)); + } +} +// --------------------------------------------------------------------------- +// Window Management +// --------------------------------------------------------------------------- +/** + * Creates and returns a new BrowserWindow pointed at `url`. + * Uses a hidden title bar with native traffic lights on macOS. + * Node integration is disabled and context isolation is enabled for security. + */ +function createWindow(url) { + ensureAppIsInDock(); + const theme = getThemeMode().toUpperCase(); + const isLight = theme.includes('LIGHT'); + const backgroundColor = isLight ? '#FAFAFA' : '#131313'; + const foregroundColor = isLight ? '#383A42' : '#FAFAFA'; + const win = new electron_1.BrowserWindow({ + width: 1400, + height: 900, + title: electron_1.app.getName(), + icon: path_1.default.join(__dirname, '..', 'icon.png'), + titleBarStyle: 'hidden', + titleBarOverlay: isMacOS() + ? false + : { + color: backgroundColor, + symbolColor: foregroundColor, + height: 30, + }, + backgroundColor, + trafficLightPosition: { x: 12, y: 12 }, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path_1.default.join(__dirname, 'preload.js'), + }, + }); + win.webContents.setWindowOpenHandler((details) => { + void electron_1.shell.openExternal(details.url); + return { action: 'deny' }; + }); + (0, loadingOverlay_1.attachLoadingOverlay)(win, foregroundColor, backgroundColor); + (0, keybindings_1.registerKeybindings)(win, { + createNewWindow: () => { + void createWindow(url); + }, + onQuitRequested: () => { + exports.showQuitConfirmation = true; + electron_1.app.quit(); + }, + }); + void win.loadURL(url); + return win; +} +/** + * Focuses a window if it exists, or creates a new one. + */ +const showOrCreateWindow = (port) => { + const wins = electron_1.BrowserWindow.getAllWindows(); + if (wins.length > 0) { + wins[0].show(); + wins[0].focus(); + } + else { + createWindow(`${constants_1.WINDOW_ORIGIN}:${port}/`); + } +}; +exports.showOrCreateWindow = showOrCreateWindow; +/** + * Manages the power save blocker to keep the computer awake. + */ +class SleepBlocker { + constructor() { + this.currentBlockerId = null; + } + static getInstance() { + if (!SleepBlocker.instance) { + SleepBlocker.instance = new SleepBlocker(); + } + return SleepBlocker.instance; + } + shouldKeepComputerAwake(keep) { + if (keep) { + if (this.currentBlockerId === null) { + this.currentBlockerId = electron_1.powerSaveBlocker.start('prevent-display-sleep'); + console.log('Power save blocker started:', this.currentBlockerId); + } + } + else { + if (this.currentBlockerId !== null) { + electron_1.powerSaveBlocker.stop(this.currentBlockerId); + console.log('Power save blocker stopped:', this.currentBlockerId); + this.currentBlockerId = null; + } + } + } +} +exports.SleepBlocker = SleepBlocker; +function getNodeWrapperPaths(envPath, os, isPackaged, userDataPath, baseDir) { + const delimiter = os === 'win32' ? ';' : ':'; + if (!isPackaged) { + const devBinPath = path_1.default.join(baseDir, '..', 'node_modules', '.bin'); + return { + newEnvPath: `${devBinPath}${delimiter}${envPath || ''}`, + nodeWrapperPath: undefined, + binPath: undefined, + }; + } + const binPath = path_1.default.join(userDataPath, 'bin'); + const nodeWrapperPath = path_1.default.join(binPath, os === 'win32' ? 'agy-node.cmd' : 'agy-node'); + return { + newEnvPath: `${binPath}${delimiter}${envPath || ''}`, + nodeWrapperPath, + binPath, + }; +} +/** + * Sets up a wrapper script for Node.js that runs Electron as Node. + * This allows running standard Node scripts using the Electron binary. + */ +function setupNodeWrapper(env) { + const userDataPath = electron_1.app.isPackaged ? electron_1.app.getPath('userData') : ''; + // Windows environment variables are case-insensitive, but when copying process.env + // into a plain object, we might get 'Path' instead of 'PATH'. We need to find + // the actual key used to avoid creating case-duplicate keys (e.g. 'Path' and 'PATH') + // which can confuse child_process.spawn on Windows. + const isWindows = process.platform === 'win32'; + const pathKey = isWindows + ? Object.keys(env).find((k) => k.toUpperCase() === 'PATH') || 'PATH' + : 'PATH'; + const { newEnvPath, nodeWrapperPath, binPath } = getNodeWrapperPaths(env[pathKey], process.platform, electron_1.app.isPackaged, userDataPath, __dirname); + env[pathKey] = newEnvPath; + // In non-packaged dev mode, we don't create a wrapper and it'll just use machine node + if (!nodeWrapperPath || !binPath) { + return; + } + if (!fs.existsSync(binPath)) { + fs.mkdirSync(binPath, { recursive: true }); + } + let nodeWrapperContent = ''; + switch (process.platform) { + case 'win32': + nodeWrapperContent = `@echo off\nset ELECTRON_RUN_AS_NODE=1\n"${process.execPath}" %*\n`; + break; + case 'darwin': { + // Use the Helper app instead of the main executable to prevent macOS + // from bouncing a new Dock icon when this script is executed. The Helper + // has LSUIElement=true in its Info.plist, running it invisibly. + const appName = path_1.default.basename(process.execPath); + let electronBinary = process.execPath; + const helperPath = path_1.default.join(path_1.default.dirname(process.execPath), '..', 'Frameworks', `${appName} Helper.app`, 'Contents', 'MacOS', `${appName} Helper`); + if (fs.existsSync(helperPath)) { + electronBinary = helperPath; + } + nodeWrapperContent = `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${electronBinary}" "$@"\n`; + break; + } + default: // linux, etc. + nodeWrapperContent = `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${process.execPath}" "$@"\n`; + break; + } + try { + const existingContent = fs.existsSync(nodeWrapperPath) + ? fs.readFileSync(nodeWrapperPath, 'utf-8') + : ''; + if (existingContent !== nodeWrapperContent) { + fs.writeFileSync(nodeWrapperPath, nodeWrapperContent); + if (process.platform !== 'win32') { + fs.chmodSync(nodeWrapperPath, 0o755); + } + } + } + catch (err) { + console.error(`Failed to create node wrapper: ${err}`); + } +} diff --git a/dist/utils.test.js b/dist/utils.test.js new file mode 100644 index 0000000..826f095 --- /dev/null +++ b/dist/utils.test.js @@ -0,0 +1,73 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const vitest_1 = require("vitest"); +const helpers_1 = require("./test/helpers"); +vitest_1.vi.mock('electron'); +vitest_1.vi.mock('path', () => { + const join = vitest_1.vi.fn((...args) => args.join('/')); + return { + join, + default: { + join, + }, + }; +}); +(0, vitest_1.describe)('utils', () => { + (0, vitest_1.beforeEach)(() => { + vitest_1.vi.clearAllMocks(); + vitest_1.vi.useFakeTimers(); + (0, helpers_1.silenceConsole)(); + }); + (0, vitest_1.afterEach)(() => { + vitest_1.vi.restoreAllMocks(); + }); + (0, vitest_1.describe)('createWindow', () => { + (0, vitest_1.it)('should ensure dock is initialized when a window is created', async () => { + vitest_1.vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + const { app } = await Promise.resolve().then(() => __importStar(require('electron'))); + const { createWindow } = await Promise.resolve().then(() => __importStar(require('./utils'))); + const dockShowSpy = vitest_1.vi.spyOn(vitest_1.vi.mocked(app).dock, 'show'); + createWindow('http://localhost:3000/'); + (0, vitest_1.expect)(dockShowSpy).toHaveBeenCalled(); + (0, vitest_1.expect)(vitest_1.vi.mocked(app).dock?.setIcon).toHaveBeenCalled(); + }); + (0, vitest_1.it)('should register before-input-event listener for keybindings', async () => { + const { createWindow } = await Promise.resolve().then(() => __importStar(require('./utils'))); + const win = createWindow('http://localhost:3000/'); + (0, vitest_1.expect)(win.webContents.on).toHaveBeenCalledWith('before-input-event', vitest_1.expect.any(Function)); + }); + }); +}); diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..e02be84 Binary files /dev/null and b/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..4c11c2e --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "ag-x", + "productName": "AG X", + "version": "2.0.3", + "description": "AG X - Agentic Desktop Application", + "homepage": "https://ag-x.dev", + "author": { + "name": "AG X", + "email": "support@ag-x.dev" + }, + "main": "dist/main.js", + "dependencies": { + "chrome-devtools-mcp": "^0.23.0", + "electron-log": "^5.4.3", + "electron-updater": "^6.8.3", + "shell-env": "^4.0.3" + } +} \ No newline at end of file diff --git a/trayTemplate.png b/trayTemplate.png new file mode 100644 index 0000000..41f0a1d Binary files /dev/null and b/trayTemplate.png differ diff --git a/trayTemplate@2x.png b/trayTemplate@2x.png new file mode 100644 index 0000000..7bad380 Binary files /dev/null and b/trayTemplate@2x.png differ