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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.map
|
||||
releases/*.deb
|
||||
43
README.md
Normal file
43
README.md
Normal 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
25
dist/__mocks__/electron-updater.js
vendored
Normal 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
145
dist/__mocks__/electron.js
vendored
Normal 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
8
dist/constants.js
vendored
Normal 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
55
dist/customScheme.js
vendored
Normal 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
131
dist/ideInstall/constants.js
vendored
Normal 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
29
dist/ideInstall/index.js
vendored
Normal 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
197
dist/ideInstall/service.js
vendored
Normal 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
155
dist/ideInstall/wizard.js
vendored
Normal 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
286
dist/ideInstall/wizardHtml.js
vendored
Normal 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
23
dist/ideInstall/wizardPreload.js
vendored
Normal 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
350
dist/ideInstallService.test.js
vendored
Normal 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
208
dist/ipcHandlers.js
vendored
Normal 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
88
dist/ipcHandlers.test.js
vendored
Normal 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
19
dist/keybindings.js
vendored
Normal 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
509
dist/languageServer.js
vendored
Normal 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
81
dist/languageServer.test.js
vendored
Normal 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
100
dist/loadingOverlay.js
vendored
Normal 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
587
dist/main.js
vendored
Normal 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
200
dist/main.test.js
vendored
Normal 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
81
dist/menu.js
vendored
Normal 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
49
dist/paths.js
vendored
Normal 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
371
dist/preload.js
vendored
Normal 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
696
dist/provider/settings.html
vendored
Normal 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
176
dist/provider/welcome.html
vendored
Normal 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
169
dist/providerSettings.js
vendored
Normal 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
116
dist/services/apiProxy.js
vendored
Normal 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
358
dist/services/providerService.js
vendored
Normal 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
49
dist/services/settingsService.js
vendored
Normal 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
65
dist/services/settingsService.test.js
vendored
Normal 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
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
128
dist/storage.js
vendored
Normal 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
142
dist/storage.test.js
vendored
Normal 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
23
dist/test/helpers.js
vendored
Normal 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
79
dist/tray.js
vendored
Normal 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
87
dist/tray.test.js
vendored
Normal 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
2
dist/types.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
241
dist/updater.js
vendored
Normal file
241
dist/updater.js
vendored
Normal 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
91
dist/updater.test.js
vendored
Normal 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
269
dist/utils.js
vendored
Normal 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
73
dist/utils.test.js
vendored
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
18
package.json
Normal file
18
package.json
Normal 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
BIN
trayTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 355 B |
BIN
trayTemplate@2x.png
Normal file
BIN
trayTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 651 B |
Reference in New Issue
Block a user