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
This commit is contained in:
admin
2026-05-22 23:20:10 +04:00
Unverified
commit 43e2a2f78f
46 changed files with 7719 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
*.log
.DS_Store
Thumbs.db
*.map
releases/*.deb

43
README.md Normal file
View File

@@ -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

25
dist/__mocks__/electron-updater.js vendored Normal file
View File

@@ -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(),
};

145
dist/__mocks__/electron.js vendored Normal file
View File

@@ -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(),
};

8
dist/constants.js vendored Normal file
View File

@@ -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=';

55
dist/customScheme.js vendored Normal file
View File

@@ -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:<port>)
// The authority is usually a hash of unique extension identifiers
// like extension ID + port + project ID. An extension running on localhost:<port>
// is then exposed on plugin://<authority>.
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 });
}
});
}

131
dist/ideInstall/constants.js vendored Normal file
View File

@@ -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;
}

29
dist/ideInstall/index.js vendored Normal file
View File

@@ -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; } });

197
dist/ideInstall/service.js vendored Normal file
View File

@@ -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 */
}
}

155
dist/ideInstall/wizard.js vendored Normal file
View File

@@ -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;
}

286
dist/ideInstall/wizardHtml.js vendored Normal file
View File

@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to AG X</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #000000;
--bg-secondary: #1A1A1A;
--bg-tertiary: #242424;
--bg-hover: #2A2A2A;
--text-primary: #F5F5F5;
--text-secondary: #A0A0A0;
--text-muted: #666;
--accent: #2F80ED;
--accent-hover: #2D74D7;
--border: #2A2A2A;
--radius: 12px;
--radius-sm: 8px;
--transition: 200ms ease;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
-webkit-app-region: drag;
-webkit-user-select: none;
user-select: none;
}
/* Traffic-light spacer for macOS */
.titlebar-spacer {
height: 38px;
flex-shrink: 0;
}
.container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 68px 68px;
-webkit-app-region: no-drag;
}
/* --- Step screens --- */
.step {
display: none;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 480px;
width: 100%;
animation: fadeIn 0.4s ease;
}
.step.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Icon */
.icon-wrapper {
width: 80px;
height: 80px;
margin-bottom: 32px;
}
.icon-wrapper img {
width: 100%;
height: 100%;
border-radius: 18px;
}
h1 {
font-size: 19px;
font-weight: 700;
line-height: 1.3;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
p {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 36px;
}
/* Loader styling */
.loader {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.loader div {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent);
opacity: 0.3;
animation: dot-pulse 1.5s infinite ease-in-out;
}
.loader div:nth-child(1) { animation-delay: 0s; }
.loader div:nth-child(2) { animation-delay: 0.3s; }
.loader div:nth-child(3) { animation-delay: 0.6s; }
@keyframes dot-pulse {
0%, 100% { opacity: 0.2; transform: scale(0.9); }
50% { opacity: 0.7; transform: scale(1.1); }
}
/* Checkbox styling */
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
color: var(--text-secondary);
transition: color var(--transition);
margin-bottom: 18px;
-webkit-app-region: no-drag;
}
.checkbox-label:hover {
color: var(--text-primary);
}
.checkbox-label input {
display: none;
}
.custom-checkbox {
width: 18px;
height: 18px;
border: 2px solid #333;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
background: var(--bg-secondary);
}
.checkbox-label:hover .custom-checkbox {
border-color: var(--accent);
}
.checkbox-label input:checked + .custom-checkbox {
background: var(--accent);
border-color: var(--accent);
}
.custom-checkbox::after {
content: '';
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
display: none;
}
.checkbox-label input:checked + .custom-checkbox::after {
display: block;
}
/* Buttons */
.button-group {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
}
button {
font-family: inherit;
font-size: 14px;
font-weight: 500;
padding: 13px 24px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all var(--transition);
-webkit-app-region: no-drag;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
</style>
</head>
<body>
<div class="titlebar-spacer"></div>
<div class="container">
<!-- Step 0: Setting up -->
<div id="step-setup" class="step active">
<div class="loader">
<div></div><div></div><div></div>
</div>
<div class="text" style="font-size: 13px; opacity: 0.6; letter-spacing: 0.03em;">Setting up…</div>
</div>
<!-- Step 1: Welcome -->
<div id="step-ask" class="step">
<div class="icon-wrapper">
<img src="data:image/png;base64,${iconBase64}" alt="AG X Icon">
</div>
<h1>Welcome to the new AG X!</h1>
<p>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 <b>AG X IDE</b>.</p>
<label class="checkbox-label">
<input type="checkbox" id="chk-download" checked>
<span class="custom-checkbox"></span>
<span>Download the AG X IDE</span>
</label>
<div class="button-group">
<button class="btn-primary" id="btn-skip">Explore the new AG X</button>
</div>
</div>
</div>
<script>
function showStep(stepId) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.getElementById(stepId).classList.add('active');
}
document.getElementById('btn-skip').addEventListener('click', async () => {
const chk = document.getElementById('chk-download');
const shouldDownload = chk ? chk.checked : false;
await window.wizardAPI.completeWizard(shouldDownload);
});
window.wizardAPI.onSetupComplete(() => {
showStep('step-ask');
});
</script>
</body>
</html>`;
}

23
dist/ideInstall/wizardPreload.js vendored Normal file
View File

@@ -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);

350
dist/ideInstallService.test.js vendored Normal file
View File

@@ -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();
});
});
});

208
dist/ipcHandlers.js vendored Normal file
View File

@@ -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);
}
});
}

88
dist/ipcHandlers.test.js vendored Normal file
View File

@@ -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 });
});
});
});

19
dist/keybindings.js vendored Normal file
View File

@@ -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();
}
}
});
}

509
dist/languageServer.js vendored Normal file
View File

@@ -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 <proto> port at <N> 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
}
});
}

81
dist/languageServer.test.js vendored Normal file
View File

@@ -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('');
});
});

100
dist/loadingOverlay.js vendored Normal file
View File

@@ -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 `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
background: ${backgroundColor};
color: ${foregroundColor};
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
-webkit-app-region: drag;
-webkit-user-select: none;
}
.loader {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.loader div {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${foregroundColor};
opacity: 0.3;
animation: dot-pulse 1.5s infinite ease-in-out;
}
.loader div:nth-child(1) { animation-delay: 0s; }
.loader div:nth-child(2) { animation-delay: 0.3s; }
.loader div:nth-child(3) { animation-delay: 0.6s; }
.text {
font-size: 13px;
font-weight: 400;
letter-spacing: 0.03em;
opacity: 0.6;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.2; transform: scale(0.9); }
50% { opacity: 0.7; transform: scale(1.1); }
}
</style>
</head>
<body>
<div class="loader">
<div></div><div></div><div></div>
</div>
<div class="text">Loading AG X</div>
</body>
</html>
`;
}
/**
* 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);
});
}

587
dist/main.js vendored Normal file
View File

@@ -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);
}
});

200
dist/main.test.js vendored Normal file
View File

@@ -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}/`);
});
});

