From 43e2a2f78f1c34a393a287b2ec5be51475c98fb5 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 22 May 2026 23:20:10 +0400 Subject: [PATCH] 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 --- .gitignore | 6 + README.md | 43 + dist/__mocks__/electron-updater.js | 25 + dist/__mocks__/electron.js | 145 +++ dist/constants.js | 8 + dist/customScheme.js | 55 ++ dist/ideInstall/constants.js | 131 +++ dist/ideInstall/index.js | 29 + dist/ideInstall/service.js | 197 ++++ dist/ideInstall/wizard.js | 155 ++++ dist/ideInstall/wizardHtml.js | 286 ++++++ dist/ideInstall/wizardPreload.js | 23 + dist/ideInstallService.test.js | 350 ++++++++ dist/ipcHandlers.js | 208 +++++ dist/ipcHandlers.test.js | 88 ++ dist/keybindings.js | 19 + dist/languageServer.js | 509 +++++++++++ dist/languageServer.test.js | 81 ++ dist/loadingOverlay.js | 100 +++ dist/main.js | 587 ++++++++++++ dist/main.test.js | 200 +++++ dist/menu.js | 81 ++ dist/paths.js | 49 + dist/preload.js | 371 ++++++++ dist/provider/settings.html | 696 +++++++++++++++ dist/provider/welcome.html | 176 ++++ dist/providerSettings.js | 169 ++++ dist/services/apiProxy.js | 116 +++ dist/services/providerService.js | 358 ++++++++ dist/services/settingsService.js | 49 + dist/services/settingsService.test.js | 65 ++ dist/services/translationProxy.js | 1191 +++++++++++++++++++++++++ dist/storage.js | 128 +++ dist/storage.test.js | 142 +++ dist/test/helpers.js | 23 + dist/tray.js | 79 ++ dist/tray.test.js | 87 ++ dist/types.js | 2 + dist/updater.js | 241 +++++ dist/updater.test.js | 91 ++ dist/utils.js | 269 ++++++ dist/utils.test.js | 73 ++ icon.png | Bin 0 -> 48633 bytes package.json | 18 + trayTemplate.png | Bin 0 -> 355 bytes trayTemplate@2x.png | Bin 0 -> 651 bytes 46 files changed, 7719 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dist/__mocks__/electron-updater.js create mode 100644 dist/__mocks__/electron.js create mode 100644 dist/constants.js create mode 100644 dist/customScheme.js create mode 100644 dist/ideInstall/constants.js create mode 100644 dist/ideInstall/index.js create mode 100644 dist/ideInstall/service.js create mode 100644 dist/ideInstall/wizard.js create mode 100644 dist/ideInstall/wizardHtml.js create mode 100644 dist/ideInstall/wizardPreload.js create mode 100644 dist/ideInstallService.test.js create mode 100644 dist/ipcHandlers.js create mode 100644 dist/ipcHandlers.test.js create mode 100644 dist/keybindings.js create mode 100644 dist/languageServer.js create mode 100644 dist/languageServer.test.js create mode 100644 dist/loadingOverlay.js create mode 100644 dist/main.js create mode 100644 dist/main.test.js create mode 100644 dist/menu.js create mode 100644 dist/paths.js create mode 100644 dist/preload.js create mode 100644 dist/provider/settings.html create mode 100644 dist/provider/welcome.html create mode 100644 dist/providerSettings.js create mode 100644 dist/services/apiProxy.js create mode 100644 dist/services/providerService.js create mode 100644 dist/services/settingsService.js create mode 100644 dist/services/settingsService.test.js create mode 100644 dist/services/translationProxy.js create mode 100644 dist/storage.js create mode 100644 dist/storage.test.js create mode 100644 dist/test/helpers.js create mode 100644 dist/tray.js create mode 100644 dist/tray.test.js create mode 100644 dist/types.js create mode 100644 dist/updater.js create mode 100644 dist/updater.test.js create mode 100644 dist/utils.js create mode 100644 dist/utils.test.js create mode 100644 icon.png create mode 100644 package.json create mode 100644 trayTemplate.png create mode 100644 trayTemplate@2x.png 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 0000000000000000000000000000000000000000..e02be845441cc3e49d879d6b4ac76d723e3abc3e GIT binary patch literal 48633 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 literal 0 HcmV?d00001