fix: llama.cpp backend download on windows (#5813)

* fix: llama.cpp backend download on windows

* test: add missing cases

* clean: linter

* fix: build
This commit is contained in:
Louis 2025-07-20 16:58:09 +07:00 committed by GitHub
parent b7b3eb9d19
commit 19cb1c96e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 188 additions and 63 deletions

View File

@ -1,6 +1,7 @@
import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core' import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { getProxyConfig } from './util' import { getProxyConfig } from './util'
import { dirname } from '@tauri-apps/api/path'
// folder structure // folder structure
// <Jan's data folder>/llamacpp/backends/<backend_version>/<backend_type> // <Jan's data folder>/llamacpp/backends/<backend_version>/<backend_type>
@ -100,7 +101,13 @@ export async function getBackendExePath(
const exe_name = const exe_name =
sysInfo.os_type === 'windows' ? 'llama-server.exe' : 'llama-server' sysInfo.os_type === 'windows' ? 'llama-server.exe' : 'llama-server'
const backendDir = await getBackendDir(backend, version) const backendDir = await getBackendDir(backend, version)
const exePath = await joinPath([backendDir, 'build', 'bin', exe_name]) let exePath: string
const buildDir = await joinPath([backendDir, 'build'])
if (await fs.existsSync(buildDir)) {
exePath = await joinPath([backendDir, 'build', 'bin', exe_name])
} else {
exePath = await joinPath([backendDir, exe_name])
}
return exePath return exePath
} }
@ -183,7 +190,7 @@ export async function downloadBackend(
// decompress the downloaded tar.gz files // decompress the downloaded tar.gz files
for (const { save_path } of downloadItems) { for (const { save_path } of downloadItems) {
if (save_path.endsWith('.tar.gz')) { if (save_path.endsWith('.tar.gz')) {
const parentDir = save_path.substring(0, save_path.lastIndexOf('/')) const parentDir = await dirname(save_path)
await invoke('decompress', { path: save_path, outputDir: parentDir }) await invoke('decompress', { path: save_path, outputDir: parentDir })
await fs.rm(save_path) await fs.rm(save_path)
} }

View File

@ -4,7 +4,7 @@ import {
getBackendDir, getBackendDir,
getBackendExePath, getBackendExePath,
isBackendInstalled, isBackendInstalled,
downloadBackend downloadBackend,
} from '../backend' } from '../backend'
// Mock the global fetch function // Mock the global fetch function
@ -22,9 +22,9 @@ describe('Backend functions', () => {
os_type: 'windows', os_type: 'windows',
cpu: { cpu: {
arch: 'x86_64', arch: 'x86_64',
extensions: ['avx', 'avx2'] extensions: ['avx', 'avx2'],
}, },
gpus: [] gpus: [],
}) })
// Mock GitHub releases // Mock GitHub releases
@ -33,21 +33,21 @@ describe('Backend functions', () => {
tag_name: 'v1.0.0', tag_name: 'v1.0.0',
assets: [ assets: [
{ name: 'llama-v1.0.0-bin-win-avx2-x64.tar.gz' }, { name: 'llama-v1.0.0-bin-win-avx2-x64.tar.gz' },
{ name: 'llama-v1.0.0-bin-win-avx-x64.tar.gz' } { name: 'llama-v1.0.0-bin-win-avx-x64.tar.gz' },
] ],
} },
] ]
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockReleases) json: () => Promise.resolve(mockReleases),
}) })
const result = await listSupportedBackends() const result = await listSupportedBackends()
expect(result).toEqual([ expect(result).toEqual([
{ version: 'v1.0.0', backend: 'win-avx2-x64' }, { version: 'v1.0.0', backend: 'win-avx2-x64' },
{ version: 'v1.0.0', backend: 'win-avx-x64' } { version: 'v1.0.0', backend: 'win-avx-x64' },
]) ])
}) })
@ -56,30 +56,26 @@ describe('Backend functions', () => {
os_type: 'macos', os_type: 'macos',
cpu: { cpu: {
arch: 'aarch64', arch: 'aarch64',
extensions: [] extensions: [],
}, },
gpus: [] gpus: [],
}) })
const mockReleases = [ const mockReleases = [
{ {
tag_name: 'v1.0.0', tag_name: 'v1.0.0',
assets: [ assets: [{ name: 'llama-v1.0.0-bin-macos-arm64.tar.gz' }],
{ name: 'llama-v1.0.0-bin-macos-arm64.tar.gz' } },
]
}
] ]
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockReleases) json: () => Promise.resolve(mockReleases),
}) })
const result = await listSupportedBackends() const result = await listSupportedBackends()
expect(result).toEqual([ expect(result).toEqual([{ version: 'v1.0.0', backend: 'macos-arm64' }])
{ version: 'v1.0.0', backend: 'macos-arm64' }
])
}) })
}) })
@ -88,48 +84,68 @@ describe('Backend functions', () => {
const { getJanDataFolderPath, joinPath } = await import('@janhq/core') const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan') vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64') vi.mocked(joinPath).mockResolvedValue(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64'
)
const result = await getBackendDir('win-avx2-x64', 'v1.0.0') const result = await getBackendDir('win-avx2-x64', 'v1.0.0')
expect(result).toBe('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64') expect(result).toBe('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64')
expect(joinPath).toHaveBeenCalledWith(['/path/to/jan', 'llamacpp', 'backends', 'v1.0.0', 'win-avx2-x64']) expect(joinPath).toHaveBeenCalledWith([
'/path/to/jan',
'llamacpp',
'backends',
'v1.0.0',
'win-avx2-x64',
])
}) })
}) })
describe('getBackendExePath', () => { describe('getBackendExePath', () => {
it('should return correct exe path for Windows', async () => { it('should return correct exe path for Windows', async () => {
window.core.api.getSystemInfo = vi.fn().mockResolvedValue({ window.core.api.getSystemInfo = vi.fn().mockResolvedValue({
os_type: 'windows' os_type: 'windows',
}) })
const { getJanDataFolderPath, joinPath } = await import('@janhq/core') const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan') vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath) vi.mocked(joinPath)
.mockResolvedValueOnce('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64') .mockResolvedValueOnce(
.mockResolvedValueOnce('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe') '/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe'
)
const result = await getBackendExePath('win-avx2-x64', 'v1.0.0') const result = await getBackendExePath('win-avx2-x64', 'v1.0.0')
expect(result).toBe('/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe') expect(result).toBe(
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe'
)
}) })
it('should return correct exe path for Linux/macOS', async () => { it('should return correct exe path for Linux/macOS', async () => {
window.core.api.getSystemInfo = vi.fn().mockResolvedValue({ window.core.api.getSystemInfo = vi.fn().mockResolvedValue({
os_type: 'linux' os_type: 'linux',
}) })
const { getJanDataFolderPath, joinPath } = await import('@janhq/core') const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan') vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath) vi.mocked(joinPath)
.mockResolvedValueOnce('/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64') .mockResolvedValueOnce(
.mockResolvedValueOnce('/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server') '/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64'
)
.mockResolvedValueOnce(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server'
)
const result = await getBackendExePath('linux-avx2-x64', 'v1.0.0') const result = await getBackendExePath('linux-avx2-x64', 'v1.0.0')
expect(result).toBe('/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server') expect(result).toBe(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server'
)
}) })
}) })
@ -158,20 +174,28 @@ describe('Backend functions', () => {
describe('downloadBackend', () => { describe('downloadBackend', () => {
it('should download backend successfully', async () => { it('should download backend successfully', async () => {
const mockDownloadManager = { const mockDownloadManager = {
downloadFiles: vi.fn().mockImplementation((items, taskId, onProgress) => { downloadFiles: vi
// Simulate successful download .fn()
onProgress(100, 100) .mockImplementation((items, taskId, onProgress) => {
return Promise.resolve() // Simulate successful download
}) onProgress(100, 100)
return Promise.resolve()
}),
} }
window.core.extensionManager.getByName = vi.fn().mockReturnValue(mockDownloadManager) window.core.extensionManager.getByName = vi
.fn()
.mockReturnValue(mockDownloadManager)
const { getJanDataFolderPath, joinPath, fs, events } = await import('@janhq/core') const { getJanDataFolderPath, joinPath, fs, events } = await import(
'@janhq/core'
)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan') vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath).mockImplementation((paths) => Promise.resolve(paths.join('/'))) vi.mocked(joinPath).mockImplementation((paths) =>
Promise.resolve(paths.join('/'))
)
vi.mocked(fs.rm).mockResolvedValue(undefined) vi.mocked(fs.rm).mockResolvedValue(undefined)
vi.mocked(invoke).mockResolvedValue(undefined) vi.mocked(invoke).mockResolvedValue(undefined)
@ -180,24 +204,118 @@ describe('Backend functions', () => {
expect(mockDownloadManager.downloadFiles).toHaveBeenCalled() expect(mockDownloadManager.downloadFiles).toHaveBeenCalled()
expect(events.emit).toHaveBeenCalledWith('onFileDownloadSuccess', { expect(events.emit).toHaveBeenCalledWith('onFileDownloadSuccess', {
modelId: 'llamacpp-v1-0-0-win-avx2-x64', modelId: 'llamacpp-v1-0-0-win-avx2-x64',
downloadType: 'Engine' downloadType: 'Engine',
}) })
}) })
it('should handle download errors', async () => { it('should handle download errors', async () => {
const mockDownloadManager = { const mockDownloadManager = {
downloadFiles: vi.fn().mockRejectedValue(new Error('Download failed')) downloadFiles: vi.fn().mockRejectedValue(new Error('Download failed')),
} }
window.core.extensionManager.getByName = vi.fn().mockReturnValue(mockDownloadManager) window.core.extensionManager.getByName = vi
.fn()
.mockReturnValue(mockDownloadManager)
const { events } = await import('@janhq/core') const { events } = await import('@janhq/core')
await expect(downloadBackend('win-avx2-x64', 'v1.0.0')).rejects.toThrow('Download failed') await expect(downloadBackend('win-avx2-x64', 'v1.0.0')).rejects.toThrow(
'Download failed'
)
expect(events.emit).toHaveBeenCalledWith('onFileDownloadError', { expect(events.emit).toHaveBeenCalledWith('onFileDownloadError', {
modelId: 'llamacpp-v1-0-0-win-avx2-x64', modelId: 'llamacpp-v1-0-0-win-avx2-x64',
downloadType: 'Engine' downloadType: 'Engine',
})
})
it('should correctly extract parent directory from Windows paths', async () => {
const { dirname } = await import('@tauri-apps/api/path')
// Mock dirname to simulate Windows path handling
vi.mocked(dirname).mockResolvedValue('C:\\path\\to\\backend')
const mockDownloadManager = {
downloadFiles: vi
.fn()
.mockImplementation((items, taskId, onProgress) => {
onProgress(100, 100)
return Promise.resolve()
}),
}
window.core.extensionManager.getByName = vi
.fn()
.mockReturnValue(mockDownloadManager)
const { getJanDataFolderPath, joinPath, fs, events } = await import(
'@janhq/core'
)
const { invoke } = await import('@tauri-apps/api/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('C:\\path\\to\\jan')
vi.mocked(joinPath).mockImplementation((paths) =>
Promise.resolve(paths.join('\\'))
)
vi.mocked(fs.rm).mockResolvedValue(undefined)
vi.mocked(invoke).mockResolvedValue(undefined)
await downloadBackend('win-avx2-x64', 'v1.0.0')
// Verify that dirname was called for path extraction
expect(dirname).toHaveBeenCalledWith(
'C:\\path\\to\\jan\\llamacpp\\backends\\v1.0.0\\win-avx2-x64\\backend.tar.gz'
)
// Verify decompress was called with correct parent directory
expect(invoke).toHaveBeenCalledWith('decompress', {
path: 'C:\\path\\to\\jan\\llamacpp\\backends\\v1.0.0\\win-avx2-x64\\backend.tar.gz',
outputDir: 'C:\\path\\to\\backend',
})
})
it('should correctly extract parent directory from Unix paths', async () => {
const { dirname } = await import('@tauri-apps/api/path')
// Mock dirname to simulate Unix path handling
vi.mocked(dirname).mockResolvedValue('/path/to/backend')
const mockDownloadManager = {
downloadFiles: vi
.fn()
.mockImplementation((items, taskId, onProgress) => {
onProgress(100, 100)
return Promise.resolve()
}),
}
window.core.extensionManager.getByName = vi
.fn()
.mockReturnValue(mockDownloadManager)
const { getJanDataFolderPath, joinPath, fs, events } = await import(
'@janhq/core'
)
const { invoke } = await import('@tauri-apps/api/core')
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
vi.mocked(joinPath).mockImplementation((paths) =>
Promise.resolve(paths.join('/'))
)
vi.mocked(fs.rm).mockResolvedValue(undefined)
vi.mocked(invoke).mockResolvedValue(undefined)
await downloadBackend('linux-avx2-x64', 'v1.0.0')
// Verify that dirname was called for path extraction
expect(dirname).toHaveBeenCalledWith(
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/backend.tar.gz'
)
// Verify decompress was called with correct parent directory
expect(invoke).toHaveBeenCalledWith('decompress', {
path: '/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/backend.tar.gz',
outputDir: '/path/to/backend',
}) })
}) })
}) })

View File

@ -169,7 +169,7 @@ pub fn decompress(app: tauri::AppHandle, path: &str, output_dir: &str) -> Result
let mut archive = tar::Archive::new(tar); let mut archive = tar::Archive::new(tar);
// NOTE: unpack() will not write files outside of output_dir // NOTE: unpack() will not write files outside of output_dir
// -> prevent path traversal // -> prevent path traversal
archive.unpack(output_dir).map_err(|e| e.to_string())?; archive.unpack(&output_dir_buf).map_err(|e| e.to_string())?;
} else { } else {
return Err("Unsupported file format. Only .tar.gz is supported.".to_string()); return Err("Unsupported file format. Only .tar.gz is supported.".to_string());
} }