add windows rocm build, better support for binaries that unpack without a launcher

This commit is contained in:
Egor 2025-08-29 23:28:11 -07:00
parent c8098a3e68
commit e2ff70a2dc
6 changed files with 321 additions and 194 deletions

View file

@ -74,12 +74,6 @@ The AUR package automatically handles installation, desktop integration, and sys
</div>
<!-- markdownlint-enable MD033 -->
### Windows ROCm Support
There is ROCm Windows support maintained by YellowRoseCx in a separate fork.
Unfortunately it does not properly support unpacking, which would greatly diminish its performance and provide a poor UX when used alongside this app.
For Friendly Kobold to work with this fork, [this issue must be fixed first](https://github.com/YellowRoseCx/koboldcpp-rocm/issues/129).
### Future features
Not all koboldcpp features have currently been ported over to the UI. As a workaround one may use the "Additional arguments" on the "Advanced" tab of the launcher to provide additional command line arguments if you know them.

View file

@ -39,9 +39,11 @@ export const ASSET_SUFFIXES = {
} as const;
export const ROCM = {
BINARY_NAME: 'koboldcpp-linux-x64-rocm',
DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm',
SIZE_BYTES_APPROX: 1024 * 1024 * 1024,
LINUX: {
BINARY_NAME: 'koboldcpp-linux-x64-rocm',
DOWNLOAD_URL: 'https://koboldai.org/cpplinuxrocm',
SIZE_BYTES_APPROX: 1024 * 1024 * 1024,
},
} as const;
export const FRONTENDS = {

View file

@ -4,11 +4,8 @@ import {
existsSync,
readdirSync,
statSync,
createWriteStream,
chmodSync,
readFileSync,
writeFileSync,
unlinkSync,
} from 'fs';
import { rm } from 'fs/promises';
import { dialog } from 'electron';
@ -16,13 +13,11 @@ import { dialog } from 'electron';
import { execa } from 'execa';
import { terminateProcess } from '@/utils/process';
import { getROCmDownload } from '@/utils/rocm';
import { got } from 'got';
import { pipeline } from 'stream/promises';
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 { stripAssetExtensions } from '@/utils/version';
import { downloadAndUnpackBinary } from '@/utils/server/download';
import type {
GitHubAsset,
InstalledVersion,
@ -82,95 +77,34 @@ export class KoboldCppManager {
asset: GitHubAsset,
onProgress?: (progress: number) => void
): Promise<string> {
const tempPackedFilePath = join(this.installDir, `${asset.name}.packed`);
const baseFilename = stripAssetExtensions(asset.name);
const unpackedDirPath = join(this.installDir, baseFilename);
if (asset.isUpdate && existsSync(unpackedDirPath)) {
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}`
);
const result = await downloadAndUnpackBinary(
{
name: asset.name,
url: asset.browser_download_url,
size: asset.size,
type: 'asset' as const,
},
{
installDir: this.installDir,
onProgress,
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');
}
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const totalBytes = asset.size;
try {
await pipeline(
got
.stream(asset.browser_download_url)
.on('downloadProgress', (progress) => {
downloadedBytes = progress.transferred;
if (onProgress && totalBytes > 0) {
onProgress((downloadedBytes / totalBytes) * 100);
}
}),
writer
);
} catch (error) {
throw new Error(`Download failed: ${(error as Error).message}`);
}
if (process.platform !== 'win32') {
try {
chmodSync(tempPackedFilePath, 0o755);
} catch (error) {
this.logManager.logError(
'Failed to make binary executable:',
error as Error
);
}
}
try {
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
try {
unlinkSync(tempPackedFilePath);
} catch (error) {
this.logManager.logError(
'Failed to cleanup packed file:',
error as Error
);
}
const launcherPath = this.getLauncherPath(unpackedDirPath);
if (launcherPath && existsSync(launcherPath)) {
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
this.windowManager.sendToRenderer('versions-updated');
return launcherPath;
} else {
throw new Error('Failed to find koboldcpp-launcher after unpacking');
}
} catch (error) {
this.logManager.logError('Failed to unpack KoboldCpp:', error as Error);
throw error;
}
return result.path!;
}
private async unpackKoboldCpp(
@ -562,91 +496,23 @@ export class KoboldCppManager {
};
}
const tempPackedFilePath = join(
this.installDir,
`${rocmInfo.name}.packed`
return await downloadAndUnpackBinary(
{
name: rocmInfo.name,
url: rocmInfo.url,
size: rocmInfo.size,
type: 'rocm' as const,
},
{
installDir: this.installDir,
onProgress,
logManager: this.logManager,
configManager: this.configManager,
windowManager: this.windowManager,
unpackFunction: this.unpackKoboldCpp.bind(this),
getLauncherPath: this.getLauncherPath.bind(this),
}
);
const baseFilename = stripAssetExtensions(rocmInfo.name);
const unpackedDirPath = join(this.installDir, baseFilename);
const response = await fetch(rocmInfo.url);
if (!response.ok) {
return {
success: false,
error: `Failed to download: ${response.statusText}`,
};
}
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const totalBytes = rocmInfo.size;
try {
await pipeline(
got.stream(rocmInfo.url).on('downloadProgress', (progress) => {
downloadedBytes = progress.transferred;
if (onProgress && totalBytes > 0) {
onProgress((downloadedBytes / totalBytes) * 100);
}
}),
writer
);
} catch (error) {
return {
success: false,
error: `Download failed: ${(error as Error).message}`,
};
}
if (process.platform !== 'win32') {
try {
chmodSync(tempPackedFilePath, 0o755);
} catch (error) {
this.logManager.logError(
'Failed to make ROCm binary executable:',
error as Error
);
}
}
try {
await this.unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
try {
unlinkSync(tempPackedFilePath);
} catch (error) {
this.logManager.logError(
'Failed to cleanup packed ROCm file:',
error as Error
);
}
const launcherPath = this.getLauncherPath(unpackedDirPath);
if (launcherPath && existsSync(launcherPath)) {
const currentBinary = this.configManager.getCurrentKoboldBinary();
if (!currentBinary) {
this.configManager.setCurrentKoboldBinary(launcherPath);
}
this.windowManager.sendToRenderer('versions-updated');
return {
success: true,
path: launcherPath,
};
} else {
return {
success: false,
error: 'Failed to find koboldcpp-launcher after unpacking ROCm',
};
}
} catch (error) {
this.logManager.logError('Failed to unpack ROCm:', error as Error);
return {
success: false,
error: `Failed to unpack ROCm: ${(error as Error).message}`,
};
}
} catch (error) {
return {
success: false,

View file

@ -3,7 +3,7 @@ import { ROCM } from '@/constants';
export const formatDownloadSize = (size: number, url?: string): string => {
if (!size) return '';
const isApproximateSize = url?.includes(ROCM.DOWNLOAD_URL);
const isApproximateSize = url?.includes(ROCM.LINUX.DOWNLOAD_URL);
return isApproximateSize
? `~${formatFileSizeInMB(size)}`

View file

@ -1,13 +1,20 @@
import { GITHUB_API, ROCM } from '@/constants';
import type { DownloadItem } from '@/types/electron';
import type { GitHubAsset } from '@/types';
export async function getROCmDownload(
platform: string
): Promise<DownloadItem | null> {
if (platform !== 'linux') {
return null;
if (platform === 'linux') {
return getLinuxROCmDownload();
} else if (platform === 'win32') {
return getWindowsROCmDownload();
}
return null;
}
async function getLinuxROCmDownload(): Promise<DownloadItem | null> {
try {
const response = await fetch(GITHUB_API.LATEST_RELEASE_URL);
if (!response.ok) {
@ -18,19 +25,54 @@ export async function getROCmDownload(
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
return {
name: ROCM.BINARY_NAME,
url: ROCM.DOWNLOAD_URL,
size: ROCM.SIZE_BYTES_APPROX,
name: ROCM.LINUX.BINARY_NAME,
url: ROCM.LINUX.DOWNLOAD_URL,
size: ROCM.LINUX.SIZE_BYTES_APPROX,
version,
type: 'rocm',
};
} catch {
return {
name: ROCM.BINARY_NAME,
url: ROCM.DOWNLOAD_URL,
size: ROCM.SIZE_BYTES_APPROX,
name: ROCM.LINUX.BINARY_NAME,
url: ROCM.LINUX.DOWNLOAD_URL,
size: ROCM.LINUX.SIZE_BYTES_APPROX,
version: 'unknown',
type: 'rocm',
};
}
}
async function getWindowsROCmDownload(): Promise<DownloadItem | null> {
try {
const response = await fetch(GITHUB_API.ROCM_LATEST_RELEASE_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const latestRelease = await response.json();
const version = latestRelease?.tag_name?.replace(/^v/, '') || 'unknown';
const windowsAsset =
latestRelease.assets?.find(
(asset: GitHubAsset) => asset.name === 'koboldcpp_rocm.exe'
) ||
latestRelease.assets?.find(
(asset: GitHubAsset) =>
asset.name.includes('koboldcpp_rocm') && asset.name.endsWith('.exe')
);
if (!windowsAsset) {
throw new Error('Windows ROCm executable not found in release');
}
return {
name: windowsAsset.name.replace('.exe', ''),
url: windowsAsset.browser_download_url,
size: windowsAsset.size,
version,
type: 'rocm',
};
} catch {
return null;
}
}

View file

@ -0,0 +1,223 @@
import { createWriteStream, chmodSync, existsSync } from 'fs';
import { unlink, rename, mkdir } from 'fs/promises';
import { join } from 'path';
import { pipeline } from 'stream/promises';
import got from 'got';
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';
export interface DownloadOptions {
installDir: string;
onProgress?: (progress: number) => void;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
logManager: LogManager;
configManager: ConfigManager;
windowManager: WindowManager;
unpackFunction: (packedPath: string, unpackDir: string) => Promise<void>;
getLauncherPath: (unpackedDir: string) => 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 || !existsSync(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 }> {
const writer = createWriteStream(tempPackedFilePath);
let downloadedBytes = 0;
const totalBytes = item.size;
try {
await pipeline(
got.stream(item.url).on('downloadProgress', (progress) => {
downloadedBytes = progress.transferred;
if (onProgress && totalBytes > 0) {
onProgress((downloadedBytes / totalBytes) * 100);
}
}),
writer
);
if (process.platform !== 'win32') {
try {
chmodSync(tempPackedFilePath, 0o755);
} catch (error) {
logManager.logError(
'Failed to make binary executable:',
error as Error
);
}
}
return { success: true };
} catch (error) {
return {
success: false,
error: `Download failed: ${(error as Error).message}`,
};
}
}
export async function downloadAndUnpackBinary(
item: DownloadItem,
options: DownloadOptions
): Promise<DownloadResult> {
const {
installDir,
onProgress,
isUpdate,
wasCurrentBinary,
logManager,
configManager,
windowManager,
unpackFunction,
getLauncherPath,
removeDirectoryWithRetry,
cleanup,
koboldProcess,
} = options;
const tempPackedFilePath = join(installDir, `${item.name}.packed`);
const baseFilename = stripAssetExtensions(item.name);
const unpackedDirPath = join(installDir, baseFilename);
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 = getLauncherPath(unpackedDirPath);
if (!launcherPath || !existsSync(launcherPath)) {
const expectedLauncherName =
process.platform === 'win32'
? 'koboldcpp-launcher.exe'
: 'koboldcpp-launcher';
const newLauncherPath = join(unpackedDirPath, expectedLauncherName);
if (existsSync(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 && existsSync(launcherPath)) {
const currentBinary = configManager.getCurrentKoboldBinary();
if (!currentBinary || (isUpdate && wasCurrentBinary)) {
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}`,
};
}
}