import { spawn, ChildProcess } from 'child_process'; import { createWriteStream } from 'fs'; import { join } from 'path'; import { rm, readdir, stat, unlink, rename, mkdir, chmod, readFile, writeFile, copyFile, } from 'fs/promises'; import { dialog } from 'electron'; import axios from 'axios'; import { execa } from 'execa'; import { terminateProcess } from '@/utils/process'; import { getInstallDir, setInstallDir, getCurrentKoboldBinary, setCurrentKoboldBinary, } from './config'; import { logError } from './logging'; import { sendKoboldOutput, getMainWindow, sendToRenderer } from './window'; import { PRODUCT_NAME, SERVER_READY_SIGNALS } from '@/constants'; import { KLITE_CSS_OVERRIDE, KLITE_AUTOSCROLL_PATCHES, } from '@/constants/patches'; import { pathExists, readJsonFile, writeJsonFile } from '@/utils/fs'; import { stripAssetExtensions } from '@/utils/version'; import { parseKoboldConfig } from '@/utils/kobold'; import type { GitHubAsset, InstalledVersion, KoboldConfig, } from '@/types/electron'; import { isDevelopment } from '@/utils/environment'; import type { FrontendPreference } from '@/types'; let koboldProcess: ChildProcess | null = null; async function removeDirectoryWithRetry( dirPath: string, maxRetries = 3, delayMs = 1000 ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await rm(dirPath, { recursive: true, force: true }); return; } catch (error) { const isLastAttempt = attempt === maxRetries; const isPermissionError = (error as Error & { code?: string }).code === 'EPERM'; if (isLastAttempt) { throw error; } if (isPermissionError && process.platform === 'win32') { sendKoboldOutput( `Attempt ${attempt}/${maxRetries} failed (file in use), retrying in ${delayMs}ms...` ); await new Promise((resolve) => setTimeout(resolve, delayMs)); delayMs *= 1.5; } else { throw error; } } } } async function handleExistingDirectory( unpackedDirPath: string, isUpdate: boolean ): Promise { if (!isUpdate || !(await pathExists(unpackedDirPath))) { return; } try { if (koboldProcess && !koboldProcess.killed) { sendKoboldOutput('Stopping process before update...'); await cleanup(); await new Promise((resolve) => setTimeout(resolve, 2000)); } await removeDirectoryWithRetry(unpackedDirPath); } catch (error) { logError('Failed to remove existing directory for update:', error as Error); throw new Error( `Cannot update: Failed to remove existing installation. ` + `Please ensure the server is stopped and try again. ` + `Error: ${(error as Error).message}` ); } } async function 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 = 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) { logError('Failed to make binary executable:', error as Error); } } resolve(); }); writer.on('error', reject); response.data.on('error', reject); }); } async function setupLauncher( tempPackedFilePath: string, unpackedDirPath: string ): Promise { 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) { logError('Failed to rename binary as launcher:', error as Error); } } } else { try { await unlink(tempPackedFilePath); } catch (error) { logError('Failed to cleanup packed file:', error as Error); } } if (!launcherPath || !(await pathExists(launcherPath))) { throw new Error('Failed to find or create launcher'); } return launcherPath; } export async function downloadRelease(asset: GitHubAsset) { const tempPackedFilePath = join(getInstallDir(), `${asset.name}.packed`); const baseFilename = stripAssetExtensions(asset.name); const folderName = asset.version ? `${baseFilename}-${asset.version}` : baseFilename; const unpackedDirPath = join(getInstallDir(), folderName); try { await handleExistingDirectory(unpackedDirPath, Boolean(asset.isUpdate)); await downloadFile(asset, tempPackedFilePath); await mkdir(unpackedDirPath, { recursive: true }); await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath); const launcherPath = await setupLauncher( tempPackedFilePath, unpackedDirPath ); const currentBinary = getCurrentKoboldBinary(); if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) { await setCurrentKoboldBinary(launcherPath); } sendToRenderer('versions-updated'); return launcherPath; } catch (error) { logError('Failed to download or unpack binary:', error as Error); throw new Error( `Failed to download or unpack binary: ${(error as Error).message}` ); } } async function unpackKoboldCpp( packedPath: string, unpackDir: string ): Promise { try { await execa(packedPath, ['--unpack', unpackDir], { timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], }); } catch (error) { const execaError = error as { stderr?: string; stdout?: string; message: string; }; const errorMessage = execaError.stderr || execaError.stdout || execaError.message; throw new Error(`Unpack failed: ${errorMessage}`); } } async function patchKliteEmbd(unpackedDir: string) { try { const possiblePaths = [ join(unpackedDir, '_internal', 'klite.embd'), join(unpackedDir, 'klite.embd'), ]; let kliteEmbdPath: string | null = null; for (const path of possiblePaths) { if (await pathExists(path)) { kliteEmbdPath = path; break; } } if (!kliteEmbdPath) { return; } const content = await readFile(kliteEmbdPath, 'utf8'); if (content.includes('')) { let patchedContent = content; if (content.includes('gerbil-css-override')) { patchedContent = patchedContent.replace( /