81
dist/menu.js vendored Normal file
View File

@@ -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);
}

49
dist/paths.js vendored Normal file
View File

@@ -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');

371
dist/preload.js vendored Normal file
View File

@@ -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 = `<span id="codex-ai-indicator"></span><span id="codex-ai-btn-text">${data.active}</span><i id="codex-ai-arrow"></i>`;
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 = `
<span>${ep.name}</span>
<span style="font-size: 10px; opacity: 0.5; font-weight: normal;">${ep.backend_type}</span>
`;
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();
});

696
dist/provider/settings.html vendored Normal file
View File

@@ -0,0 +1,696 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Provider Settings</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; color: #e0e0e0;
min-height: 100vh; padding: 0;
}
.header {
background: linear-gradient(135deg, #16213e 0%, #0f3460 100%);
padding: 20px 32px; border-bottom: 1px solid #2a2a4a;
display: flex; align-items: center; justify-content: space-between;
-webkit-app-region: drag;
}
.header h1 { font-size: 20px; font-weight: 600; color: #fff; }
.header .subtitle { font-size: 12px; color: #8892b0; margin-top: 2px; }
.header-badge {
background: #e94560; color: #fff; font-size: 10px;
padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.container { padding: 24px 32px; max-width: 900px; margin: 0 auto; }
/* Category headings */
.category-title {
font-size: 11px; font-weight: 700; color: #8892b0;
text-transform: uppercase; letter-spacing: 1px;
margin: 20px 0 10px 0; padding-bottom: 4px;
border-bottom: 1px solid #2a2a4a;
}
.provider-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px; margin-bottom: 20px;
}
.provider-card {
background: #16213e; border: 2px solid #2a2a4a; border-radius: 12px;
padding: 14px; cursor: pointer; transition: all 0.2s;
position: relative;
}
.provider-card:hover { border-color: #0f3460; transform: translateY(-1px); }
.provider-card.active { border-color: #e94560; background: #1a1a3e; }
.provider-card.active::after {
content: '✓'; position: absolute; top: 10px; right: 12px;
color: #e94560; font-size: 18px; font-weight: bold;
}
.provider-icon { font-size: 26px; margin-bottom: 6px; }
.provider-name { font-size: 13px; font-weight: 600; color: #fff; }
.provider-desc { font-size: 10px; color: #8892b0; margin-top: 3px; line-height: 1.3; }
.section {
background: #16213e; border: 1px solid #2a2a4a; border-radius: 12px;
padding: 24px; margin-bottom: 20px;
}
.section-title {
font-size: 15px; font-weight: 600; color: #fff;
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
}
.section-title .icon { font-size: 18px; }
.form-group { margin-bottom: 16px; }
.form-group label {
display: block; font-size: 12px; font-weight: 500;
color: #8892b0; margin-bottom: 6px; text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input, .form-group select {
width: 100%; padding: 10px 14px; background: #0f1a2e;
border: 1px solid #2a2a4a; border-radius: 8px; color: #e0e0e0;
font-size: 14px; outline: none; transition: border-color 0.2s;
}
.form-group input:focus, .form-group select:focus {
border-color: #e94560;
}
.form-group input::placeholder { color: #555; }
.form-group select option { background: #16213e; color: #e0e0e0; }
.api-key-wrapper { position: relative; }
.api-key-wrapper input { padding-right: 40px; }
.toggle-visibility {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: #8892b0; cursor: pointer;
font-size: 14px;
}
.btn-row { display: flex; gap: 12px; margin-top: 24px; }
.btn {
padding: 10px 24px; border-radius: 8px; font-size: 14px;
font-weight: 600; cursor: pointer; border: none; transition: all 0.2s;
}
.btn-primary { background: #e94560; color: #fff; }
.btn-primary:hover { background: #c73650; }
.btn-secondary { background: #2a2a4a; color: #e0e0e0; }
.btn-secondary:hover { background: #3a3a5a; }
.btn-danger { background: #4a1a1a; color: #e94560; }
.btn-danger:hover { background: #5a2020; }
.status-bar {
background: #0f1a2e; border-top: 1px solid #2a2a4a;
padding: 10px 32px; display: flex; align-items: center;
justify-content: space-between; font-size: 12px; color: #8892b0;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.status-dot.connected { background: #4caf50; }
.status-dot.disconnected { background: #e94560; }
.status-dot.unknown { background: #ffa726; }
.toast {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
background: #4caf50; color: #fff; padding: 10px 24px; border-radius: 8px;
font-size: 14px; font-weight: 500; opacity: 0; transition: opacity 0.3s;
pointer-events: none; z-index: 100;
}
.toast.show { opacity: 1; }
.toast.error { background: #e94560; }
.help-text { font-size: 11px; color: #666; margin-top: 4px; }
.models-container {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
}
.model-chip {
padding: 4px 12px; background: #0f1a2e; border: 1px solid #2a2a4a;
border-radius: 16px; font-size: 12px; color: #8892b0; cursor: pointer;
transition: all 0.2s;
}
.model-chip:hover { border-color: #e94560; color: #e0e0e0; }
.model-chip.selected { background: #e94560; color: #fff; border-color: #e94560; }
.test-result {
margin-top: 12px; padding: 12px; border-radius: 8px;
font-size: 13px; font-family: monospace; display: none;
}
.test-result.success { display: block; background: #1a2e1a; border: 1px solid #2e4a2e; color: #4caf50; }
.test-result.error { display: block; background: #2e1a1a; border: 1px solid #4a2e2e; color: #e94560; }
.tab-bar { display: flex; gap: 0; margin-bottom: 20px; }
.tab {
padding: 8px 20px; background: transparent; border: none;
border-bottom: 2px solid transparent; color: #8892b0;
cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;
}
.tab:hover { color: #e0e0e0; }
.tab.active { color: #e94560; border-bottom-color: #e94560; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 10px; font-weight: 600; margin-left: 6px;
}
.badge-native { background: #1a3a1a; color: #4caf50; }
.badge-proxy { background: #3a3a1a; color: #ffa726; }
.badge-cc { background: #3a1a3a; color: #ce93d8; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>AI Provider Settings</h1>
<div class="subtitle">Choose and configure your AI provider — AG X supports 20+ providers</div>
</div>
<span class="header-badge">v3.7</span>
</div>
<div class="container">
<div class="tab-bar">
<button class="tab active" data-tab="providers">Provider</button>
<button class="tab" data-tab="advanced">Advanced</button>
<button class="tab" data-tab="about">About</button>
</div>
<!-- Providers Tab -->
<div class="tab-content active" id="tab-providers">
<div class="section">
<div class="section-title"><span class="icon"></span> Select AI Provider</div>
<div id="providerGridContainer"></div>
</div>
<div class="section" id="providerConfig">
<div class="section-title"><span class="icon">🔧</span> <span id="configTitle">Provider Configuration</span></div>
<div class="form-group">
<label>API Base URL</label>
<input type="text" id="apiUrl" placeholder="https://api.example.com">
<div class="help-text" id="apiUrlHelp"></div>
</div>
<div class="form-group" id="apiKeyGroup">
<label>API Key</label>
<div class="api-key-wrapper">
<input type="password" id="apiKey" placeholder="Enter your API key">
<button class="toggle-visibility" onclick="toggleKeyVisibility()">👁</button>
</div>
<div class="help-text" id="apiKeyHelp"></div>
</div>
<div class="form-group">
<label>Model</label>
<input type="text" id="modelInput" placeholder="Enter model name or select below">
<div class="models-container" id="modelChips"></div>
</div>
<div class="form-group">
<button class="btn btn-secondary" onclick="testConnection()">Test Connection</button>
<div class="test-result" id="testResult"></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="saveSettings()">Save & Apply</button>
<button class="btn btn-secondary" onclick="resetToDefaults()">Reset Defaults</button>
</div>
</div>
<!-- Advanced Tab -->
<div class="tab-content" id="tab-advanced">
<div class="section">
<div class="section-title"><span class="icon">🛠️</span> Advanced Settings</div>
<div class="form-group">
<label>Proxy Port</label>
<input type="number" id="proxyPort" value="9876" min="1024" max="65535">
<div class="help-text">Port for the local API proxy (default: 9876). Restart required.</div>
</div>
<div class="form-group">
<label>Translate Proxy Path</label>
<div style="padding:10px;background:#0f1a2e;border-radius:8px;font-size:12px;color:#4caf50;">✅ Built-in Node.js translation proxy — no external tools needed</div>
<div class="help-text">Translation proxy is built into AG X for all provider types.</div>
</div>
<div class="form-group">
<label>Command Code Version</label>
<input type="text" id="ccVersion" value="0.26.8">
<div class="help-text">x-command-code-version header value (for Command Code provider only).</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="saveAdvanced()">Save Advanced</button>
</div>
</div>
<!-- About Tab -->
<div class="tab-content" id="tab-about">
<div class="section">
<div class="section-title"><span class="icon"></span> About Provider System</div>
<p style="color: #8892b0; line-height: 1.6; font-size: 13px;">
AG X supports multiple AI providers through a modular provider system.<br><br>
<strong style="color:#e0e0e0;">Provider Types:</strong><br>
<strong>Native</strong> — Direct connection to the provider API (Google Gemini, OpenAI)<br>
<strong>OpenAI-Compatible</strong> — Any provider with a Chat Completions endpoint<br>
<strong>Anthropic</strong> — Claude models via the Messages API<br>
<strong>Command Code</strong> — 20+ models via Command Code's /alpha/generate API<br><br>
<strong style="color:#e0e0e0;">Translation:</strong><br>
Simple providers use a built-in lightweight proxy. Complex providers (Anthropic, Command Code)
uses the <strong>built-in Node.js proxy</strong>
for full Responses API ↔ provider API translation.<br><br>
<strong style="color:#e0e0e0;">Supported Providers:</strong><br>
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.
</p>
</div>
</div>
</div>
<div class="status-bar">
<div>
<span class="status-dot unknown" id="statusDot"></span>
<span id="statusText">Loading provider configuration…</span>
</div>
<div id="backendBadge" style="font-size:11px; color:#555;"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const { ipcRenderer } = require('electron');
// Provider definitions — mirrors providerService.js
const PROVIDERS = {
google_gemini: {
name: 'Google Gemini (OAuth)', icon: '🔮',
desc: 'Built-in Gemini — zero config',
backendType: 'gemini-native', requiresApiKey: false,
apiUrl: 'https://daily-cloudcode-pa.sandbox.googleapis.com',
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',
category: 'Google',
},
openai: {
name: 'OpenAI', icon: '🟢',
desc: 'GPT models via Responses API',
backendType: 'native', requiresApiKey: true, apiKeyHint: 'sk-...',
apiUrl: 'https://api.openai.com/v1',
models: ['gpt-4o','gpt-4o-mini','o1','o1-mini','o3','o3-mini'],
defaultModel: 'gpt-4o',
category: 'Direct',
},
anthropic: {
name: 'Anthropic', icon: '🟣',
desc: 'Claude models via Messages API',
backendType: 'anthropic', requiresApiKey: true, apiKeyHint: 'sk-ant-...',
apiUrl: 'https://api.anthropic.com',
models: ['claude-sonnet-4-20250514','claude-3-5-sonnet-20241022','claude-3-5-haiku-20241022','claude-3-opus-20240229'],
defaultModel: 'claude-sonnet-4-20250514',
category: 'Direct',
},
z_ai: {
name: 'Z.AI Coding', icon: '🅩',
desc: 'GLM & Z models via Z.AI',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'Z.AI Key',
apiUrl: 'https://api.z.ai/api/coding/paas/v4',
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',
category: 'OpenAI-Compatible',
},
opencode_zen: {
name: 'OpenCode Zen', icon: '🧘',
desc: 'Multi-model via OpenCode Zen',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'OpenCode Key',
apiUrl: 'https://opencode.ai/zen/v1',
models: ['glm-5.1','glm-5','kimi-k2.5','kimi-k2.6','minimax-m2.7','minimax-m2.5','deepseek-v4-flash-free','qwen3.6-plus','big-pickle'],
defaultModel: 'glm-5.1',
category: 'OpenAI-Compatible',
},
opencode_go: {
name: 'OpenCode Go', icon: '🚀',
desc: 'Multi-model via OpenCode Go',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'OpenCode Key',
apiUrl: 'https://opencode.ai/zen/go/v1',
models: ['glm-5.1','glm-5','kimi-k2.5','kimi-k2.6','mimo-v2.5','minimax-m2.7','qwen3.6-plus','deepseek-v4-pro','deepseek-v4-flash'],
defaultModel: 'glm-5.1',
category: 'OpenAI-Compatible',
},
opencode_zen_anthropic: {
name: 'OpenCode Zen (Anthropic)', icon: '🧘',
desc: 'Claude models via OpenCode Zen',
backendType: 'anthropic', requiresApiKey: true, apiKeyHint: 'OpenCode Key',
apiUrl: 'https://opencode.ai/zen/v1',
models: ['claude-opus-4-7','claude-opus-4-6','claude-sonnet-4-6','claude-sonnet-4-5','claude-sonnet-4','claude-haiku-4-5'],
defaultModel: 'claude-sonnet-4-6',
category: 'OpenAI-Compatible',
},
opencode_go_anthropic: {
name: 'OpenCode Go (Anthropic)', icon: '🚀',
desc: 'Claude models via OpenCode Go',
backendType: 'anthropic', requiresApiKey: true, apiKeyHint: 'OpenCode Key',
apiUrl: 'https://opencode.ai/zen/go/v1',
models: ['minimax-m2.7','minimax-m2.5'],
defaultModel: 'minimax-m2.7',
category: 'OpenAI-Compatible',
},
crof_ai: {
name: 'Crof.ai', icon: '🌐',
desc: 'Models via Crof.ai',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'Crof.ai Key',
apiUrl: 'https://crof.ai/v1',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
nvidia_nim: {
name: 'NVIDIA NIM', icon: '💚',
desc: 'NVIDIA accelerated inference',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'nvapi-...',
apiUrl: 'https://integrate.api.nvidia.com/v1',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
kilo_ai: {
name: 'Kilo.ai', icon: '⚖️',
desc: 'Multi-provider gateway',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'Kilo.ai Key',
apiUrl: 'https://api.kilo.ai/api/gateway',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
command_code: {
name: 'Command Code', icon: '⌘',
desc: '20+ models via CC API',
backendType: 'command-code', requiresApiKey: true, apiKeyHint: 'CC API Key',
apiUrl: 'https://api.commandcode.ai',
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',
category: 'Command Code',
},
openrouter: {
name: 'OpenRouter', icon: '🔀',
desc: 'Hundreds of models',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'sk-or-...',
apiUrl: 'https://openrouter.ai/api/v1',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
openadapter: {
name: 'OpenAdapter', icon: '🔌',
desc: 'Free/proxy models',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'OA Key',
apiUrl: 'https://api.openadapter.in/v1',
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',
category: 'OpenAI-Compatible',
},
deepseek: {
name: 'DeepSeek', icon: '🔍',
desc: 'DeepSeek models directly',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'DS Key',
apiUrl: 'https://api.deepseek.com/v1',
models: ['deepseek-chat','deepseek-reasoner'],
defaultModel: 'deepseek-chat',
category: 'OpenAI-Compatible',
},
ollama: {
name: 'Ollama (Local)', icon: '🦙',
desc: 'Run models locally',
backendType: 'openai-compat', requiresApiKey: false,
apiUrl: 'http://127.0.0.1:11434',
models: ['llama3.1','llama3','codellama','mistral','mixtral','deepseek-coder','qwen2.5-coder'],
defaultModel: 'llama3.1',
category: 'Local',
},
together: {
name: 'Together AI', icon: '🤝',
desc: 'Open-source models',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'Together Key',
apiUrl: 'https://api.together.xyz/v1',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
groq: {
name: 'Groq', icon: '⚡',
desc: 'Ultra-fast inference',
backendType: 'openai-compat', requiresApiKey: true, apiKeyHint: 'gsk_...',
apiUrl: 'https://api.groq.com/openai/v1',
models: [],
defaultModel: '',
category: 'OpenAI-Compatible',
},
custom: {
name: 'Custom Provider', icon: '⚙️',
desc: 'Any OpenAI-compat endpoint',
backendType: 'openai-compat', requiresApiKey: false,
apiUrl: '',
models: [],
defaultModel: '',
category: 'Custom',
},
};
// Category order
const CATEGORIES = ['Google', 'Direct', 'OpenAI-Compatible', 'Command Code', 'Local', 'Custom'];
let currentConfig = null;
let selectedProvider = null;
async function init() {
try {
currentConfig = await ipcRenderer.invoke('provider:get-config');
} catch (e) {
console.error('Failed to load config:', e);
currentConfig = { activeProvider: 'google_gemini', providers: {} };
}
selectedProvider = currentConfig.activeProvider;
renderProviderGrid();
selectProvider(selectedProvider);
updateStatus();
}
function renderProviderGrid() {
const container = document.getElementById('providerGridContainer');
container.innerHTML = '';
// Group providers by category
const grouped = {};
for (const [key, prov] of Object.entries(PROVIDERS)) {
const cat = prov.category || 'Other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({ key, ...prov });
}
for (const cat of CATEGORIES) {
if (!grouped[cat]) continue;
const title = document.createElement('div');
title.className = 'category-title';
title.textContent = cat;
container.appendChild(title);
const grid = document.createElement('div');
grid.className = 'provider-grid';
for (const prov of grouped[cat]) {
const card = document.createElement('div');
card.className = 'provider-card' + (prov.key === selectedProvider ? ' active' : '');
card.dataset.provider = prov.key;
card.innerHTML = `
<div class="provider-icon">${prov.icon}</div>
<div class="provider-name">${prov.name}</div>
<div class="provider-desc">${prov.desc}</div>
`;
card.addEventListener('click', () => selectProvider(prov.key));
grid.appendChild(card);
}
container.appendChild(grid);
}
}
function selectProvider(key) {
selectedProvider = key;
// Update active card
document.querySelectorAll('.provider-card').forEach(c => {
c.classList.toggle('active', c.dataset.provider === key);
});
const prov = PROVIDERS[key];
const config = currentConfig?.providers?.[key] || {};
document.getElementById('configTitle').textContent = prov.name + ' Configuration';
document.getElementById('apiUrl').value = config.apiUrl || prov.apiUrl || '';
document.getElementById('apiKey').value = config.apiKey || '';
document.getElementById('modelInput').value = config.model || config.defaultModel || prov.defaultModel || '';
document.getElementById('apiKeyHelp').textContent = prov.apiKeyHint || '';
// Show/hide API key field
const apiKeyGroup = document.getElementById('apiKeyGroup');
apiKeyGroup.style.display = prov.requiresApiKey ? 'block' : 'none';
// Render model chips
const chipsContainer = document.getElementById('modelChips');
const models = prov.models || [];
if (models.length === 0) {
chipsContainer.innerHTML = '<div class="help-text">Type a model name above or use "Fetch Models" after saving</div>';
} else {
const currentModel = document.getElementById('modelInput').value;
chipsContainer.innerHTML = models.map(m =>
`<div class="model-chip${m === currentModel ? ' selected' : ''}" onclick="selectModel('${m}')">${m}</div>`
).join('');
}
// Backend badge
const badge = document.getElementById('backendBadge');
const bt = prov.backendType;
const badges = {
'gemini-native': '<span class="badge badge-native">NATIVE</span>',
'native': '<span class="badge badge-native">NATIVE</span>',
'openai-compat': '<span class="badge badge-proxy">OPENAI-COMPAT</span>',
'anthropic': '<span class="badge badge-proxy">ANTHROPIC</span>',
'command-code': '<span class="badge badge-cc">COMMAND CODE</span>',
};
badge.innerHTML = (badges[bt] || bt) + ' ' + prov.name;
updateStatus();
}
function selectModel(model) {
document.getElementById('modelInput').value = model;
document.querySelectorAll('.model-chip').forEach(c => {
c.classList.toggle('selected', c.textContent === model);
});
}
function toggleKeyVisibility() {
const input = document.getElementById('apiKey');
input.type = input.type === 'password' ? 'text' : 'password';
}
async function saveSettings() {
const key = selectedProvider;
const prov = PROVIDERS[key];
const settings = {
activeProvider: key,
providerConfig: {
apiUrl: document.getElementById('apiUrl').value,
apiKey: document.getElementById('apiKey').value,
model: document.getElementById('modelInput').value,
backendType: prov.backendType,
requiresApiKey: prov.requiresApiKey,
},
};
try {
const result = await ipcRenderer.invoke('provider:save', settings);
currentConfig = await ipcRenderer.invoke('provider:get-config');
if (result && result.needsRestart) {
showToast('✓ Provider configured! Starting AG X...');
updateStatus();
// Auto-close settings window after a brief delay
setTimeout(() => {
const currentWindow = require('electron').remote?.getCurrentWindow();
if (currentWindow) currentWindow.close();
// Fallback: close via ipcRenderer
require('electron').ipcRenderer.send('provider:close-settings');
}, 1500);
} else {
showToast('Settings saved! Provider: ' + prov.name);
updateStatus();
}
} catch (e) {
showToast('Error saving: ' + e.message, true);
}
}
async function resetToDefaults() {
try {
await ipcRenderer.invoke('provider:reset');
currentConfig = await ipcRenderer.invoke('provider:get-config');
selectedProvider = currentConfig.activeProvider;
renderProviderGrid();
selectProvider(selectedProvider);
showToast('Reset to defaults');
updateStatus();
} catch (e) {
showToast('Error resetting: ' + e.message, true);
}
}
async function testConnection() {
const resultEl = document.getElementById('testResult');
resultEl.className = 'test-result';
resultEl.style.display = 'block';
resultEl.textContent = 'Testing connection…';
const prov = PROVIDERS[selectedProvider];
try {
const result = await ipcRenderer.invoke('provider:test-connection', {
provider: selectedProvider,
apiUrl: document.getElementById('apiUrl').value,
apiKey: document.getElementById('apiKey').value,
model: document.getElementById('modelInput').value,
backendType: prov.backendType,
});
if (result.success) {
resultEl.className = 'test-result success';
resultEl.textContent = '✓ ' + result.message;
} else {
resultEl.className = 'test-result error';
resultEl.textContent = '✗ ' + result.error;
}
} catch (e) {
resultEl.className = 'test-result error';
resultEl.textContent = '✗ ' + e.message;
}
}
async function saveAdvanced() {
try {
await ipcRenderer.invoke('provider:save-advanced', {
proxyPort: parseInt(document.getElementById('proxyPort').value) || 9876,
translateProxyPath: document.getElementById('translateProxyPath').value,
ccVersion: document.getElementById('ccVersion').value,
});
showToast('Advanced settings saved');
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (isError ? ' error' : '');
setTimeout(() => { toast.className = 'toast'; }, 3000);
}
function updateStatus() {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
const prov = PROVIDERS[selectedProvider];
if (prov) {
const config = currentConfig?.providers?.[selectedProvider];
if (prov.backendType === 'gemini-native' || prov.backendType === 'native') {
dot.className = 'status-dot connected';
text.textContent = prov.name + ' — Direct connection (no proxy needed)';
} else if (config?.apiKey) {
dot.className = 'status-dot unknown';
text.textContent = prov.name + ' — API key configured, proxy will start on launch';
} else {
dot.className = 'status-dot disconnected';
text.textContent = prov.name + ' — API key required';
}
}
}
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
init();
</script>
</body>
</html>

176
dist/provider/welcome.html vendored Normal file
View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to AG X</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3e 50%, #0a0a1a 100%);
color: #e0e0e0; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.welcome-container {
text-align: center; max-width: 620px; width: 100%; padding: 40px;
}
.logo { font-size: 64px; margin-bottom: 8px; }
.app-name {
font-size: 42px; font-weight: 800;
background: linear-gradient(135deg, #e94560, #ff6b8a, #e94560);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.app-version {
font-size: 13px; color: #555; margin-bottom: 32px;
letter-spacing: 2px; text-transform: uppercase;
}
.tagline {
font-size: 16px; color: #8892b0; margin-bottom: 40px;
line-height: 1.6;
}
.choice-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
margin-bottom: 32px;
}
.choice-card {
background: rgba(22, 33, 62, 0.8);
border: 2px solid #2a2a4a; border-radius: 16px;
padding: 28px 20px; cursor: pointer; transition: all 0.3s;
text-align: center; position: relative; overflow: hidden;
}
.choice-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0;
height: 3px; background: transparent; transition: background 0.3s;
}
.choice-card:hover {
border-color: #e94560; transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(233, 69, 96, 0.15);
}
.choice-card:hover::before { background: linear-gradient(90deg, #e94560, #ff6b8a); }
.choice-card .icon { font-size: 40px; margin-bottom: 12px; }
.choice-card h3 { font-size: 16px; color: #fff; margin-bottom: 8px; }
.choice-card p { font-size: 12px; color: #8892b0; line-height: 1.5; }
.choice-card .badge {
display: inline-block; margin-top: 10px; padding: 3px 10px;
border-radius: 10px; font-size: 10px; font-weight: 600;
}
.badge-easy { background: #1a3a1a; color: #4caf50; }
.badge-power { background: #3a2a1a; color: #ffa726; }
.divider {
display: flex; align-items: center; margin: 24px 0;
color: #444; font-size: 12px; text-transform: uppercase;
letter-spacing: 1px;
}
.divider::before, .divider::after {
content: ''; flex: 1; height: 1px; background: #2a2a4a;
}
.divider span { padding: 0 16px; }
.footer {
font-size: 11px; color: #444; margin-top: 20px;
}
.footer a { color: #e94560; text-decoration: none; }
.footer a:hover { text-decoration: underline; }
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.welcome-container > * {
animation: fadeIn 0.5s ease forwards;
opacity: 0;
}
.welcome-container > *:nth-child(1) { animation-delay: 0.1s; }
.welcome-container > *:nth-child(2) { animation-delay: 0.2s; }
.welcome-container > *:nth-child(3) { animation-delay: 0.25s; }
.welcome-container > *:nth-child(4) { animation-delay: 0.3s; }
.welcome-container > *:nth-child(5) { animation-delay: 0.4s; }
.welcome-container > *:nth-child(6) { animation-delay: 0.5s; }
/* Particles */
.particles {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 0;
}
.particle {
position: absolute; width: 2px; height: 2px;
background: rgba(233, 69, 96, 0.3); border-radius: 50%;
animation: float linear infinite;
}
@keyframes float {
0% { transform: translateY(100vh) scale(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-10vh) scale(1); opacity: 0; }
}
</style>
</head>
<body>
<div class="particles" id="particles"></div>
<div class="welcome-container">
<div class="logo"></div>
<div class="app-name">AG X</div>
<div class="app-version">AI-Powered Code Intelligence</div>
<div class="tagline">
Choose how you'd like to connect to AI.<br>
You can always change this later from the menu.
</div>
<div class="choice-grid">
<div class="choice-card" id="choiceGoogle" onclick="chooseGoogle()">
<div class="icon">🔮</div>
<h3>Google Gemini</h3>
<p>Sign in with your Google account for instant access to Gemini models. No API key needed.</p>
<span class="badge badge-easy">Easiest — zero config</span>
</div>
<div class="choice-card" id="choiceProvider" onclick="chooseProvider()">
<div class="icon">🔌</div>
<h3>Another AI Provider</h3>
<p>Use OpenAI, Anthropic, DeepSeek, Ollama, or 15+ other providers with your own API key.</p>
<span class="badge badge-power">Power users</span>
</div>
</div>
<div class="footer">
Provider settings are always available via <strong>Menu → AI Provider Settings</strong> or the <strong>tray icon</strong>.
</div>
</div>
<script>
const { ipcRenderer } = require('electron');
// Create particles
(function() {
const container = document.getElementById('particles');
for (let i = 0; i < 30; i++) {
const p = document.createElement('div');
p.className = 'particle';
p.style.left = Math.random() * 100 + '%';
p.style.animationDuration = (8 + Math.random() * 12) + 's';
p.style.animationDelay = Math.random() * 10 + 's';
p.style.width = p.style.height = (1 + Math.random() * 3) + 'px';
container.appendChild(p);
}
})();
function chooseGoogle() {
document.getElementById('choiceGoogle').style.borderColor = '#e94560';
document.getElementById('choiceGoogle').style.background = 'rgba(233,69,96,0.1)';
ipcRenderer.send('welcome:choice', { provider: 'google_gemini' });
}
function chooseProvider() {
document.getElementById('choiceProvider').style.borderColor = '#ffa726';
document.getElementById('choiceProvider').style.background = 'rgba(255,167,38,0.1)';
ipcRenderer.send('welcome:choice', { provider: 'custom' });
}
</script>
</body>
</html>

169
dist/providerSettings.js vendored Normal file
View File

@@ -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();
});
}

116
dist/services/apiProxy.js vendored Normal file
View File

@@ -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;

358
dist/services/providerService.js vendored Normal file
View File

@@ -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;

49
dist/services/settingsService.js vendored Normal file
View File

@@ -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;

65
dist/services/settingsService.test.js vendored Normal file
View File

@@ -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);
});
});

1191
dist/services/translationProxy.js vendored Normal file

File diff suppressed because it is too large Load Diff

128
dist/storage.js vendored Normal file
View File

@@ -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;

142
dist/storage.test.js vendored Normal file
View File

@@ -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' });
});
});
});

23
dist/test/helpers.js vendored Normal file
View File

@@ -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(() => { });
}

79
dist/tray.js vendored Normal file
View File

@@ -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);
}
}
}

87
dist/tray.test.js vendored Normal file
View File

@@ -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();
});
});

2
dist/types.js vendored Normal file
View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

241
dist/updater.js vendored Normal file
View File

@@ -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();
}

91
dist/updater.test.js vendored Normal file
View File

@@ -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();
});
});

269
dist/utils.js vendored Normal file
View File

@@ -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}`);
}
}

73
dist/utils.test.js vendored Normal file
View File

@@ -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));
});
});
});

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "ag-x",
"productName": "AG X",
"version": "2.0.3",
"description": "AG X - Agentic Desktop Application",
"homepage": "https://ag-x.dev",
"author": {
"name": "AG X",
"email": "support@ag-x.dev"
},
"main": "dist/main.js",
"dependencies": {
"chrome-devtools-mcp": "^0.23.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3",
"shell-env": "^4.0.3"
}
}

BIN
trayTemplate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

BIN
trayTemplate@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B