force getCurrentVersion usage instead of direct config reading, better about modal

This commit is contained in:
lone-cloud 2025-08-30 13:44:54 -07:00
parent 01c64049fb
commit a1cc5571c8
12 changed files with 222 additions and 384 deletions

View file

@ -125,6 +125,18 @@ const config = [
message:
'Synchronous file operations are forbidden. Use async alternatives.',
},
{
selector:
'CallExpression[callee.object.object.object.name="window"][callee.object.object.property.name="electronAPI"][callee.object.property.name="config"][callee.property.name="get"] Literal[value="currentKoboldBinary"]',
message:
'Direct access to currentKoboldBinary config is forbidden. Use window.electronAPI.kobold.getCurrentVersion() instead to get proper fallback logic.',
},
{
selector:
'CallExpression[callee.object.object.object.name="window"][callee.object.object.property.name="electronAPI"][callee.object.property.name="config"][callee.property.name="set"] Literal[value="currentKoboldBinary"]',
message:
'Direct setting of currentKoboldBinary config is forbidden. Use window.electronAPI.kobold.setCurrentVersion() instead.',
},
],
'import/no-default-export': 'error',

View file

@ -47,42 +47,25 @@ export const App = () => {
useEffect(() => {
const checkInstallation = async () => {
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath, hasSeenWelcome, preference] =
await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get(
'currentKoboldBinary'
) as Promise<string>,
window.electronAPI.config.get('hasSeenWelcome') as Promise<boolean>,
window.electronAPI.config.get(
'frontendPreference'
) as Promise<FrontendPreference>,
]);
const [currentVersion, hasSeenWelcome, preference] = await Promise.all([
window.electronAPI.kobold.getCurrentVersion(),
window.electronAPI.config.get('hasSeenWelcome') as Promise<boolean>,
window.electronAPI.config.get(
'frontendPreference'
) as Promise<FrontendPreference>,
]);
setFrontendPreference(preference || 'koboldcpp');
if (!hasSeenWelcome) {
setCurrentScreenWithTransition('welcome');
} else if (versions.length > 0) {
let current = null;
if (currentBinaryPath) {
current = versions.find((v) => v.path === currentBinaryPath);
}
if (!current) {
current = versions[0];
if (current) {
await window.electronAPI.config.set(
'currentKoboldBinary',
current.path
);
}
}
} else if (currentVersion) {
setCurrentScreenWithTransition('launch');
} else {
setCurrentScreenWithTransition('download');
}
if (versions.length > 0) {
if (currentVersion) {
setTimeout(() => {
checkForUpdates();
}, 2000);
@ -112,26 +95,7 @@ export const App = () => {
const handleDownloadComplete = async () => {
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get('currentKoboldBinary') as Promise<string>,
]);
if (versions.length > 0) {
let current = null;
if (currentBinaryPath) {
current = versions.find((v) => v.path === currentBinaryPath);
}
if (!current) {
current = versions[0];
if (current) {
await window.electronAPI.config.set(
'currentKoboldBinary',
current.path
);
}
}
}
await window.electronAPI.kobold.getCurrentVersion();
}, 'Error refreshing versions after download:');
setTimeout(() => {

View file

@ -64,38 +64,13 @@ export const VersionsTab = () => {
setLoadingInstalled(true);
await Logger.safeExecute(async () => {
const [versions, currentBinaryPath] = await Promise.all([
const [versions, currentVersion] = await Promise.all([
window.electronAPI.kobold.getInstalledVersions(),
window.electronAPI.config.get('currentKoboldBinary') as Promise<string>,
window.electronAPI.kobold.getCurrentVersion(),
]);
setInstalledVersions(versions);
if (currentBinaryPath && versions.length > 0) {
const current = versions.find((v) => v.path === currentBinaryPath);
if (current) {
setCurrentVersion(current);
} else {
setCurrentVersion(versions[0]);
await window.electronAPI.config.set(
'currentKoboldBinary',
versions[0].path
);
}
} else if (versions.length > 0) {
setCurrentVersion(versions[0]);
await window.electronAPI.config.set(
'currentKoboldBinary',
versions[0].path
);
} else {
setCurrentVersion(null);
if (currentBinaryPath) {
await window.electronAPI.config.set('currentKoboldBinary', '');
}
}
setCurrentVersion(currentVersion);
}, 'Failed to load installed versions:');
setLoadingInstalled(false);
@ -245,17 +220,7 @@ export const VersionsTab = () => {
);
if (success) {
await window.electronAPI.config.set(
'currentKoboldBinary',
version.installedPath!
);
const newCurrentVersion = installedVersions.find(
(v) => v.path === version.installedPath
);
if (newCurrentVersion) {
setCurrentVersion(newCurrentVersion);
}
await loadInstalledVersions();
}
}, 'Failed to set current version:');
};

View file

@ -1,5 +1,9 @@
import { useState, useCallback, useEffect } from 'react';
import { getDisplayNameFromPath, compareVersions } from '@/utils/version';
import {
getDisplayNameFromPath,
compareVersions,
stripAssetExtensions,
} from '@/utils/version';
import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { getROCmDownload } from '@/utils/rocm';
import type { InstalledVersion, DownloadItem } from '@/types/electron';
@ -64,22 +68,14 @@ export const useUpdateChecker = () => {
setIsChecking(true);
try {
const [currentBinaryPath, installedVersionsResult, rocmDownload] =
await Promise.all([
window.electronAPI.config.get(
'currentKoboldBinary'
) as Promise<string>,
window.electronAPI.kobold.getInstalledVersions(),
getROCmDownload(),
]);
const [currentVersion, rocmDownload] = await Promise.all([
window.electronAPI.kobold.getCurrentVersion(),
getROCmDownload(),
]);
if (!currentBinaryPath || installedVersionsResult.length === 0) {
if (!currentVersion) {
return;
}
const currentVersion = installedVersionsResult.find(
(v: InstalledVersion) => v.path === currentBinaryPath
);
if (!currentVersion) {
return;
}
@ -93,9 +89,7 @@ export const useUpdateChecker = () => {
const matchingDownload = availableDownloads.find(
(download: DownloadItem) => {
const downloadBaseName = download.name
.replace(/\.(tar\.gz|zip|exe)$/i, '')
.replace(/\.packed$/, '');
const downloadBaseName = stripAssetExtensions(download.name);
return downloadBaseName === currentDisplayName;
}
);

View file

@ -61,6 +61,10 @@ export class IPCHandlers {
this.koboldManager.getInstalledVersions()
);
ipcMain.handle('kobold:getCurrentVersion', () =>
this.koboldManager.getCurrentVersion()
);
ipcMain.handle('kobold:getConfigFiles', () =>
this.koboldManager.getConfigFiles()
);

View file

@ -1,7 +1,9 @@
import { spawn, ChildProcess } from 'child_process';
import { createWriteStream } from 'fs';
import { join } from 'path';
import { rm, readdir, stat } from 'fs/promises';
import { rm, readdir, stat, unlink, rename, mkdir, chmod } from 'fs/promises';
import { dialog } from 'electron';
import axios from 'axios';
import { execa } from 'execa';
import { terminateProcess } from '@/utils/process';
@ -9,8 +11,8 @@ import { ConfigManager } from '@/main/managers/ConfigManager';
import { LogManager } from '@/main/managers/LogManager';
import { WindowManager } from '@/main/managers/WindowManager';
import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants';
import { downloadAndUnpackBinary } from '@/utils/server/download';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs';
import { stripAssetExtensions } from '@/utils/version';
import type {
GitHubAsset,
InstalledVersion,
@ -68,40 +70,165 @@ export class KoboldCppManager {
}
}
async downloadRelease(asset: GitHubAsset): Promise<string> {
const result = await downloadAndUnpackBinary(
{
name: asset.name,
url: asset.browser_download_url,
size: asset.size,
version: asset.version,
},
{
installDir: this.installDir,
onProgress: (progress: number) => {
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
},
isUpdate: asset.isUpdate,
wasCurrentBinary: asset.wasCurrentBinary,
logManager: this.logManager,
configManager: this.configManager,
windowManager: this.windowManager,
unpackFunction: this.unpackKoboldCpp.bind(this),
getLauncherPath: this.getLauncherPath.bind(this),
removeDirectoryWithRetry: this.removeDirectoryWithRetry.bind(this),
cleanup: this.cleanup.bind(this),
koboldProcess: this.koboldProcess || undefined,
}
);
if (!result.success) {
throw new Error(result.error || 'Download failed');
private async handleExistingDirectory(
unpackedDirPath: string,
isUpdate: boolean
): Promise<void> {
if (!isUpdate || !(await pathExists(unpackedDirPath))) {
return;
}
return result.path!;
try {
if (this.koboldProcess && !this.koboldProcess.killed) {
this.windowManager.sendKoboldOutput(
'Stopping KoboldCpp process before update...'
);
await this.cleanup();
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await this.removeDirectoryWithRetry(unpackedDirPath);
} catch (error) {
this.logManager.logError(
'Failed to remove existing directory for update:',
error as Error
);
throw new Error(
`Cannot update: Failed to remove existing installation. ` +
`Please ensure KoboldCpp is stopped and try again. ` +
`Error: ${(error as Error).message}`
);
}
}
private async downloadFile(
asset: GitHubAsset,
tempPackedFilePath: string
): Promise<void> {
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const response = await axios({
method: 'GET',
url: asset.browser_download_url,
responseType: 'stream',
timeout: 30000,
maxRedirects: 5,
});
const totalBytes = asset.size;
response.data.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100;
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('download-progress', progress);
}
}
});
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', async () => {
if (process.platform !== 'win32') {
try {
await chmod(tempPackedFilePath, 0o755);
} catch (error) {
this.logManager.logError(
'Failed to make binary executable:',
error as Error
);
}
}
resolve();
});
writer.on('error', reject);
response.data.on('error', reject);
});
}
private async setupLauncher(
tempPackedFilePath: string,
unpackedDirPath: string
): Promise<string> {
let launcherPath = await this.getLauncherPath(unpackedDirPath);
if (!launcherPath || !(await pathExists(launcherPath))) {
const expectedLauncherName =
process.platform === 'win32'
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (await pathExists(tempPackedFilePath)) {
try {
await rename(tempPackedFilePath, newLauncherPath);
launcherPath = newLauncherPath;
} catch (error) {
this.logManager.logError(
'Failed to rename binary as launcher:',
error as Error
);
}
}
} else {
try {
await unlink(tempPackedFilePath);
} catch (error) {
this.logManager.logError(
'Failed to cleanup packed file:',
error as Error
);
}
}
if (!launcherPath || !(await pathExists(launcherPath))) {
throw new Error('Failed to find or create koboldcpp launcher');
}
return launcherPath;
}
async downloadRelease(asset: GitHubAsset): Promise<string> {
const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`);
const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version
? `${baseFilename}-${asset.version}`
: baseFilename;
const unpackedDirPath = join(this.installDir, folderName);
try {
await this.handleExistingDirectory(
unpackedDirPath,
Boolean(asset.isUpdate)
);
await this.downloadFile(asset, tempPackedFilePath);
await mkdir(unpackedDirPath, { recursive: true });
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
const launcherPath = await this.setupLauncher(
tempPackedFilePath,
unpackedDirPath
);
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) {
await this.configManager.setCurrentKoboldBinary(launcherPath);
}
this.windowManager.sendToRenderer('versions-updated');
return launcherPath;
} catch (error) {
this.logManager.logError(
'Failed to download or unpack binary:',
error as Error
);
throw new Error(
`Failed to download or unpack binary: ${(error as Error).message}`
);
}
}
private async unpackKoboldCpp(
@ -362,7 +489,9 @@ export class KoboldCppManager {
const folderName = launcherPath.split(/[/\\]/).slice(-2, -1)[0];
if (folderName) {
const versionMatch = folderName.match(/-(\d+\.\d+(?:\.\d+)?)$/);
const versionMatch = folderName.match(
/-(\d+\.\d+(?:\.\d+)?(?:\.[a-zA-Z0-9]+)*(?:-[a-zA-Z0-9]+)*)$/
);
if (versionMatch) {
return versionMatch[1];
}

View file

@ -316,9 +316,13 @@ Node.js: ${nodeVersion}
V8: ${v8Version}
OS: ${osInfo}`;
const iconPath = this.getIconPath();
const iconImage = nativeImage.createFromPath(iconPath);
const aboutWindow = new BrowserWindow({
width: 500,
height: 400,
width: 400,
height: 450,
icon: iconImage,
modal: true,
parent: this.mainWindow!,
resizable: false,
@ -334,7 +338,7 @@ OS: ${osInfo}`;
aboutWindow.setMenu(null);
const htmlPath = this.getTemplatePath('about-dialog.html');
const htmlPath = this.getTemplatePath('about-modal.html');
await aboutWindow.loadFile(htmlPath);
aboutWindow.webContents.executeJavaScript(

View file

@ -10,6 +10,7 @@ import type {
const koboldAPI: KoboldAPI = {
getInstalledVersions: () => ipcRenderer.invoke('kobold:getInstalledVersions'),
getCurrentVersion: () => ipcRenderer.invoke('kobold:getCurrentVersion'),
setCurrentVersion: (version: string) =>
ipcRenderer.invoke('kobold:setCurrentVersion', version),
getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'),

View file

@ -95,6 +95,7 @@ export interface KoboldConfig {
export interface KoboldAPI {
getInstalledVersions: () => Promise<InstalledVersion[]>;
getCurrentVersion: () => Promise<InstalledVersion | null>;
setCurrentVersion: (version: string) => Promise<boolean>;
getPlatform: () => Promise<string>;
detectGPU: () => Promise<BasicGPUInfo>;

View file

@ -1,240 +0,0 @@
import { createWriteStream } from 'fs';
import { unlink, rename, mkdir, chmod } from 'fs/promises';
import { join } from 'path';
import axios from 'axios';
import type { LogManager } from '@/main/managers/LogManager';
import type { ConfigManager } from '@/main/managers/ConfigManager';
import type { WindowManager } from '@/main/managers/WindowManager';
import type { DownloadItem } from '@/types/electron';
import { stripAssetExtensions } from '@/utils/version';
import type { ChildProcess } from 'child_process';
import { pathExists } from '@/utils/fs';
export interface DownloadOptions {
installDir: string;
onProgress?: (progress: number) => void;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
version?: string;
logManager: LogManager;
configManager: ConfigManager;
windowManager: WindowManager;
unpackFunction: (packedPath: string, unpackDir: string) => Promise<void>;
getLauncherPath: (unpackedDir: string) => Promise<string | null>;
removeDirectoryWithRetry?: (dirPath: string) => Promise<void>;
cleanup?: () => Promise<void>;
koboldProcess?: ChildProcess;
}
export interface DownloadResult {
success: boolean;
path?: string;
error?: string;
}
async function handleExistingDirectory(
unpackedDirPath: string,
isUpdate: boolean,
koboldProcess: ChildProcess | undefined,
windowManager: WindowManager,
cleanup: (() => Promise<void>) | undefined,
removeDirectoryWithRetry: ((dirPath: string) => Promise<void>) | undefined,
logManager: LogManager
): Promise<{ success: boolean; error?: string }> {
if (!isUpdate || !(await pathExists(unpackedDirPath))) {
return { success: true };
}
try {
if (koboldProcess && !koboldProcess.killed) {
windowManager.sendKoboldOutput(
'Stopping KoboldCpp process before update...'
);
if (cleanup) {
await cleanup();
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (removeDirectoryWithRetry) {
await removeDirectoryWithRetry(unpackedDirPath);
}
return { success: true };
} catch (error) {
logManager.logError(
'Failed to remove existing directory for update:',
error as Error
);
return {
success: false,
error:
`Cannot update: Failed to remove existing installation. ` +
`Please ensure KoboldCpp is stopped and try again. ` +
`Error: ${(error as Error).message}`,
};
}
}
async function downloadFile(
item: DownloadItem,
tempPackedFilePath: string,
onProgress: ((progress: number) => void) | undefined,
logManager: LogManager
): Promise<{ success: boolean; error?: string }> {
try {
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const response = await axios({
method: 'GET',
url: item.url,
responseType: 'stream',
timeout: 30000,
maxRedirects: 5,
});
const totalBytes = item.size;
response.data.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (onProgress && totalBytes > 0) {
onProgress((downloadedBytes / totalBytes) * 100);
}
});
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', async () => {
if (process.platform !== 'win32') {
try {
await chmod(tempPackedFilePath, 0o755);
} catch (error) {
logManager.logError(
'Failed to make binary executable:',
error as Error
);
}
}
resolve({ success: true });
});
writer.on('error', (error) => {
reject(error);
});
response.data.on('error', (error: Error) => {
reject(error);
});
});
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export async function downloadAndUnpackBinary(
item: DownloadItem,
options: DownloadOptions
): Promise<DownloadResult> {
const {
installDir,
onProgress,
isUpdate,
wasCurrentBinary,
version,
logManager,
configManager,
windowManager,
unpackFunction,
getLauncherPath,
removeDirectoryWithRetry,
cleanup,
koboldProcess,
} = options;
const tempPackedFilePath = join(installDir, `${item.name}.packed`);
const baseFilename = stripAssetExtensions(item.name);
const folderName = version ? `${baseFilename}-${version}` : baseFilename;
const unpackedDirPath = join(installDir, folderName);
const dirResult = await handleExistingDirectory(
unpackedDirPath,
Boolean(isUpdate),
koboldProcess,
windowManager,
cleanup,
removeDirectoryWithRetry,
logManager
);
if (!dirResult.success) {
return { success: false, error: dirResult.error };
}
const downloadResult = await downloadFile(
item,
tempPackedFilePath,
onProgress,
logManager
);
if (!downloadResult.success) {
return { success: false, error: downloadResult.error };
}
try {
await mkdir(unpackedDirPath, { recursive: true });
await unpackFunction(tempPackedFilePath, unpackedDirPath);
let launcherPath = await getLauncherPath(unpackedDirPath);
if (!launcherPath || !(await pathExists(launcherPath))) {
const expectedLauncherName =
process.platform === 'win32'
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (await pathExists(tempPackedFilePath)) {
try {
await rename(tempPackedFilePath, newLauncherPath);
launcherPath = newLauncherPath;
} catch (error) {
logManager.logError(
'Failed to rename binary as launcher:',
error as Error
);
}
}
} else {
try {
await unlink(tempPackedFilePath);
} catch (error) {
logManager.logError('Failed to cleanup packed file:', error as Error);
}
}
if (launcherPath && (await pathExists(launcherPath))) {
const currentBinary = configManager.getCurrentKoboldBinary();
if (!currentBinary || (isUpdate && wasCurrentBinary)) {
await configManager.setCurrentKoboldBinary(launcherPath);
}
windowManager.sendToRenderer('versions-updated');
return {
success: true,
path: launcherPath,
};
} else {
return {
success: false,
error: 'Failed to find or create koboldcpp launcher',
};
}
} catch (error) {
logManager.logError('Failed to unpack binary:', error as Error);
return {
success: false,
error: `Failed to unpack binary: ${(error as Error).message}`,
};
}
}

View file

@ -9,16 +9,20 @@ export const getDisplayNameFromPath = (
);
if (launcherIndex > 0) {
return pathParts[launcherIndex - 1];
return stripVersionSuffix(pathParts[launcherIndex - 1]);
}
return installedVersion.filename;
};
export const stripAssetExtensions = (assetName: string): string =>
assetName
.replace(/\.(tar\.gz|zip|exe|dmg|AppImage)$/i, '')
.replace(/\.packed$/, '');
assetName.replace(/\.(tar\.gz|zip|exe|dmg|AppImage)$/i, '');
const stripVersionSuffix = (displayName: string): string =>
displayName.replace(
/-(\d+\.\d+(?:\.\d+)?(?:\.[a-zA-Z0-9]+)*(?:-[a-zA-Z0-9]+)*)$/,
''
);
export const compareVersions = (versionA: string, versionB: string): number => {
const cleanVersion = (version: string): string =>