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:
parent
b7b3eb9d19
commit
19cb1c96e0
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user