diff --git a/package.json b/package.json index dd6a276..a73c8d3 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,10 @@ "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.40.0", "@typescript-eslint/parser": "^8.40.0", - "@vitejs/plugin-react": "^5.0.0", + "@vitejs/plugin-react": "^5.0.1", "cross-env": "^10.0.0", "cspell": "^9.2.0", - "electron": "^37.3.0", + "electron": "^37.3.1", "electron-builder": "^26.0.12", "electron-vite": "^4.0.0", "eslint": "^9.33.0", @@ -75,7 +75,7 @@ "prettier": "^3.6.2", "rollup-plugin-visualizer": "^6.0.3", "typescript": "^5.9.2", - "vite": "^7.1.2", + "vite": "^7.1.3", "wait-on": "^8.0.4" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 4d89a24..29cb450 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,15 @@ import { DownloadScreen } from '@/components/screens/Download'; import { LaunchScreen } from '@/components/screens/Launch'; import { InterfaceScreen } from '@/components/screens/Interface'; import { UpdateDialog } from '@/components/UpdateDialog'; +import { UpdateAvailableModal } from '@/components/UpdateAvailableModal'; import { SettingsModal } from '@/components/settings/SettingsModal'; import { ScreenTransition } from '@/components/ScreenTransition'; import { AppHeader } from '@/components/AppHeader'; +import { useUpdateChecker } from '@/hooks/useUpdateChecker'; +import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { UI } from '@/constants'; import type { UpdateInfo } from '@/types'; +import type { DownloadItem } from '@/types/electron'; type Screen = 'download' | 'launch' | 'interface'; @@ -24,6 +28,19 @@ export const App = () => { const [isImageGenerationMode, setIsImageGenerationMode] = useState(false); + const { + updateInfo: binaryUpdateInfo, + showUpdateModal, + checkForUpdates, + dismissUpdate, + } = useUpdateChecker(); + + const { + handleDownload: sharedHandleDownload, + downloading, + downloadProgress, + } = useKoboldVersions(); + useEffect(() => { const checkInstallation = async () => { try { @@ -54,6 +71,12 @@ export const App = () => { } setHasInitialized(true); + + if (versions.length > 0) { + setTimeout(() => { + checkForUpdates(); + }, 2000); + } } catch (error) { window.electronAPI.logs.logError( 'Error checking installation:', @@ -86,7 +109,28 @@ export const App = () => { cleanupInstallDirListener(); cleanupVersionsListener(); }; - }, []); + }, [checkForUpdates]); + + const handleBinaryUpdate = async (download: DownloadItem) => { + try { + const downloadType = download.type === 'rocm' ? 'rocm' : 'asset'; + const success = await sharedHandleDownload( + downloadType, + download, + true, + true + ); + + if (success) { + dismissUpdate(); + } + } catch (error) { + window.electronAPI.logs.logError( + 'Failed to update binary:', + error as Error + ); + } + }; const handleDownloadComplete = async () => { try { @@ -235,6 +279,22 @@ export const App = () => { onAccept={handleUpdateAccept} /> )} + + {showUpdateModal && binaryUpdateInfo && ( + + )} ) => void; onMakeCurrent?: () => void; + onUpdate?: (e: MouseEvent) => void; } export const DownloadCard = ({ @@ -39,10 +42,35 @@ export const DownloadCard = ({ isDownloading = false, downloadProgress = 0, disabled = false, + hasUpdate = false, + newerVersion, onDownload, onMakeCurrent, + onUpdate, }: DownloadCardProps) => { const renderActionButton = () => { + if (hasUpdate && onUpdate) { + return ( + + ); + } + if (!isInstalled && onDownload) { return ( + + + + + + ); +}; diff --git a/src/components/screens/Download.tsx b/src/components/screens/Download.tsx index 301c72c..40dc234 100644 --- a/src/components/screens/Download.tsx +++ b/src/components/screens/Download.tsx @@ -52,7 +52,12 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => { setDownloadingAsset(type === 'asset' ? download!.name : null); try { - const success = await sharedHandleDownload(type, download); + const success = await sharedHandleDownload( + type, + download, + false, + false + ); if (success) { onDownloadComplete(); diff --git a/src/components/settings/VersionsTab.tsx b/src/components/settings/VersionsTab.tsx index 93d95d0..8fd71bb 100644 --- a/src/components/settings/VersionsTab.tsx +++ b/src/components/settings/VersionsTab.tsx @@ -21,6 +21,31 @@ import { getDisplayNameFromPath, formatFileSizeInMB } from '@/utils'; import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import type { InstalledVersion, ReleaseWithStatus } from '@/types/electron'; +const compareVersions = (versionA: string, versionB: string): number => { + const cleanVersion = (version: string): string => + version.replace(/^v/, '').replace(/[^0-9.]/g, ''); + + const parseVersion = (version: string): number[] => + cleanVersion(version) + .split('.') + .map((num) => parseInt(num, 10) || 0); + + const a = parseVersion(versionA); + const b = parseVersion(versionB); + const maxLength = Math.max(a.length, b.length); + + for (let i = 0; i < maxLength; i++) { + const aVal = a[i] || 0; + const bVal = b[i] || 0; + + if (aVal !== bVal) { + return aVal - bVal; + } + } + + return 0; +}; + interface VersionInfo { name: string; version: string; @@ -30,6 +55,8 @@ interface VersionInfo { downloadUrl?: string; installedPath?: string; isROCm?: boolean; + hasUpdate?: boolean; + newerVersion?: string; } export const VersionsTab = () => { @@ -68,6 +95,7 @@ export const VersionsTab = () => { if (currentBinaryPath && versions.length > 0) { const current = versions.find((v) => v.path === currentBinaryPath); + if (current) { setCurrentVersion(current); } else { @@ -79,6 +107,7 @@ export const VersionsTab = () => { } } else if (versions.length > 0) { setCurrentVersion(versions[0]); + await window.electronAPI.config.set( 'currentKoboldBinary', versions[0].path @@ -119,13 +148,15 @@ export const VersionsTab = () => { const getAllVersions = (): VersionInfo[] => { const versions: VersionInfo[] = []; + const processedInstalled = new Set(); availableDownloads.forEach((download) => { + const downloadBaseName = download.name + .replace(/\.(tar\.gz|zip|exe)$/i, '') + .replace(/\.packed$/, ''); + const installedVersion = installedVersions.find((v) => { const displayName = getDisplayNameFromPath(v); - const downloadBaseName = download.name - .replace(/\.(tar\.gz|zip|exe)$/i, '') - .replace(/\.packed$/, ''); return displayName === downloadBaseName; }); @@ -135,23 +166,43 @@ export const VersionsTab = () => { currentVersion.path === installedVersion.path ); - versions.push({ - name: download.name, - version: installedVersion?.version || download.version || 'unknown', - size: installedVersion ? undefined : download.size, - isInstalled: Boolean(installedVersion), - isCurrent, - downloadUrl: download.url, - installedPath: installedVersion?.path, - isROCm: download.type === 'rocm', - }); + if (installedVersion) { + processedInstalled.add(installedVersion.path); + + const hasUpdate = + compareVersions( + download.version || 'unknown', + installedVersion.version + ) > 0; + + versions.push({ + name: download.name, + version: installedVersion.version, + size: undefined, + isInstalled: true, + isCurrent, + downloadUrl: download.url, + installedPath: installedVersion.path, + isROCm: download.type === 'rocm', + hasUpdate, + newerVersion: hasUpdate ? download.version : undefined, + }); + } else { + versions.push({ + name: download.name, + version: download.version || 'unknown', + size: download.size, + isInstalled: false, + isCurrent: false, + downloadUrl: download.url, + isROCm: download.type === 'rocm', + }); + } }); installedVersions.forEach((installed) => { - const displayName = getDisplayNameFromPath(installed); - const existsInRemote = versions.some((v) => v.name === displayName); - - if (!existsInRemote) { + if (!processedInstalled.has(installed.path)) { + const displayName = getDisplayNameFromPath(installed); const isCurrent = Boolean( currentVersion && currentVersion.path === installed.path ); @@ -179,7 +230,12 @@ export const VersionsTab = () => { } const downloadType = download.type === 'rocm' ? 'rocm' : 'asset'; - const success = await sharedHandleDownload(downloadType, download); + const success = await sharedHandleDownload( + downloadType, + download, + false, + false + ); if (success) { await loadInstalledVersions(); @@ -189,6 +245,30 @@ export const VersionsTab = () => { } }; + const handleUpdate = async (version: VersionInfo) => { + try { + const download = availableDownloads.find((d) => d.name === version.name); + if (!download) { + throw new Error('Download not found'); + } + + const downloadType = download.type === 'rocm' ? 'rocm' : 'asset'; + const wasCurrentBinary = version.isCurrent; + const success = await sharedHandleDownload( + downloadType, + download, + true, + wasCurrentBinary + ); + + if (success) { + await loadInstalledVersions(); + } + } catch (error) { + window.electronAPI.logs.logError('Failed to update:', error as Error); + } + }; + const makeCurrent = async (version: VersionInfo) => { if (!version.installedPath) return; @@ -310,6 +390,8 @@ export const VersionsTab = () => { isDownloading={isDownloading} downloadProgress={downloadProgress[version.name]} disabled={downloading !== null} + hasUpdate={version.hasUpdate} + newerVersion={version.newerVersion} onDownload={ !version.isInstalled ? (e) => { @@ -318,8 +400,16 @@ export const VersionsTab = () => { } : undefined } + onUpdate={ + version.hasUpdate + ? (e) => { + e.stopPropagation(); + handleUpdate(version); + } + : undefined + } onMakeCurrent={ - version.isInstalled && !version.isCurrent + version.isInstalled && !version.isCurrent && !version.hasUpdate ? () => makeCurrent(version) : undefined } diff --git a/src/constants/index.ts b/src/constants/index.ts index 36d4595..366a484 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -14,6 +14,7 @@ export const GITHUB_API = { BASE_URL: 'https://api.github.com', KOBOLDCPP_REPO: 'LostRuins/koboldcpp', KOBOLDCPP_ROCM_REPO: 'YellowRoseCx/koboldcpp-rocm', + FRIENDLY_KOBOLD_REPO: 'lone-cloud/friendly-kobold', get LATEST_RELEASE_URL() { return `${this.BASE_URL}/repos/${this.KOBOLDCPP_REPO}/releases/latest`; }, diff --git a/src/hooks/useKoboldVersions.ts b/src/hooks/useKoboldVersions.ts index 3e42f8d..8a29f71 100644 --- a/src/hooks/useKoboldVersions.ts +++ b/src/hooks/useKoboldVersions.ts @@ -17,7 +17,9 @@ interface UseKoboldVersionsReturn { loadRemoteVersions: () => Promise; handleDownload: ( type: 'asset' | 'rocm', - item?: DownloadItem + item?: DownloadItem, + isUpdate?: boolean, + wasCurrentBinary?: boolean ) => Promise; setDownloading: (value: string | null) => void; setDownloadProgress: ( @@ -113,7 +115,12 @@ export const useKoboldVersions = (): UseKoboldVersionsReturn => { }, [platformInfo.platform]); const handleDownload = useCallback( - async (type: 'asset' | 'rocm', item?: DownloadItem): Promise => { + async ( + type: 'asset' | 'rocm', + item?: DownloadItem, + isUpdate = false, + wasCurrentBinary = false + ): Promise => { if (type === 'asset' && !item) return false; const downloadName = item?.name || 'download'; @@ -130,6 +137,8 @@ export const useKoboldVersions = (): UseKoboldVersionsReturn => { browser_download_url: item!.url, size: item!.size, created_at: new Date().toISOString(), + isUpdate, + wasCurrentBinary, }); return result.success !== false; diff --git a/src/hooks/useUpdateChecker.ts b/src/hooks/useUpdateChecker.ts new file mode 100644 index 0000000..07ccafb --- /dev/null +++ b/src/hooks/useUpdateChecker.ts @@ -0,0 +1,115 @@ +import { useState, useCallback } from 'react'; +import { getDisplayNameFromPath } from '@/utils/versionUtils'; +import type { InstalledVersion, DownloadItem } from '@/types/electron'; + +const compareVersions = (versionA: string, versionB: string): number => { + const cleanVersion = (version: string): string => + version.replace(/^v/, '').replace(/[^0-9.]/g, ''); + + const parseVersion = (version: string): number[] => + cleanVersion(version) + .split('.') + .map((num) => parseInt(num, 10) || 0); + + const a = parseVersion(versionA); + const b = parseVersion(versionB); + const maxLength = Math.max(a.length, b.length); + + for (let i = 0; i < maxLength; i++) { + const aVal = a[i] || 0; + const bVal = b[i] || 0; + + if (aVal !== bVal) { + return aVal - bVal; + } + } + + return 0; +}; + +interface UpdateInfo { + currentVersion: InstalledVersion; + availableUpdate: DownloadItem; +} + +export const useUpdateChecker = () => { + const [updateInfo, setUpdateInfo] = useState(null); + const [isChecking, setIsChecking] = useState(false); + const [showUpdateModal, setShowUpdateModal] = useState(false); + + const checkForUpdates = useCallback(async () => { + setIsChecking(true); + + try { + const [currentBinaryPath, installedVersions, releases, rocm] = + await Promise.all([ + window.electronAPI.config.get( + 'currentKoboldBinary' + ) as Promise, + window.electronAPI.kobold.getInstalledVersions(), + window.electronAPI.kobold.getLatestRelease(), + window.electronAPI.kobold.getROCmDownload(), + ]); + + if (!currentBinaryPath || installedVersions.length === 0) { + return; + } + + const currentVersion = installedVersions.find( + (v: InstalledVersion) => v.path === currentBinaryPath + ); + if (!currentVersion) { + return; + } + + const availableDownloads: DownloadItem[] = [...releases]; + if (rocm) { + availableDownloads.push(rocm); + } + + const currentDisplayName = getDisplayNameFromPath(currentVersion); + + const matchingDownload = availableDownloads.find( + (download: DownloadItem) => { + const downloadBaseName = download.name + .replace(/\.(tar\.gz|zip|exe)$/i, '') + .replace(/\.packed$/, ''); + return downloadBaseName === currentDisplayName; + } + ); + + if (matchingDownload && matchingDownload.version) { + const hasUpdate = + compareVersions(matchingDownload.version, currentVersion.version) > 0; + + if (hasUpdate) { + setUpdateInfo({ + currentVersion, + availableUpdate: matchingDownload, + }); + setShowUpdateModal(true); + } + } + } catch (error) { + window.electronAPI.logs.logError( + 'Failed to check for updates:', + error as Error + ); + } finally { + setIsChecking(false); + } + }, []); + + const dismissUpdate = useCallback(() => { + setShowUpdateModal(false); + setUpdateInfo(null); + }, []); + + return { + updateInfo, + showUpdateModal, + isChecking, + checkForUpdates, + dismissUpdate, + }; +}; diff --git a/src/main/managers/KoboldCppManager.ts b/src/main/managers/KoboldCppManager.ts index faf2d2e..adb21ee 100644 --- a/src/main/managers/KoboldCppManager.ts +++ b/src/main/managers/KoboldCppManager.ts @@ -10,6 +10,7 @@ import { writeFileSync, unlinkSync, } from 'fs'; +import { rm } from 'fs/promises'; import { dialog } from 'electron'; import { GitHubService } from '@/main/services/GitHubService'; import { ConfigManager } from '@/main/managers/ConfigManager'; @@ -23,6 +24,8 @@ interface GitHubAsset { browser_download_url: string; size: number; created_at: string; + isUpdate?: boolean; + wasCurrentBinary?: boolean; } interface GitHubRelease { @@ -85,6 +88,17 @@ export class KoboldCppManager { const baseFilename = asset.name.replace(/\.exe$/, ''); const unpackedDirPath = join(this.installDir, baseFilename); + if (asset.isUpdate && existsSync(unpackedDirPath)) { + try { + await rm(unpackedDirPath, { recursive: true, force: true }); + } catch (error) { + this.logManager.logError( + 'Failed to remove existing directory for update:', + error as Error + ); + } + } + const response = await fetch(asset.browser_download_url); if (!response.ok) { @@ -150,7 +164,7 @@ export class KoboldCppManager { const launcherPath = this.getLauncherPath(unpackedDirPath); if (launcherPath && existsSync(launcherPath)) { const currentBinary = this.configManager.getCurrentKoboldBinary(); - if (!currentBinary) { + if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) { this.configManager.setCurrentKoboldBinary(launcherPath); } diff --git a/src/main/managers/WindowManager.ts b/src/main/managers/WindowManager.ts index e40992f..77eb72e 100644 --- a/src/main/managers/WindowManager.ts +++ b/src/main/managers/WindowManager.ts @@ -8,6 +8,7 @@ import { } from 'electron'; import * as os from 'os'; import { join } from 'path'; +import { GITHUB_API } from '../../constants'; export class WindowManager { private mainWindow: BrowserWindow | null = null; @@ -255,7 +256,9 @@ export class WindowManager { { label: 'KoboldCpp Wiki', click: () => { - shell.openExternal('https://github.com/LostRuins/koboldcpp/wiki'); + shell.openExternal( + `https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/wiki` + ); }, }, { @@ -327,7 +330,9 @@ OS: ${osInfo}`; }); ipcMain.once('open-github', () => { - shell.openExternal('https://github.com/lone-cloud/friendly-kobold'); + shell.openExternal( + `https://github.com/${GITHUB_API.FRIENDLY_KOBOLD_REPO}` + ); }); ipcMain.once('close-about-dialog', () => { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 5a8bba6..c14e88a 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,8 @@ interface GitHubAsset { browser_download_url: string; size: number; created_at: string; + isUpdate?: boolean; + wasCurrentBinary?: boolean; } export interface GitHubRelease { diff --git a/yarn.lock b/yarn.lock index 06c7ef4..f9c66cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,7 +40,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.27.7, @babel/core@npm:^7.28.0": +"@babel/core@npm:^7.27.7, @babel/core@npm:^7.28.3": version: 7.28.3 resolution: "@babel/core@npm:7.28.3" dependencies: @@ -1661,10 +1661,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.30": - version: 1.0.0-beta.30 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.30" - checksum: 10c0/aff8b532cb9d82d94c9a4101fa12ecb10620ad47d52dbb9135a5c65bde1ad19895b41026b821f4d607083699239a5d0010198401b6a6a54ab6a10d0015302768 +"@rolldown/pluginutils@npm:1.0.0-beta.32": + version: 1.0.0-beta.32 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.32" + checksum: 10c0/ba3582fc3c35c8eb57b0df2d22d0733b1be83d37edcc258203364773f094f58fc0cb7a056d604603573a69dd0105a466506cad467f59074e1e53d0dc26191f06 languageName: node linkType: hard @@ -2191,19 +2191,19 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:^5.0.0": - version: 5.0.0 - resolution: "@vitejs/plugin-react@npm:5.0.0" +"@vitejs/plugin-react@npm:^5.0.1": + version: 5.0.1 + resolution: "@vitejs/plugin-react@npm:5.0.1" dependencies: - "@babel/core": "npm:^7.28.0" + "@babel/core": "npm:^7.28.3" "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" - "@rolldown/pluginutils": "npm:1.0.0-beta.30" + "@rolldown/pluginutils": "npm:1.0.0-beta.32" "@types/babel__core": "npm:^7.20.5" react-refresh: "npm:^0.17.0" peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/e5813839d319ab5dc1b90cab40b6c08388f26e456166ba9df10ffc3c3f4ecc594cec06715b5c93390bba56140ca5f68a18f2233f7d275d77e5bbfeb979e4fd9b + checksum: 10c0/2641171beedfc38edc5671abb47706906f9af2a79a6dfff4e946106c9550de4f83ccae41c164f3ee26a3edf07127ecc0e415fe5cddbf7abc71fbb2540016c27d languageName: node linkType: hard @@ -3615,16 +3615,16 @@ __metadata: languageName: node linkType: hard -"electron@npm:^37.3.0": - version: 37.3.0 - resolution: "electron@npm:37.3.0" +"electron@npm:^37.3.1": + version: 37.3.1 + resolution: "electron@npm:37.3.1" dependencies: "@electron/get": "npm:^2.0.0" "@types/node": "npm:^22.7.7" extract-zip: "npm:^2.0.1" bin: electron: cli.js - checksum: 10c0/6f08ca2955c65591368cb0dadbd637d86749040c8d4cd616d7265e1c71ece854f23c678b7cfc23746100327dcdb34f4815504745e97ca3ac2f40c72b951e1044 + checksum: 10c0/1b171804646f229768c29413629b70b968c4c06686913b69e8615da09c58d63a95817021c31ed3c3bdbba0ad360ad4469773b9dda2662884d7df2866559f95f1 languageName: node linkType: hard @@ -4304,7 +4304,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4, fdir@npm:^6.4.6": +"fdir@npm:^6.4.4, fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" peerDependencies: @@ -4433,10 +4433,10 @@ __metadata: "@types/react-dom": "npm:^19.1.7" "@typescript-eslint/eslint-plugin": "npm:^8.40.0" "@typescript-eslint/parser": "npm:^8.40.0" - "@vitejs/plugin-react": "npm:^5.0.0" + "@vitejs/plugin-react": "npm:^5.0.1" cross-env: "npm:^10.0.0" cspell: "npm:^9.2.0" - electron: "npm:^37.3.0" + electron: "npm:^37.3.1" electron-builder: "npm:^26.0.12" electron-vite: "npm:^4.0.0" eslint: "npm:^9.33.0" @@ -4457,7 +4457,7 @@ __metadata: rollup-plugin-visualizer: "npm:^6.0.3" systeminformation: "npm:^5.27.7" typescript: "npm:^5.9.2" - vite: "npm:^7.1.2" + vite: "npm:^7.1.3" wait-on: "npm:^8.0.4" languageName: unknown linkType: soft @@ -8180,12 +8180,12 @@ __metadata: languageName: node linkType: hard -"vite@npm:^7.1.2": - version: 7.1.2 - resolution: "vite@npm:7.1.2" +"vite@npm:^7.1.3": + version: 7.1.3 + resolution: "vite@npm:7.1.3" dependencies: esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.6" + fdir: "npm:^6.5.0" fsevents: "npm:~2.3.3" picomatch: "npm:^4.0.3" postcss: "npm:^8.5.6" @@ -8231,7 +8231,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/4ed825b20bc0f49db99cd382de9506b2721ccd47dcebd4a68e0ef65e3cdd2347fded52b306c34178308e0fd7fe78fd5ff517623002cb00710182ad3012c92ced + checksum: 10c0/a0aa418beab80673dc9a3e9d1fa49472955d6ef9d41a4c9c6bd402953f411346f612864dae267adfb2bb8ceeb894482369316ffae5816c84fd45990e352b727d languageName: node linkType: hard