diff --git a/eslint.config.ts b/eslint.config.ts index 6278cb5..3ab5089 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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', diff --git a/src/App.tsx b/src/App.tsx index f1bdcab..f2b7184 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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, - window.electronAPI.config.get('hasSeenWelcome') as Promise, - window.electronAPI.config.get( - 'frontendPreference' - ) as Promise, - ]); + const [currentVersion, hasSeenWelcome, preference] = await Promise.all([ + window.electronAPI.kobold.getCurrentVersion(), + window.electronAPI.config.get('hasSeenWelcome') as Promise, + window.electronAPI.config.get( + 'frontendPreference' + ) as Promise, + ]); 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, - ]); - - 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(() => { diff --git a/src/components/settings/VersionsTab.tsx b/src/components/settings/VersionsTab.tsx index 54a0658..c5635dd 100644 --- a/src/components/settings/VersionsTab.tsx +++ b/src/components/settings/VersionsTab.tsx @@ -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, + 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:'); }; diff --git a/src/hooks/useUpdateChecker.ts b/src/hooks/useUpdateChecker.ts index 63d16a5..d86f42c 100644 --- a/src/hooks/useUpdateChecker.ts +++ b/src/hooks/useUpdateChecker.ts @@ -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, - 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; } ); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d6f1883..f89019f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -61,6 +61,10 @@ export class IPCHandlers { this.koboldManager.getInstalledVersions() ); + ipcMain.handle('kobold:getCurrentVersion', () => + this.koboldManager.getCurrentVersion() + ); + ipcMain.handle('kobold:getConfigFiles', () => this.koboldManager.getConfigFiles() ); diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index 8de7ae3..bf73b3c 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -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 { - 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 { + 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 { + 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((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 { + 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 { + 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]; } diff --git a/src/main/managers/WindowManager.ts b/src/main/managers/WindowManager.ts index 129ec65..6d1023d 100644 --- a/src/main/managers/WindowManager.ts +++ b/src/main/managers/WindowManager.ts @@ -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( diff --git a/src/main/templates/about-dialog.html b/src/main/templates/about-modal.html similarity index 100% rename from src/main/templates/about-dialog.html rename to src/main/templates/about-modal.html diff --git a/src/preload/index.ts b/src/preload/index.ts index 3145295..1d8723e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index cd92fbc..b26a942 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -95,6 +95,7 @@ export interface KoboldConfig { export interface KoboldAPI { getInstalledVersions: () => Promise; + getCurrentVersion: () => Promise; setCurrentVersion: (version: string) => Promise; getPlatform: () => Promise; detectGPU: () => Promise; diff --git a/src/utils/server/download.ts b/src/utils/server/download.ts deleted file mode 100644 index 6df80f8..0000000 --- a/src/utils/server/download.ts +++ /dev/null @@ -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; - getLauncherPath: (unpackedDir: string) => Promise; - removeDirectoryWithRetry?: (dirPath: string) => Promise; - cleanup?: () => Promise; - 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) | undefined, - removeDirectoryWithRetry: ((dirPath: string) => Promise) | 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 { - 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}`, - }; - } -} diff --git a/src/utils/version.ts b/src/utils/version.ts index 0e967d0..dc59320 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -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 =>