auto update improvements, dont allow process globals (import instead), refactor to use execa more

This commit is contained in:
Egor 2025-09-18 17:20:59 -07:00
parent 611a5b4615
commit 3eb97f5683
27 changed files with 353 additions and 384 deletions

View file

@ -11,11 +11,6 @@ export default defineConfig({
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
}, },
}, },
define: {
'process.env.VITE_DEV_SERVER_URL': JSON.stringify(
process.env.VITE_DEV_SERVER_URL
),
},
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],

View file

@ -152,6 +152,15 @@ const config = [
'no-console': 'error', 'no-console': 'error',
'no-restricted-globals': [
'error',
{
name: 'process',
message:
'Import specific properties from "process" instead of using the global: import { platform, env } from "process"',
},
],
'@typescript-eslint/no-inferrable-types': 'warn', '@typescript-eslint/no-inferrable-types': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.4.0-beta.3", "version": "1.4.0-beta.4",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",

View file

@ -1,4 +1,12 @@
import { Group, ActionIcon, Box, Image, Select, AppShell } from '@mantine/core'; import {
Group,
ActionIcon,
Box,
Image,
Select,
AppShell,
Tooltip,
} from '@mantine/core';
import { Minus, Square, X, Copy, Settings } from 'lucide-react'; import { Minus, Square, X, Copy, Settings } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useInterfaceOptions } from '@/hooks/useInterfaceSelection'; import { useInterfaceOptions } from '@/hooks/useInterfaceSelection';
@ -138,20 +146,22 @@ export const TitleBar = ({
<Group gap="0" style={{ WebkitAppRegion: 'no-drag' }}> <Group gap="0" style={{ WebkitAppRegion: 'no-drag' }}>
<UpdateButton /> <UpdateButton />
<ActionIcon <Tooltip label="Settings" position="bottom">
variant="subtle" <ActionIcon
size={TITLEBAR_HEIGHT} variant="subtle"
onClick={() => setSettingsModalOpen(true)} size={TITLEBAR_HEIGHT}
aria-label="Open settings" onClick={() => setSettingsModalOpen(true)}
tabIndex={-1} aria-label="Open settings"
style={{ tabIndex={-1}
borderRadius: 0, style={{
margin: '2px 1px 1px', borderRadius: 0,
outline: 'none', margin: '2px 1px 1px',
}} outline: 'none',
> }}
<Settings size="1.25rem" /> >
</ActionIcon> <Settings size="1.25rem" />
</ActionIcon>
</Tooltip>
<Box <Box
style={{ style={{

View file

@ -1,7 +1,8 @@
import { ActionIcon } from '@mantine/core'; import { ActionIcon, Tooltip } from '@mantine/core';
import { CircleFadingArrowUp } from 'lucide-react'; import { CircleFadingArrowUp, Download } from 'lucide-react';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker'; import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { TITLEBAR_HEIGHT } from '@/constants'; import { TITLEBAR_HEIGHT } from '@/constants';
import { useState, type MouseEvent } from 'react';
export const UpdateButton = () => { export const UpdateButton = () => {
const { const {
@ -14,14 +15,14 @@ export const UpdateButton = () => {
installUpdate, installUpdate,
} = useAppUpdateChecker(); } = useAppUpdateChecker();
const [showDownload, setShowDownload] = useState(false);
if (!hasUpdate) return null; if (!hasUpdate) return null;
const isButton = canAutoUpdate;
const isLink = !canAutoUpdate && releaseUrl;
let color: 'green' | 'blue' | 'orange' = 'orange'; let color: 'green' | 'blue' | 'orange' = 'orange';
let label = 'New release available'; let label = 'New release available - Click to view changelog';
let onClick: (() => void) | undefined; let onClick: (() => void) | undefined;
let icon = <CircleFadingArrowUp size="1.25rem" />;
if (isUpdateDownloaded) { if (isUpdateDownloaded) {
color = 'green'; color = 'green';
@ -30,30 +31,53 @@ export const UpdateButton = () => {
} else if (isDownloading) { } else if (isDownloading) {
color = 'blue'; color = 'blue';
label = 'Downloading update...'; label = 'Downloading update...';
} else if (canAutoUpdate) { icon = <Download size="1.25rem" />;
} else if (showDownload && canAutoUpdate) {
color = 'blue'; color = 'blue';
label = 'Download and install update'; label = 'Download and install update';
onClick = downloadUpdate; onClick = () => {
downloadUpdate();
setShowDownload(false);
};
icon = <Download size="1.25rem" />;
} else {
color = 'orange';
label = canAutoUpdate
? 'New release available - Click to view changelog, right-click to download'
: 'New release available - Click to view changelog';
onClick = () => {
if (releaseUrl) {
window.electronAPI.app.openExternal(releaseUrl);
}
};
} }
const handleContextMenu = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (canAutoUpdate && !isDownloading && !isUpdateDownloaded) {
setShowDownload(true);
downloadUpdate();
}
};
return ( return (
<ActionIcon <Tooltip label={label} position="bottom">
component={(isButton ? 'button' : 'a') as 'button' | 'a'} <ActionIcon
href={isLink ? releaseUrl : undefined} component="button"
target={isLink ? '_blank' : undefined} variant="subtle"
rel={isLink ? 'noopener noreferrer' : undefined} color={color}
variant="subtle" size={TITLEBAR_HEIGHT}
color={color} aria-label={label}
size={TITLEBAR_HEIGHT} tabIndex={-1}
aria-label={label} onClick={onClick}
tabIndex={-1} onContextMenu={handleContextMenu}
onClick={onClick} style={{
style={{ borderRadius: 0,
borderRadius: 0, margin: 0,
margin: 0, }}
}} >
> {icon}
<CircleFadingArrowUp size="1.25rem" /> </ActionIcon>
</ActionIcon> </Tooltip>
); );
}; };

View file

@ -42,6 +42,12 @@ export const useAppUpdateChecker = () => {
setIsDownloading(true); setIsDownloading(true);
await tryExecute(async () => { await tryExecute(async () => {
const checkResult = await window.electronAPI.updater.checkForUpdates();
if (!checkResult) {
setIsDownloading(false);
return;
}
const success = await window.electronAPI.updater.downloadUpdate(); const success = await window.electronAPI.updater.downloadUpdate();
if (success) { if (success) {
setIsUpdateDownloaded(true); setIsUpdateDownloaded(true);

View file

@ -1,5 +1,6 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { platform, exit, stdout, stderr, stdin, on } from 'process';
import { terminateProcess } from '@/utils/node/process'; import { terminateProcess } from '@/utils/node/process';
import { pathExists, readJsonFile } from '@/utils/node/fs'; import { pathExists, readJsonFile } from '@/utils/node/fs';
@ -28,17 +29,17 @@ export async function handleCliMode(args: string[]) {
console.error( console.error(
'Error: No binary found. Please run the GUI first to download the binary.' 'Error: No binary found. Please run the GUI first to download the binary.'
); );
process.exit(1); exit(1);
} }
if (!(await pathExists(currentBinary))) { if (!(await pathExists(currentBinary))) {
console.error(`Error: Binary not found at: ${currentBinary}`); console.error(`Error: Binary not found at: ${currentBinary}`);
console.error('Please run the GUI to download and configure the binary.'); console.error('Please run the GUI to download and configure the binary.');
process.exit(1); exit(1);
} }
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const isWindows = process.platform === 'win32'; const isWindows = platform === 'win32';
const child = spawn(currentBinary, args, { const child = spawn(currentBinary, args, {
stdio: isWindows ? 'pipe' : 'inherit', stdio: isWindows ? 'pipe' : 'inherit',
@ -50,24 +51,24 @@ export async function handleCliMode(args: string[]) {
child.stderr?.setEncoding('utf8'); child.stderr?.setEncoding('utf8');
child.stdout?.on('data', (data) => { child.stdout?.on('data', (data) => {
process.stdout.write(data.toString()); stdout.write(data.toString());
}); });
child.stderr?.on('data', (data) => { child.stderr?.on('data', (data) => {
process.stderr.write(data.toString()); stderr.write(data.toString());
}); });
if (child.stdin && process.stdin.readable) { if (child.stdin && stdin.readable) {
process.stdin.pipe(child.stdin); stdin.pipe(child.stdin);
} }
} }
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
if (signal) { if (signal) {
console.log(`\nProcess terminated with signal: ${signal}`); console.log(`\nProcess terminated with signal: ${signal}`);
process.exit(128 + (signal === 'SIGTERM' ? 15 : 2)); exit(128 + (signal === 'SIGTERM' ? 15 : 2));
} else if (code !== null) { } else if (code !== null) {
process.exit(code); exit(code);
} else { } else {
resolve(); resolve();
} }
@ -90,10 +91,10 @@ export async function handleCliMode(args: string[]) {
} }
}; };
process.on('SIGINT', handleSignal); on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal); on('SIGTERM', handleSignal);
if (process.platform === 'win32') { if (platform === 'win32') {
process.on('SIGBREAK', handleSignal); on('SIGBREAK', handleSignal);
} }
}); });
} }

View file

@ -1,4 +1,5 @@
import { app } from 'electron'; import { app } from 'electron';
import { platform } from 'process';
import { import {
createMainWindow, createMainWindow,
@ -28,7 +29,7 @@ export async function initializeApp() {
createMainWindow(); createMainWindow();
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform === 'darwin') { if (platform === 'darwin') {
return; return;
} }

View file

@ -1,6 +1,7 @@
import { argv, exit } from 'process';
import { getAppVersion } from '@/utils/node/fs'; import { getAppVersion } from '@/utils/node/fs';
if (process.argv[1] === '--version') { if (argv[1] === '--version') {
(async () => { (async () => {
try { try {
const version = await getAppVersion(); const version = await getAppVersion();
@ -10,27 +11,27 @@ if (process.argv[1] === '--version') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('unknown'); console.log('unknown');
} }
process.exit(0); exit(0);
})(); })();
} else { } else {
(async () => { (async () => {
const isCliMode = process.argv.includes('--cli'); const isCliMode = argv.includes('--cli');
if (isCliMode) { if (isCliMode) {
try { try {
const cliModule = await import('./cli'); const cliModule = await import('./cli');
const args = process.argv.slice(process.argv.indexOf('--cli') + 1); const args = argv.slice(argv.indexOf('--cli') + 1);
try { try {
await cliModule.handleCliMode(args); await cliModule.handleCliMode(args);
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('CLI mode error:', error); console.error('CLI mode error:', error);
process.exit(1); exit(1);
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Failed to load CLI module:', error); console.error('Failed to load CLI module:', error);
process.exit(1); exit(1);
} }
} else { } else {
try { try {

View file

@ -1,6 +1,7 @@
import { ipcMain, shell, app } from 'electron'; import { ipcMain, shell, app } from 'electron';
import { join } from 'path'; import { join } from 'path';
import * as os from 'os'; import * as os from 'os';
import { platform, versions, arch } from 'process';
import type { MantineColorScheme } from '@mantine/core'; import type { MantineColorScheme } from '@mantine/core';
import { import {
launchKoboldCpp, launchKoboldCpp,
@ -40,6 +41,7 @@ import {
isNpxAvailable, isNpxAvailable,
getUvVersion, getUvVersion,
getSystemNodeVersion, getSystemNodeVersion,
isAURInstallation,
} from '@/main/modules/dependencies'; } from '@/main/modules/dependencies';
import { parseKoboldConfig } from '@/utils/node/kobold'; import { parseKoboldConfig } from '@/utils/node/kobold';
import { getMainWindow } from '@/main/modules/window'; import { getMainWindow } from '@/main/modules/window';
@ -137,7 +139,7 @@ export function setupIPCHandlers() {
getAvailableBackends(includeDisabled) getAvailableBackends(includeDisabled)
); );
ipcMain.handle('kobold:getPlatform', () => process.platform); ipcMain.handle('kobold:getPlatform', () => platform);
ipcMain.handle('kobold:launchKoboldCpp', (_, args) => ipcMain.handle('kobold:launchKoboldCpp', (_, args) =>
launchKoboldCppWithCustomFrontends(args) launchKoboldCppWithCustomFrontends(args)
@ -173,13 +175,13 @@ export function setupIPCHandlers() {
return { return {
appVersion, appVersion,
electronVersion: process.versions.electron, electronVersion: versions.electron,
nodeVersion: process.versions.node, nodeVersion: versions.node,
chromeVersion: process.versions.chrome, chromeVersion: versions.chrome,
v8Version: process.versions.v8, v8Version: versions.v8,
osVersion: os.release(), osVersion: os.release(),
platform: process.platform, platform,
arch: process.arch, arch,
nodeJsSystemVersion, nodeJsSystemVersion,
uvVersion, uvVersion,
}; };
@ -285,8 +287,17 @@ export function setupIPCHandlers() {
ipcMain.handle('app:isUpdateDownloaded', () => isUpdateDownloaded()); ipcMain.handle('app:isUpdateDownloaded', () => isUpdateDownloaded());
ipcMain.handle( ipcMain.handle('app:canAutoUpdate', async () => {
'app:canAutoUpdate', if (!app.isPackaged) return false;
() => process.platform !== 'linux' || Boolean(process.env.APPIMAGE)
); if (platform === 'linux' && (await isAURInstallation())) {
return false;
}
return (
platform === 'win32' || platform === 'darwin' || platform === 'linux'
);
});
ipcMain.handle('app:isAURInstallation', () => isAURInstallation());
} }

View file

@ -2,6 +2,7 @@ import { autoUpdater } from 'electron-updater';
import { app } from 'electron'; import { app } from 'electron';
import { logError } from '@/main/modules/logging'; import { logError } from '@/main/modules/logging';
import { safeExecute } from '@/utils/node/logger'; import { safeExecute } from '@/utils/node/logger';
import { isDevelopment } from '@/utils/node/environment';
export interface UpdateInfo { export interface UpdateInfo {
version: string; version: string;
@ -34,17 +35,33 @@ function setupAutoUpdater() {
setupAutoUpdater(); setupAutoUpdater();
export const checkForUpdates = async () => export const checkForUpdates = async () => {
(await safeExecute( if (isDevelopment) {
() => autoUpdater.checkForUpdates(), logError('Auto-updater: Cannot check for updates in development mode');
'Failed to check for updates' return false;
)) !== null; }
export const downloadUpdate = async () => return (
(await safeExecute( (await safeExecute(
() => autoUpdater.downloadUpdate(), () => autoUpdater.checkForUpdates(),
'Failed to download update' 'Failed to check for updates'
)) !== null; )) !== null
);
};
export const downloadUpdate = async () => {
if (isDevelopment) {
logError('Auto-updater: Cannot download updates in development mode');
return false;
}
return (
(await safeExecute(
() => autoUpdater.downloadUpdate(),
'Failed to download update'
)) !== null
);
};
export function quitAndInstall() { export function quitAndInstall() {
if (updateDownloaded) { if (updateDownloaded) {

View file

@ -1,4 +1,5 @@
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { platform } from 'process';
import { pathExists } from '@/utils/node/fs'; import { pathExists } from '@/utils/node/fs';
import { getCurrentBinaryInfo } from './koboldcpp'; import { getCurrentBinaryInfo } from './koboldcpp';
import { detectGPUCapabilities, detectCPU } from './hardware'; import { detectGPUCapabilities, detectCPU } from './hardware';
@ -26,7 +27,6 @@ async function detectBackendSupportFromPath(koboldBinaryPath: string) {
const binaryDir = dirname(koboldBinaryPath); const binaryDir = dirname(koboldBinaryPath);
const internalDir = join(binaryDir, '_internal'); const internalDir = join(binaryDir, '_internal');
const platform = process.platform;
const libExtension = platform === 'win32' ? '.dll' : '.so'; const libExtension = platform === 'win32' ? '.dll' : '.so';
const hasKoboldCppLib = async (name: string): Promise<boolean> => { const hasKoboldCppLib = async (name: string): Promise<boolean> => {

View file

@ -1,6 +1,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { access } from 'fs/promises'; import { access } from 'fs/promises';
import { platform, on } from 'process';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
import yauzl from 'yauzl'; import yauzl from 'yauzl';
@ -80,17 +81,17 @@ async function shouldUpdateComfyUI(workspaceDir: string): Promise<boolean> {
let comfyUIProcess: ChildProcess | null = null; let comfyUIProcess: ChildProcess | null = null;
function getPythonPath(workspaceDir: string): string { function getPythonPath(workspaceDir: string): string {
const isWindows = process.platform === 'win32'; const isWindows = platform === 'win32';
const pythonExecutable = isWindows ? 'python.exe' : 'python'; const pythonExecutable = isWindows ? 'python.exe' : 'python';
const scriptsDir = isWindows ? 'Scripts' : 'bin'; const scriptsDir = isWindows ? 'Scripts' : 'bin';
return join(workspaceDir, '.venv', scriptsDir, pythonExecutable); return join(workspaceDir, '.venv', scriptsDir, pythonExecutable);
} }
process.on('SIGINT', () => { on('SIGINT', () => {
void cleanup(); void cleanup();
}); });
process.on('SIGTERM', () => { on('SIGTERM', () => {
void cleanup(); void cleanup();
}); });
@ -99,7 +100,7 @@ async function shouldForceCPUMode(): Promise<boolean> {
const gpus = await getGPUData(); const gpus = await getGPUData();
const hasAMD = gpus.some((gpu) => gpu.deviceName.includes('AMD')); const hasAMD = gpus.some((gpu) => gpu.deviceName.includes('AMD'));
const hasNVIDIA = gpus.some((gpu) => gpu.deviceName.includes('NVIDIA')); const hasNVIDIA = gpus.some((gpu) => gpu.deviceName.includes('NVIDIA'));
const isWindows = process.platform === 'win32'; const isWindows = platform === 'win32';
return (hasAMD && isWindows) || (!hasAMD && !hasNVIDIA); return (hasAMD && isWindows) || (!hasAMD && !hasNVIDIA);
} catch { } catch {
@ -122,7 +123,7 @@ async function getPyTorchInstallArgs(pythonPath: string) {
const gpus = await getGPUData(); const gpus = await getGPUData();
const hasAMD = gpus.some((gpu) => gpu.deviceName.includes('AMD')); const hasAMD = gpus.some((gpu) => gpu.deviceName.includes('AMD'));
const hasNVIDIA = gpus.some((gpu) => gpu.deviceName.includes('NVIDIA')); const hasNVIDIA = gpus.some((gpu) => gpu.deviceName.includes('NVIDIA'));
const isWindows = process.platform === 'win32'; const isWindows = platform === 'win32';
if (hasAMD && !isWindows) { if (hasAMD && !isWindows) {
sendKoboldOutput( sendKoboldOutput(

View file

@ -3,6 +3,7 @@ import { safeExecute } from '@/utils/node/logger';
import { getConfigDir } from '@/utils/node/path'; import { getConfigDir } from '@/utils/node/path';
import { homedir } from 'os'; import { homedir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { platform } from 'process';
import { nativeTheme } from 'electron'; import { nativeTheme } from 'electron';
import { PRODUCT_NAME } from '@/constants'; import { PRODUCT_NAME } from '@/constants';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
@ -53,7 +54,6 @@ export async function set(key: string, value: ConfigValue) {
} }
function getDefaultInstallDir() { function getDefaultInstallDir() {
const platform = process.platform;
const home = homedir(); const home = homedir();
switch (platform) { switch (platform) {

View file

@ -1,51 +1,23 @@
import { spawn } from 'child_process';
import { access, readdir } from 'fs/promises'; import { access, readdir } from 'fs/promises';
import { homedir } from 'os'; import { homedir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { platform, env as processEnv } from 'process';
interface CommandResult { import { app } from 'electron';
success: boolean; import { execa } from 'execa';
output?: string;
}
async function executeCommand( async function executeCommand(
command: string, command: string,
args: string[], args: string[],
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
timeout = 5000, timeout = 5000
useShell = false
) { ) {
try { try {
const testProcess = spawn(command, args, { const { stdout } = await execa(command, args, {
stdio: 'pipe',
env, env,
shell: useShell, timeout,
}); reject: false,
return new Promise<CommandResult>((resolve) => {
let output = '';
const timeoutId = setTimeout(() => {
testProcess.kill();
resolve({ success: false });
}, timeout);
testProcess.stdout?.on('data', (data) => {
output += data.toString();
});
testProcess.on('exit', (code) => {
clearTimeout(timeoutId);
resolve({
success: code === 0,
output: code === 0 ? output.trim() : undefined,
});
});
testProcess.on('error', () => {
clearTimeout(timeoutId);
resolve({ success: false });
});
}); });
return { success: true, output: stdout.trim() };
} catch { } catch {
return { success: false }; return { success: false };
} }
@ -64,13 +36,7 @@ export async function getUvVersion() {
export async function getSystemNodeVersion() { export async function getSystemNodeVersion() {
const env = await getNodeEnvironment(); const env = await getNodeEnvironment();
const result = await executeCommand( const result = await executeCommand('node', ['--version'], env);
'node',
['--version'],
env,
5000,
process.platform === 'win32'
);
if (result.success && result.output) { if (result.success && result.output) {
return result.output.replace(/^v/, '') || null; return result.output.replace(/^v/, '') || null;
@ -85,7 +51,7 @@ export async function isUvAvailable() {
} }
export async function getUvEnvironment() { export async function getUvEnvironment() {
const env = { ...process.env }; const env = { ...processEnv };
const uvPaths = [ const uvPaths = [
join(homedir(), '.cargo', 'bin'), join(homedir(), '.cargo', 'bin'),
@ -103,11 +69,11 @@ export async function getUvEnvironment() {
} }
if (existingPaths.length > 0) { if (existingPaths.length > 0) {
const pathSeparator = process.platform === 'win32' ? ';' : ':'; const pathSeparator = platform === 'win32' ? ';' : ':';
env.PATH = `${existingPaths.join(pathSeparator)}${pathSeparator}${env.PATH}`; env.PATH = `${existingPaths.join(pathSeparator)}${pathSeparator}${env.PATH}`;
} }
if (process.platform === 'win32') { if (platform === 'win32') {
env.PYTHONIOENCODING = 'utf-8'; env.PYTHONIOENCODING = 'utf-8';
env.PYTHONLEGACYWINDOWSSTDIO = '1'; env.PYTHONLEGACYWINDOWSSTDIO = '1';
env.PYTHONUTF8 = '1'; env.PYTHONUTF8 = '1';
@ -119,20 +85,14 @@ export async function getUvEnvironment() {
export async function isNpxAvailable() { export async function isNpxAvailable() {
const env = await getNodeEnvironment(); const env = await getNodeEnvironment();
const result = await executeCommand( const result = await executeCommand('npx', ['--version'], env);
'npx',
['--version'],
env,
5000,
process.platform === 'win32'
);
return result.success; return result.success;
} }
export async function getNodeEnvironment() { export async function getNodeEnvironment() {
const env = { ...process.env }; const env = { ...processEnv };
if (process.platform === 'win32') { if (platform === 'win32') {
return env; return env;
} }
@ -144,7 +104,7 @@ export async function getNodeEnvironment() {
]; ];
const systemPaths: string[] = []; const systemPaths: string[] = [];
if (process.platform === 'darwin') { if (platform === 'darwin') {
systemPaths.push('/opt/homebrew/bin', '/usr/local/bin'); systemPaths.push('/opt/homebrew/bin', '/usr/local/bin');
} }
@ -196,11 +156,36 @@ async function tryVersionManagerPath(
return false; return false;
} }
export async function isAURInstallation(): Promise<boolean> {
if (platform !== 'linux') {
return false;
}
const appPath = app.getAppPath();
const aurPaths = ['/usr/lib/', '/opt/', '/usr/share/'];
const isInSystemPath = aurPaths.some((path) => appPath.startsWith(path));
if (!isInSystemPath) {
return false;
}
try {
const { stdout } = await execa('pacman', ['-Q', 'gerbil'], {
timeout: 1000,
reject: false,
});
return stdout.trim().length > 0;
} catch {
return false;
}
}
function tryAddPathToEnv( function tryAddPathToEnv(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
path: string path: string
) { ) {
const pathSeparator = process.platform === 'win32' ? ';' : ':'; const pathSeparator = platform === 'win32' ? ';' : ':';
if (!env.PATH?.includes(path)) { if (!env.PATH?.includes(path)) {
env.PATH = `${path}${pathSeparator}${env.PATH}`; env.PATH = `${path}${pathSeparator}${env.PATH}`;
return true; return true;

View file

@ -1,16 +1,14 @@
/* eslint-disable no-comments/disallowComments */ /* eslint-disable no-comments/disallowComments */
import si from 'systeminformation'; import si from 'systeminformation';
import { safeExecute } from '@/utils/node/logger'; import { safeExecute } from '@/utils/node/logger';
import { terminateProcess } from '@/utils/node/process';
import { getGPUData } from '@/utils/node/gpu'; import { getGPUData } from '@/utils/node/gpu';
import type { import type {
CPUCapabilities, CPUCapabilities,
GPUCapabilities, GPUCapabilities,
BasicGPUInfo, BasicGPUInfo,
GPUMemoryInfo, GPUMemoryInfo,
HardwareDetectionResult,
} from '@/types/hardware'; } from '@/types/hardware';
import { spawn } from 'child_process'; import { execa } from 'execa';
import { formatDeviceName } from '@/utils/format'; import { formatDeviceName } from '@/utils/format';
let cpuCapabilitiesCache: CPUCapabilities | null = null; let cpuCapabilitiesCache: CPUCapabilities | null = null;
@ -132,48 +130,33 @@ export async function detectGPUCapabilities() {
async function detectCUDA() { async function detectCUDA() {
try { try {
const nvidia = spawn( const { stdout } = await execa(
'nvidia-smi', 'nvidia-smi',
['--query-gpu=name,memory.total,memory.free', '--format=csv,noheader'], ['--query-gpu=name,memory.total,memory.free', '--format=csv,noheader'],
{ timeout: 5000 } {
timeout: 5000,
reject: false,
}
); );
let output = ''; if (stdout.trim()) {
nvidia.stdout.on('data', (data) => { const devices = stdout
output += data.toString(); .trim()
}); .split('\n')
.map((line) => {
const parts = line.split(',');
const rawName = parts[0]?.trim() || 'Unknown NVIDIA GPU';
return formatDeviceName(rawName);
})
.filter(Boolean);
return new Promise<HardwareDetectionResult>((resolve) => { return {
nvidia.on('close', (code) => { supported: devices.length > 0,
if (code === 0 && output.trim()) { devices,
const devices = output };
.trim() }
.split('\n')
.map((line) => {
const parts = line.split(',');
const rawName = parts[0]?.trim() || 'Unknown NVIDIA GPU';
return formatDeviceName(rawName);
})
.filter(Boolean);
resolve({ return { supported: false, devices: [] };
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
}
});
nvidia.on('error', () => {
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(nvidia);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch { } catch {
return { supported: false, devices: [] }; return { supported: false, devices: [] };
} }
@ -189,6 +172,7 @@ async function findRocminfoCommand() {
} }
} }
// eslint-disable-next-line sonarjs/cognitive-complexity
export async function detectROCm() { export async function detectROCm() {
try { try {
const rocminfoCommand = await findRocminfoCommand(); const rocminfoCommand = await findRocminfoCommand();
@ -197,91 +181,74 @@ export async function detectROCm() {
} }
const isWindows = rocminfoCommand.includes('hipInfo'); const isWindows = rocminfoCommand.includes('hipInfo');
const rocminfo = spawn(rocminfoCommand, [], { timeout: 5000 }); const { stdout } = await execa(rocminfoCommand, [], {
timeout: 5000,
let output = ''; reject: false,
rocminfo.stdout.on('data', (data) => {
output += data.toString();
}); });
return new Promise<HardwareDetectionResult>((resolve) => { if (stdout.trim()) {
// eslint-disable-next-line sonarjs/cognitive-complexity const devices: string[] = [];
rocminfo.on('close', (code) => {
if (code === 0 && output.trim()) {
const devices: string[] = [];
if (isWindows) { if (isWindows) {
const lines = output.split('\n'); const lines = stdout.split('\n');
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (trimmedLine.startsWith('Name:')) { if (trimmedLine.startsWith('Name:')) {
const name = trimmedLine.split('Name:')[1]?.trim(); const name = trimmedLine.split('Name:')[1]?.trim();
if ( if (
name && name &&
!name.toLowerCase().includes('cpu') && !name.toLowerCase().includes('cpu') &&
!devices.includes(formatDeviceName(name)) !devices.includes(formatDeviceName(name))
) { ) {
devices.push(formatDeviceName(name)); devices.push(formatDeviceName(name));
}
}
}
} else {
const lines = stdout.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) {
const name = line.split('Marketing Name:')[1]?.trim();
if (name) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() || '';
break;
} }
} }
}
} else {
const lines = output.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('Marketing Name:')) { if (deviceType !== 'CPU') {
const name = line.split('Marketing Name:')[1]?.trim(); devices.push(formatDeviceName(name));
if (name) {
let deviceType = '';
const searchRangeLines = 20;
const searchStartIndex = Math.max(0, i - searchRangeLines);
const searchEndIndex = Math.min(
lines.length,
i + searchRangeLines
);
for (
let searchIndex = searchStartIndex;
searchIndex < searchEndIndex;
searchIndex++
) {
if (lines[searchIndex].includes('Device Type:')) {
deviceType =
lines[searchIndex].split('Device Type:')[1]?.trim() ||
'';
break;
}
}
if (deviceType !== 'CPU') {
devices.push(formatDeviceName(name));
}
}
} }
} }
} }
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
} }
}); }
rocminfo.on('error', () => { return {
resolve({ supported: false, devices: [] }); supported: devices.length > 0,
}); devices,
};
}
setTimeout(async () => { return { supported: false, devices: [] };
await terminateProcess(rocminfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch { } catch {
return { supported: false, devices: [] }; return { supported: false, devices: [] };
} }
@ -289,49 +256,34 @@ export async function detectROCm() {
async function detectVulkan() { async function detectVulkan() {
try { try {
const vulkaninfo = spawn('vulkaninfo', ['--summary'], { timeout: 5000 }); const { stdout } = await execa('vulkaninfo', ['--summary'], {
timeout: 5000,
let output = ''; reject: false,
vulkaninfo.stdout.on('data', (data) => {
output += data.toString();
}); });
return new Promise<HardwareDetectionResult>((resolve) => { if (stdout.trim()) {
vulkaninfo.on('close', (code) => { const devices: string[] = [];
if (code === 0 && output.trim()) { const lines = stdout.split('\n');
const devices: string[] = [];
const lines = output.split('\n');
for (const line of lines) { for (const line of lines) {
if (line.includes('deviceName') && line.includes('=')) { if (line.includes('deviceName') && line.includes('=')) {
const parts = line.split('='); const parts = line.split('=');
if (parts.length >= 2) { if (parts.length >= 2) {
const name = parts[1]?.trim(); const name = parts[1]?.trim();
if (name) { if (name) {
devices.push(formatDeviceName(name)); devices.push(formatDeviceName(name));
}
}
} }
} }
resolve({
supported: devices.length > 0,
devices,
});
} else {
resolve({ supported: false, devices: [] });
} }
}); }
vulkaninfo.on('error', () => { return {
resolve({ supported: false, devices: [] }); supported: devices.length > 0,
}); devices,
};
}
setTimeout(async () => { return { supported: false, devices: [] };
await terminateProcess(vulkaninfo);
resolve({ supported: false, devices: [] });
}, 5000);
});
} catch { } catch {
return { supported: false, devices: [] }; return { supported: false, devices: [] };
} }
@ -391,35 +343,20 @@ function findDeviceNameInClInfo(lines: string[], startIndex: number) {
async function detectCLBlast() { async function detectCLBlast() {
try { try {
const clinfo = spawn('clinfo', [], { timeout: 3000 }); const { stdout } = await execa('clinfo', [], {
timeout: 3000,
let output = ''; reject: false,
clinfo.stdout.on('data', (data) => {
output += data.toString();
}); });
return new Promise<HardwareDetectionResult>((resolve) => { if (stdout.trim()) {
clinfo.on('close', (code) => { const devices = parseClInfoOutput(stdout);
if (code === 0 && output.trim()) { return {
const devices = parseClInfoOutput(output); supported: devices.length > 0,
resolve({ devices,
supported: devices.length > 0, };
devices, }
});
} else {
resolve({ supported: false, devices: [] });
}
});
clinfo.on('error', () => { return { supported: false, devices: [] };
resolve({ supported: false, devices: [] });
});
setTimeout(async () => {
await terminateProcess(clinfo);
resolve({ supported: false, devices: [] });
}, 3000);
});
} catch { } catch {
return { supported: false, devices: [] }; return { supported: false, devices: [] };
} }

View file

@ -1,6 +1,7 @@
import { spawn, ChildProcess } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import { createWriteStream } from 'fs'; import { createWriteStream } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { platform } from 'process';
import { import {
rm, rm,
readdir, readdir,
@ -63,7 +64,7 @@ async function removeDirectoryWithRetry(
throw error; throw error;
} }
if (isPermissionError && process.platform === 'win32') { if (isPermissionError && platform === 'win32') {
sendKoboldOutput( sendKoboldOutput(
`Attempt ${attempt}/${maxRetries} failed (file in use), retrying in ${delayMs}ms...` `Attempt ${attempt}/${maxRetries} failed (file in use), retrying in ${delayMs}ms...`
); );
@ -131,7 +132,7 @@ async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
writer.on('finish', async () => { writer.on('finish', async () => {
if (process.platform !== 'win32') { if (platform !== 'win32') {
try { try {
await chmod(tempPackedFilePath, 0o755); await chmod(tempPackedFilePath, 0o755);
} catch (error) { } catch (error) {
@ -153,9 +154,7 @@ async function setupLauncher(
if (!launcherPath || !(await pathExists(launcherPath))) { if (!launcherPath || !(await pathExists(launcherPath))) {
const expectedLauncherName = const expectedLauncherName =
process.platform === 'win32' platform === 'win32' ? 'koboldcpp-launcher.exe' : 'koboldcpp-launcher';
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName); const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (await pathExists(tempPackedFilePath)) { if (await pathExists(tempPackedFilePath)) {
@ -297,7 +296,7 @@ async function patchKcppSduiEmbd(unpackedDir: string) {
} }
async function getLauncherPath(unpackedDir: string) { async function getLauncherPath(unpackedDir: string) {
const extensions = process.platform === 'win32' ? ['.exe', ''] : ['', '.exe']; const extensions = platform === 'win32' ? ['.exe', ''] : ['', '.exe'];
for (const ext of extensions) { for (const ext of extensions) {
const launcherPath = join(unpackedDir, `koboldcpp-launcher${ext}`); const launcherPath = join(unpackedDir, `koboldcpp-launcher${ext}`);

View file

@ -17,8 +17,7 @@ export const initializeLogger = () => {
format: format.combine( format: format.combine(
format.timestamp(), format.timestamp(),
format.printf(({ timestamp, level, message, error }) => { format.printf(({ timestamp, level, message, error }) => {
const processInfo = `[${process.type || 'unknown'}:${process.pid}]`; let logEntry = `${timestamp} [MAIN] [${level.toUpperCase()}] ${message}`;
let logEntry = `${timestamp} ${processInfo} [${level.toUpperCase()}] ${message}`;
if (error && error instanceof Error) { if (error && error instanceof Error) {
logEntry += `\n Error: ${error.message}`; logEntry += `\n Error: ${error.message}`;
@ -42,7 +41,6 @@ export const initializeLogger = () => {
], ],
}); });
setupGlobalErrorHandlers();
isInitialized = true; isInitialized = true;
}; };
@ -72,18 +70,6 @@ export const flushLogs = () => {
} }
}; };
const setupGlobalErrorHandlers = () => {
process.on('uncaughtException', (error) => {
logError('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, _promise) => {
const message = `Unhandled Promise Rejection`;
const error = reason instanceof Error ? reason : new Error(String(reason));
logError(message, error);
});
};
export const getLogFilePath = () => export const getLogFilePath = () =>
join(app.getPath('userData'), 'logs', 'gerbil.log'); join(app.getPath('userData'), 'logs', 'gerbil.log');

View file

@ -1,6 +1,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { on } from 'process';
import { logError } from './logging'; import { logError } from './logging';
import { safeTryExecute, tryExecute } from '@/utils/node/logger'; import { safeTryExecute, tryExecute } from '@/utils/node/logger';
@ -16,11 +17,11 @@ let openWebUIProcess: ChildProcess | null = null;
const OPENWEBUI_BASE_ARGS = ['--python', '3.11', 'open-webui@latest', 'serve']; const OPENWEBUI_BASE_ARGS = ['--python', '3.11', 'open-webui@latest', 'serve'];
process.on('SIGINT', () => { on('SIGINT', () => {
void cleanup(); void cleanup();
}); });
process.on('SIGTERM', () => { on('SIGTERM', () => {
void cleanup(); void cleanup();
}); });

View file

@ -1,4 +1,4 @@
import { spawn } from 'child_process'; import { execa } from 'execa';
import { platform } from 'process'; import { platform } from 'process';
import { safeExecute } from '@/utils/node/logger'; import { safeExecute } from '@/utils/node/logger';
@ -12,37 +12,16 @@ const LINUX_PERFORMANCE_APPS = [
]; ];
async function tryLaunchCommand(command: string, args: string[] = []) { async function tryLaunchCommand(command: string, args: string[] = []) {
return new Promise((resolve) => { try {
const child = spawn(command, args, { await execa(command, args, {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
timeout: 2000,
}); });
return true;
let hasResolved = false; } catch {
return false;
child.on('error', () => { }
if (!hasResolved) {
hasResolved = true;
resolve(false);
}
});
child.on('spawn', () => {
if (!hasResolved) {
hasResolved = true;
child.unref();
resolve(true);
}
});
setTimeout(() => {
if (!hasResolved) {
hasResolved = true;
child.kill();
resolve(false);
}
}, 2000);
});
} }
export const openPerformanceManager = async () => export const openPerformanceManager = async () =>

View file

@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import { createServer, request, type Server } from 'http'; import { createServer, request, type Server } from 'http';
import { homedir } from 'os'; import { homedir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { platform, on } from 'process';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
import { logError } from './logging'; import { logError } from './logging';
@ -26,16 +27,15 @@ const SILLYTAVERN_BASE_ARGS = [
'--disableCsrf', '--disableCsrf',
]; ];
process.on('SIGINT', () => { on('SIGINT', () => {
void cleanup(); void cleanup();
}); });
process.on('SIGTERM', () => { on('SIGTERM', () => {
void cleanup(); void cleanup();
}); });
function getFallbackDataRoot() { function getFallbackDataRoot() {
const platform = process.platform;
const home = homedir(); const home = homedir();
switch (platform) { switch (platform) {
@ -76,7 +76,7 @@ async function createNpxProcess(args: string[]) {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
detached: false, detached: false,
env, env,
shell: process.platform === 'win32', shell: platform === 'win32',
}); });
} }

View file

@ -141,6 +141,7 @@ const updaterAPI: UpdaterAPI = {
quitAndInstall: () => ipcRenderer.invoke('app:quitAndInstall'), quitAndInstall: () => ipcRenderer.invoke('app:quitAndInstall'),
isUpdateDownloaded: () => ipcRenderer.invoke('app:isUpdateDownloaded'), isUpdateDownloaded: () => ipcRenderer.invoke('app:isUpdateDownloaded'),
canAutoUpdate: () => ipcRenderer.invoke('app:canAutoUpdate'), canAutoUpdate: () => ipcRenderer.invoke('app:canAutoUpdate'),
isAURInstallation: () => ipcRenderer.invoke('app:isAURInstallation'),
}; };
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {

View file

@ -203,6 +203,7 @@ export interface UpdaterAPI {
quitAndInstall: () => void; quitAndInstall: () => void;
isUpdateDownloaded: () => Promise<boolean>; isUpdateDownloaded: () => Promise<boolean>;
canAutoUpdate: () => Promise<boolean>; canAutoUpdate: () => Promise<boolean>;
isAURInstallation: () => Promise<boolean>;
} }
declare global { declare global {

View file

@ -1,10 +1,11 @@
import { join } from 'path'; import { join } from 'path';
import { resourcesPath } from 'process';
import { isDevelopment } from '@/utils/node/environment'; import { isDevelopment } from '@/utils/node/environment';
export function getAssetPath(assetName: string) { export function getAssetPath(assetName: string) {
if (isDevelopment) { if (isDevelopment) {
return join(__dirname, '../../assets', assetName); return join(__dirname, '../../assets', assetName);
} else { } else {
return join(process.resourcesPath, '..', 'assets', assetName); return join(resourcesPath, '..', 'assets', assetName);
} }
} }

View file

@ -1 +1,3 @@
export const isDevelopment = process.env.NODE_ENV === 'development'; import { env } from 'process';
export const isDevelopment = env.NODE_ENV === 'development';

View file

@ -1,5 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { platform } from 'process';
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants'; import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
export function getConfigDir() { export function getConfigDir() {
@ -7,7 +8,6 @@ export function getConfigDir() {
} }
function getConfigDirPath() { function getConfigDirPath() {
const platform = process.platform;
const home = homedir(); const home = homedir();
switch (platform) { switch (platform) {

View file

@ -1,4 +1,5 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { platform } from 'process';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
export interface ProcessTerminationOptions { export interface ProcessTerminationOptions {
@ -43,7 +44,7 @@ export async function terminateProcess(
} }
try { try {
if (process.platform === 'win32') { if (platform === 'win32') {
await killWindowsProcessTree(childProcess.pid, logError); await killWindowsProcessTree(childProcess.pid, logError);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {