feat: proxy support for the new downloader (#5795)

* feat: proxy support for the new downloader

* test: remove outdated test

* ci: clean up
This commit is contained in:
Louis 2025-07-17 23:10:21 +07:00 committed by GitHub
parent 92703bceb2
commit 8ca507c01c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1145 additions and 171 deletions

View File

@ -114,10 +114,6 @@ jobs:
with:
node-version: 20
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
# Clean cache, continue on error
- name: 'Cleanup cache'
shell: powershell
@ -154,10 +150,6 @@ jobs:
with:
node-version: 20
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
- name: 'Cleanup cache'
shell: powershell
continue-on-error: true
@ -202,10 +194,6 @@ jobs:
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 webkit2gtk-driver
- name: Install tauri-driver dependencies
run: |
cargo install tauri-driver --locked
- name: 'Cleanup cache'
continue-on-error: true
run: |

View File

@ -9,6 +9,7 @@ export enum Settings {
interface DownloadItem {
url: string
save_path: string
proxy?: Record<string, string | string[] | boolean>
}
type DownloadEvent = {
@ -30,10 +31,11 @@ export default class DownloadManager extends BaseExtension {
url: string,
savePath: string,
taskId: string,
proxyConfig: Record<string, string | string[] | boolean> = {},
onProgress?: (transferred: number, total: number) => void
) {
return await this.downloadFiles(
[{ url, save_path: savePath }],
[{ url, save_path: savePath, proxy: proxyConfig }],
taskId,
onProgress
)

View File

@ -1,16 +1,14 @@
import {
getJanDataFolderPath,
fs,
joinPath,
events,
} from '@janhq/core'
import { getJanDataFolderPath, fs, joinPath, events } from '@janhq/core'
import { invoke } from '@tauri-apps/api/core'
import { getProxyConfig } from './util'
// folder structure
// <Jan's data folder>/llamacpp/backends/<backend_version>/<backend_type>
// what should be available to the user for selection?
export async function listSupportedBackends(): Promise<{ version: string, backend: string }[]> {
export async function listSupportedBackends(): Promise<
{ version: string; backend: string }[]
> {
const sysInfo = await window.core.api.getSystemInfo()
const os_type = sysInfo.os_type
const arch = sysInfo.cpu.arch
@ -35,8 +33,7 @@ export async function listSupportedBackends(): Promise<{ version: string, backen
// not available yet, placeholder for future
else if (sysType == 'windows-aarch64') {
supportedBackends.push('win-arm64')
}
else if (sysType == 'linux-x86_64') {
} else if (sysType == 'linux-x86_64') {
supportedBackends.push('linux-noavx-x64')
if (features.avx) supportedBackends.push('linux-avx-x64')
if (features.avx2) supportedBackends.push('linux-avx2-x64')
@ -48,11 +45,9 @@ export async function listSupportedBackends(): Promise<{ version: string, backen
// not available yet, placeholder for future
else if (sysType === 'linux-aarch64') {
supportedBackends.push('linux-arm64')
}
else if (sysType === 'macos-x86_64') {
} else if (sysType === 'macos-x86_64') {
supportedBackends.push('macos-x64')
}
else if (sysType === 'macos-aarch64') {
} else if (sysType === 'macos-aarch64') {
supportedBackends.push('macos-arm64')
}
@ -82,39 +77,64 @@ export async function listSupportedBackends(): Promise<{ version: string, backen
return backendVersions
}
export async function getBackendDir(backend: string, version: string): Promise<string> {
export async function getBackendDir(
backend: string,
version: string
): Promise<string> {
const janDataFolderPath = await getJanDataFolderPath()
const backendDir = await joinPath([janDataFolderPath, 'llamacpp', 'backends', version, backend])
const backendDir = await joinPath([
janDataFolderPath,
'llamacpp',
'backends',
version,
backend,
])
return backendDir
}
export async function getBackendExePath(backend: string, version: string): Promise<string> {
export async function getBackendExePath(
backend: string,
version: string
): Promise<string> {
const sysInfo = await window.core.api.getSystemInfo()
const exe_name = sysInfo.os_type === 'windows' ? 'llama-server.exe' : 'llama-server'
const exe_name =
sysInfo.os_type === 'windows' ? 'llama-server.exe' : 'llama-server'
const backendDir = await getBackendDir(backend, version)
const exePath = await joinPath([backendDir, 'build', 'bin', exe_name])
return exePath
}
export async function isBackendInstalled(backend: string, version: string): Promise<boolean> {
export async function isBackendInstalled(
backend: string,
version: string
): Promise<boolean> {
const exePath = await getBackendExePath(backend, version)
const result = await fs.existsSync(exePath)
return result
}
export async function downloadBackend(backend: string, version: string): Promise<void> {
export async function downloadBackend(
backend: string,
version: string
): Promise<void> {
const janDataFolderPath = await getJanDataFolderPath()
const llamacppPath = await joinPath([janDataFolderPath, 'llamacpp'])
const backendDir = await getBackendDir(backend, version)
const libDir = await joinPath([llamacppPath, 'lib'])
const downloadManager = window.core.extensionManager.getByName('@janhq/download-extension')
const downloadManager = window.core.extensionManager.getByName(
'@janhq/download-extension'
)
// Get proxy configuration from localStorage
const proxyConfig = getProxyConfig()
const downloadItems = [
{
url: `https://github.com/menloresearch/llama.cpp/releases/download/${version}/llama-${version}-bin-${backend}.tar.gz`,
save_path: await joinPath([backendDir, 'backend.tar.gz']),
}
proxy: proxyConfig,
},
]
// also download CUDA runtime + cuBLAS + cuBLASLt if needed
@ -122,18 +142,24 @@ export async function downloadBackend(backend: string, version: string): Promise
downloadItems.push({
url: `https://github.com/menloresearch/llama.cpp/releases/download/${version}/cudart-llama-bin-linux-cu11.7-x64.tar.gz`,
save_path: await joinPath([libDir, 'cuda11.tar.gz']),
proxy: proxyConfig,
})
} else if (backend.includes('cu12.0') && !(await _isCudaInstalled('12.0'))) {
downloadItems.push({
url: `https://github.com/menloresearch/llama.cpp/releases/download/${version}/cudart-llama-bin-linux-cu12.0-x64.tar.gz`,
save_path: await joinPath([libDir, 'cuda12.tar.gz']),
proxy: proxyConfig,
})
}
const taskId = `llamacpp-${version}-${backend}`.replace(/\./g, '-')
const downloadType = 'Engine'
console.log(`Downloading backend ${backend} version ${version}: ${JSON.stringify(downloadItems)}`)
console.log(
`Downloading backend ${backend} version ${version}: ${JSON.stringify(
downloadItems
)}`
)
let downloadCompleted = false
try {
const onProgress = (transferred: number, total: number) => {
@ -212,13 +238,15 @@ async function _getSupportedFeatures() {
async function _fetchGithubReleases(
owner: string,
repo: string,
repo: string
): Promise<any[]> {
// by default, it's per_page=30 and page=1 -> the latest 30 releases
const url = `https://api.github.com/repos/${owner}/${repo}/releases`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch releases from ${url}: ${response.statusText}`)
throw new Error(
`Failed to fetch releases from ${url}: ${response.statusText}`
)
}
return response.json()
}
@ -257,21 +285,25 @@ async function _isCudaInstalled(version: string): Promise<boolean> {
// check for libraries shipped with Jan's llama.cpp extension
const janDataFolderPath = await getJanDataFolderPath()
const cudartPath = await joinPath([janDataFolderPath, 'llamacpp', 'lib', libname])
const cudartPath = await joinPath([
janDataFolderPath,
'llamacpp',
'lib',
libname,
])
return await fs.existsSync(cudartPath)
}
function compareVersions(a: string, b: string): number {
const aParts = a.split('.').map(Number);
const bParts = b.split('.').map(Number);
const len = Math.max(aParts.length, bParts.length);
const aParts = a.split('.').map(Number)
const bParts = b.split('.').map(Number)
const len = Math.max(aParts.length, bParts.length)
for (let i = 0; i < len; i++) {
const x = aParts[i] || 0;
const y = bParts[i] || 0;
if (x > y) return 1;
if (x < y) return -1;
const x = aParts[i] || 0
const y = bParts[i] || 0
if (x > y) return 1
if (x < y) return -1
}
return 0;
return 0
}

View File

@ -27,6 +27,7 @@ import {
getBackendExePath,
} from './backend'
import { invoke } from '@tauri-apps/api/core'
import { getProxyConfig } from './util'
type LlamacppConfig = {
version_backend: string
@ -61,6 +62,7 @@ type LlamacppConfig = {
interface DownloadItem {
url: string
save_path: string
proxy?: Record<string, string | string[] | boolean>
}
interface ModelConfig {
@ -607,7 +609,11 @@ export default class llamacpp_extension extends AIEngine {
// if URL, add to downloadItems, and return local path
if (path.startsWith('https://')) {
const localPath = `${modelDir}/${saveName}`
downloadItems.push({ url: path, save_path: localPath })
downloadItems.push({
url: path,
save_path: localPath,
proxy: getProxyConfig(),
})
return localPath
}

View File

@ -0,0 +1,546 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getProxyConfig } from './util'
// Mock localStorage
const mockLocalStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
// Mock console.log and console.error to avoid noise in tests
const mockConsole = {
log: vi.fn(),
error: vi.fn(),
}
// Set up mocks
beforeEach(() => {
// Clear all mocks
vi.clearAllMocks()
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
// Mock console
Object.defineProperty(console, 'log', {
value: mockConsole.log,
writable: true,
})
Object.defineProperty(console, 'error', {
value: mockConsole.error,
writable: true,
})
})
describe('getProxyConfig', () => {
it('should return null when no proxy configuration is stored', () => {
mockLocalStorage.getItem.mockReturnValue(null)
const result = getProxyConfig()
expect(result).toBeNull()
expect(mockLocalStorage.getItem).toHaveBeenCalledWith('setting-proxy-config')
})
it('should return null when proxy is disabled', () => {
const proxyConfig = {
state: {
proxyEnabled: false,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: 'user',
proxyPassword: 'pass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: 'localhost,127.0.0.1',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toBeNull()
})
it('should return null when proxy is enabled but no URL is provided', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: '',
proxyUsername: 'user',
proxyPassword: 'pass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toBeNull()
})
it('should return basic proxy configuration with SSL settings', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'https://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: true,
verifyProxySSL: false,
verifyProxyHostSSL: false,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'https://proxy.example.com:8080',
ignore_ssl: true,
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should include authentication when both username and password are provided', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: 'testuser',
proxyPassword: 'testpass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
username: 'testuser',
password: 'testpass',
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should not include authentication when only username is provided', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: 'testuser',
proxyPassword: '',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
expect(result?.username).toBeUndefined()
expect(result?.password).toBeUndefined()
})
it('should not include authentication when only password is provided', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: 'testpass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
expect(result?.username).toBeUndefined()
expect(result?.password).toBeUndefined()
})
it('should parse no_proxy list correctly', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: 'localhost, 127.0.0.1, *.example.com , specific.domain.com',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
no_proxy: ['localhost', '127.0.0.1', '*.example.com', 'specific.domain.com'],
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should handle empty no_proxy entries', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: 'localhost, , 127.0.0.1, ,',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
no_proxy: ['localhost', '127.0.0.1'],
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should handle mixed SSL verification settings', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'https://proxy.example.com:8080',
proxyUsername: 'user',
proxyPassword: 'pass',
proxyIgnoreSSL: true,
verifyProxySSL: false,
verifyProxyHostSSL: true,
verifyPeerSSL: false,
verifyHostSSL: true,
noProxy: 'localhost',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'https://proxy.example.com:8080',
username: 'user',
password: 'pass',
no_proxy: ['localhost'],
ignore_ssl: true,
verify_proxy_ssl: false,
verify_proxy_host_ssl: true,
verify_peer_ssl: false,
verify_host_ssl: true,
})
})
it('should handle all SSL verification settings as false', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: false,
verifyProxySSL: false,
verifyProxyHostSSL: false,
verifyPeerSSL: false,
verifyHostSSL: false,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'http://proxy.example.com:8080',
ignore_ssl: false,
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: false,
verify_host_ssl: false,
})
})
it('should handle all SSL verification settings as true', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'https://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: true,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'https://proxy.example.com:8080',
ignore_ssl: true,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should log proxy configuration details', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'https://proxy.example.com:8080',
proxyUsername: 'testuser',
proxyPassword: 'testpass',
proxyIgnoreSSL: true,
verifyProxySSL: false,
verifyProxyHostSSL: true,
verifyPeerSSL: false,
verifyHostSSL: true,
noProxy: 'localhost,127.0.0.1',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
getProxyConfig()
expect(mockConsole.log).toHaveBeenCalledWith('Using proxy configuration:', {
url: 'https://proxy.example.com:8080',
hasAuth: true,
noProxyCount: 2,
ignoreSSL: true,
verifyProxySSL: false,
verifyProxyHostSSL: true,
verifyPeerSSL: false,
verifyHostSSL: true,
})
})
it('should log proxy configuration without authentication', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'http://proxy.example.com:8080',
proxyUsername: '',
proxyPassword: '',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
getProxyConfig()
expect(mockConsole.log).toHaveBeenCalledWith('Using proxy configuration:', {
url: 'http://proxy.example.com:8080',
hasAuth: false,
noProxyCount: 0,
ignoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
})
})
it('should return null and log error when JSON parsing fails', () => {
mockLocalStorage.getItem.mockReturnValue('invalid-json')
const result = getProxyConfig()
expect(result).toBeNull()
expect(mockConsole.error).toHaveBeenCalledWith(
'Failed to parse proxy configuration:',
expect.any(SyntaxError)
)
})
it('should handle missing state property gracefully', () => {
const proxyConfig = {
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
expect(() => getProxyConfig()).toThrow()
expect(mockConsole.error).toHaveBeenCalledWith(
'Failed to parse proxy configuration:',
expect.any(Error)
)
})
it('should handle SOCKS proxy URLs', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'socks5://proxy.example.com:1080',
proxyUsername: 'user',
proxyPassword: 'pass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'socks5://proxy.example.com:1080',
username: 'user',
password: 'pass',
ignore_ssl: false,
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
it('should handle comprehensive proxy configuration', () => {
const proxyConfig = {
state: {
proxyEnabled: true,
proxyUrl: 'https://secure-proxy.example.com:8443',
proxyUsername: 'admin',
proxyPassword: 'secretpass',
proxyIgnoreSSL: true,
verifyProxySSL: false,
verifyProxyHostSSL: false,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24',
},
version: 0,
}
mockLocalStorage.getItem.mockReturnValue(JSON.stringify(proxyConfig))
const result = getProxyConfig()
expect(result).toEqual({
url: 'https://secure-proxy.example.com:8443',
username: 'admin',
password: 'secretpass',
no_proxy: ['localhost', '127.0.0.1', '*.local', '192.168.1.0/24'],
ignore_ssl: true,
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: true,
verify_host_ssl: true,
})
})
})

View File

@ -0,0 +1,68 @@
// Zustand proxy state structure
interface ProxyState {
proxyEnabled: boolean
proxyUrl: string
proxyUsername: string
proxyPassword: string
proxyIgnoreSSL: boolean
verifyProxySSL: boolean
verifyProxyHostSSL: boolean
verifyPeerSSL: boolean
verifyHostSSL: boolean
noProxy: string
}
export function getProxyConfig(): Record<
string,
string | string[] | boolean
> | null {
try {
// Retrieve proxy configuration from localStorage
const proxyConfigString = localStorage.getItem('setting-proxy-config')
if (!proxyConfigString) {
return null
}
const proxyConfigData = JSON.parse(proxyConfigString)
const proxyState: ProxyState = proxyConfigData.state
// Only return proxy config if proxy is enabled
if (!proxyState.proxyEnabled || !proxyState.proxyUrl) {
return null
}
const proxyConfig: Record<string, string | string[] | boolean> = {
url: proxyState.proxyUrl,
}
// Add username/password if both are provided
if (proxyState.proxyUsername && proxyState.proxyPassword) {
proxyConfig.username = proxyState.proxyUsername
proxyConfig.password = proxyState.proxyPassword
}
// Parse no_proxy list if provided
if (proxyState.noProxy) {
const noProxyList = proxyState.noProxy
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0)
if (noProxyList.length > 0) {
proxyConfig.no_proxy = noProxyList
}
}
// Add SSL verification settings
proxyConfig.ignore_ssl = proxyState.proxyIgnoreSSL
proxyConfig.verify_proxy_ssl = proxyState.verifyProxySSL
proxyConfig.verify_proxy_host_ssl = proxyState.verifyProxyHostSSL
proxyConfig.verify_peer_ssl = proxyState.verifyPeerSSL
proxyConfig.verify_host_ssl = proxyState.verifyHostSSL
return proxyConfig
} catch (error) {
console.error('Failed to parse proxy configuration:', error)
return null
}
}

View File

@ -45,6 +45,7 @@ uuid = { version = "1.7", features = ["v4"] }
env = "1.0.1"
futures-util = "0.3.31"
tokio-util = "0.7.14"
url = "2.5"
tauri-plugin-dialog = "2.2.1"
dirs = "6.0.0"
sysinfo = "0.34.2"

View File

@ -9,16 +9,31 @@ use tauri::{Emitter, State};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio_util::sync::CancellationToken;
use url::Url;
#[derive(Default)]
pub struct DownloadManagerState {
pub cancel_tokens: HashMap<String, CancellationToken>,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct ProxyConfig {
pub url: String,
pub username: Option<String>,
pub password: Option<String>,
pub no_proxy: Option<Vec<String>>, // List of domains to bypass proxy
pub ignore_ssl: Option<bool>, // Ignore SSL certificate verification
pub verify_proxy_ssl: Option<bool>, // Verify proxy SSL certificate
pub verify_proxy_host_ssl: Option<bool>, // Verify proxy host SSL certificate
pub verify_peer_ssl: Option<bool>, // Verify peer SSL certificate
pub verify_host_ssl: Option<bool>, // Verify host SSL certificate
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct DownloadItem {
pub url: String,
pub save_path: String,
pub proxy: Option<ProxyConfig>,
}
#[derive(serde::Serialize, Clone, Debug)]
@ -31,6 +46,130 @@ fn err_to_string<E: std::fmt::Display>(e: E) -> String {
format!("Error: {}", e)
}
fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
// Validate proxy URL format
if let Err(e) = Url::parse(&config.url) {
return Err(format!("Invalid proxy URL '{}': {}", config.url, e));
}
// Check if proxy URL has valid scheme
let url = Url::parse(&config.url).unwrap(); // Safe to unwrap as we just validated it
match url.scheme() {
"http" | "https" | "socks4" | "socks5" => {}
scheme => return Err(format!("Unsupported proxy scheme: {}", scheme)),
}
// Validate authentication credentials
if config.username.is_some() && config.password.is_none() {
return Err("Username provided without password".to_string());
}
if config.password.is_some() && config.username.is_none() {
return Err("Password provided without username".to_string());
}
// Validate no_proxy entries
if let Some(no_proxy) = &config.no_proxy {
for entry in no_proxy {
if entry.is_empty() {
return Err("Empty no_proxy entry".to_string());
}
// Basic validation for wildcard patterns
if entry.starts_with("*.") && entry.len() < 3 {
return Err(format!("Invalid wildcard pattern: {}", entry));
}
}
}
// SSL verification settings are all optional booleans, no validation needed
Ok(())
}
fn create_proxy_from_config(config: &ProxyConfig) -> Result<reqwest::Proxy, String> {
// Validate the configuration first
validate_proxy_config(config)?;
let mut proxy = reqwest::Proxy::all(&config.url).map_err(err_to_string)?;
// Add authentication if provided
if let (Some(username), Some(password)) = (&config.username, &config.password) {
proxy = proxy.basic_auth(username, password);
}
Ok(proxy)
}
fn should_bypass_proxy(url: &str, no_proxy: &[String]) -> bool {
if no_proxy.is_empty() {
return false;
}
// Parse the URL to get the host
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(_) => return false,
};
let host = match parsed_url.host_str() {
Some(h) => h,
None => return false,
};
// Check if host matches any no_proxy entry
for entry in no_proxy {
if entry == "*" {
return true;
}
// Simple wildcard matching
if entry.starts_with("*.") {
let domain = &entry[2..];
if host.ends_with(domain) {
return true;
}
} else if host == entry {
return true;
}
}
false
}
fn _get_client_for_item(
item: &DownloadItem,
header_map: &HeaderMap,
) -> Result<reqwest::Client, String> {
let mut client_builder = reqwest::Client::builder()
.http2_keep_alive_timeout(Duration::from_secs(15))
.default_headers(header_map.clone());
// Add proxy configuration if provided
if let Some(proxy_config) = &item.proxy {
// Handle SSL verification settings
if proxy_config.ignore_ssl.unwrap_or(false) {
client_builder = client_builder.danger_accept_invalid_certs(true);
log::info!("SSL certificate verification disabled for URL {}", item.url);
}
// Note: reqwest doesn't have fine-grained SSL verification controls
// for verify_proxy_ssl, verify_proxy_host_ssl, verify_peer_ssl, verify_host_ssl
// These settings are handled by the underlying TLS implementation
// Check if this URL should bypass proxy
let no_proxy = proxy_config.no_proxy.as_deref().unwrap_or(&[]);
if !should_bypass_proxy(&item.url, no_proxy) {
let proxy = create_proxy_from_config(proxy_config)?;
client_builder = client_builder.proxy(proxy);
log::info!("Using proxy {} for URL {}", proxy_config.url, item.url);
} else {
log::info!("Bypassing proxy for URL {}", item.url);
}
}
client_builder.build().map_err(err_to_string)
}
#[tauri::command]
pub async fn download_files(
app: tauri::AppHandle,
@ -130,19 +269,10 @@ async fn _download_files_internal(
let header_map = _convert_headers(headers).map_err(err_to_string)?;
// .read_timeout() and .connect_timeout() requires reqwest 0.12, which is not
// compatible with hyper 0.14
let client = reqwest::Client::builder()
.http2_keep_alive_timeout(Duration::from_secs(15))
// .read_timeout(Duration::from_secs(10)) // timeout between chunks
// .connect_timeout(Duration::from_secs(10)) // timeout for first connection
.default_headers(header_map.clone())
.build()
.map_err(err_to_string)?;
let total_size = {
let mut total_size = 0u64;
for item in items.iter() {
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
total_size += _get_file_size(&client, &item.url)
.await
.map_err(err_to_string)?;
@ -203,6 +333,7 @@ async fn _download_files_internal(
.map_err(err_to_string)?;
log::info!("Started downloading: {}", item.url);
let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?;
let mut download_delta = 0u64;
let resp = if resume {
let downloaded_size = tmp_save_path.metadata().map_err(err_to_string)?.len();
@ -311,3 +442,314 @@ async fn _get_maybe_resume(
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
// Helper function to create a minimal proxy config for testing
fn create_test_proxy_config(url: &str) -> ProxyConfig {
ProxyConfig {
url: url.to_string(),
username: None,
password: None,
no_proxy: None,
ignore_ssl: None,
verify_proxy_ssl: None,
verify_proxy_host_ssl: None,
verify_peer_ssl: None,
verify_host_ssl: None,
}
}
#[test]
fn test_validate_proxy_config() {
// Valid HTTP proxy
let config = ProxyConfig {
url: "http://proxy.example.com:8080".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
no_proxy: Some(vec!["localhost".to_string(), "*.example.com".to_string()]),
ignore_ssl: Some(true),
verify_proxy_ssl: Some(false),
verify_proxy_host_ssl: Some(false),
verify_peer_ssl: Some(false),
verify_host_ssl: Some(false),
};
assert!(validate_proxy_config(&config).is_ok());
// Valid HTTPS proxy
let config = ProxyConfig {
url: "https://proxy.example.com:8080".to_string(),
username: None,
password: None,
no_proxy: None,
ignore_ssl: None,
verify_proxy_ssl: None,
verify_proxy_host_ssl: None,
verify_peer_ssl: None,
verify_host_ssl: None,
};
assert!(validate_proxy_config(&config).is_ok());
// Valid SOCKS5 proxy
let config = ProxyConfig {
url: "socks5://proxy.example.com:1080".to_string(),
username: None,
password: None,
no_proxy: None,
ignore_ssl: None,
verify_proxy_ssl: None,
verify_proxy_host_ssl: None,
verify_peer_ssl: None,
verify_host_ssl: None,
};
assert!(validate_proxy_config(&config).is_ok());
// Invalid URL
let config = create_test_proxy_config("invalid-url");
assert!(validate_proxy_config(&config).is_err());
// Unsupported scheme
let config = create_test_proxy_config("ftp://proxy.example.com:21");
assert!(validate_proxy_config(&config).is_err());
// Username without password
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.username = Some("user".to_string());
assert!(validate_proxy_config(&config).is_err());
// Password without username
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.password = Some("pass".to_string());
assert!(validate_proxy_config(&config).is_err());
// Empty no_proxy entry
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.no_proxy = Some(vec!["".to_string()]);
assert!(validate_proxy_config(&config).is_err());
// Invalid wildcard pattern
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.no_proxy = Some(vec!["*.".to_string()]);
assert!(validate_proxy_config(&config).is_err());
}
#[test]
fn test_should_bypass_proxy() {
let no_proxy = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"*.example.com".to_string(),
"specific.domain.com".to_string(),
];
// Should bypass for localhost
assert!(should_bypass_proxy("http://localhost:8080/path", &no_proxy));
// Should bypass for 127.0.0.1
assert!(should_bypass_proxy("https://127.0.0.1:3000/api", &no_proxy));
// Should bypass for wildcard match
assert!(should_bypass_proxy(
"http://sub.example.com/path",
&no_proxy
));
assert!(should_bypass_proxy("https://api.example.com/v1", &no_proxy));
// Should bypass for specific domain
assert!(should_bypass_proxy(
"http://specific.domain.com/test",
&no_proxy
));
// Should NOT bypass for other domains
assert!(!should_bypass_proxy("http://other.com/path", &no_proxy));
assert!(!should_bypass_proxy("https://example.org/api", &no_proxy));
// Should bypass everything with "*"
let wildcard_no_proxy = vec!["*".to_string()];
assert!(should_bypass_proxy(
"http://any.domain.com/path",
&wildcard_no_proxy
));
// Empty no_proxy should not bypass anything
let empty_no_proxy = vec![];
assert!(!should_bypass_proxy(
"http://any.domain.com/path",
&empty_no_proxy
));
}
#[test]
fn test_create_proxy_from_config() {
// Valid configuration should work
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.username = Some("user".to_string());
config.password = Some("pass".to_string());
assert!(create_proxy_from_config(&config).is_ok());
// Invalid configuration should fail
let config = create_test_proxy_config("invalid-url");
assert!(create_proxy_from_config(&config).is_err());
}
#[test]
fn test_convert_headers() {
let mut headers = HashMap::new();
headers.insert("User-Agent".to_string(), "test-agent".to_string());
headers.insert("Authorization".to_string(), "Bearer token".to_string());
let header_map = _convert_headers(&headers).unwrap();
assert_eq!(header_map.len(), 2);
assert_eq!(header_map.get("User-Agent").unwrap(), "test-agent");
assert_eq!(header_map.get("Authorization").unwrap(), "Bearer token");
}
#[test]
fn test_proxy_ssl_verification_settings() {
// Test proxy config with SSL verification settings
let mut config = create_test_proxy_config("https://proxy.example.com:8080");
config.ignore_ssl = Some(true);
config.verify_proxy_ssl = Some(false);
config.verify_proxy_host_ssl = Some(false);
config.verify_peer_ssl = Some(true);
config.verify_host_ssl = Some(true);
// Should validate successfully
assert!(validate_proxy_config(&config).is_ok());
// Test with all SSL settings as false
config.ignore_ssl = Some(false);
config.verify_proxy_ssl = Some(false);
config.verify_proxy_host_ssl = Some(false);
config.verify_peer_ssl = Some(false);
config.verify_host_ssl = Some(false);
// Should still validate successfully
assert!(validate_proxy_config(&config).is_ok());
}
#[test]
fn test_proxy_config_with_mixed_ssl_settings() {
// Test with mixed SSL settings - ignore_ssl true, others false
let mut config = create_test_proxy_config("https://proxy.example.com:8080");
config.ignore_ssl = Some(true);
config.verify_proxy_ssl = Some(false);
config.verify_proxy_host_ssl = Some(true);
config.verify_peer_ssl = Some(false);
config.verify_host_ssl = Some(true);
assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok());
}
#[test]
fn test_proxy_config_ssl_defaults() {
// Test with no SSL settings (should use None defaults)
let config = create_test_proxy_config("https://proxy.example.com:8080");
assert_eq!(config.ignore_ssl, None);
assert_eq!(config.verify_proxy_ssl, None);
assert_eq!(config.verify_proxy_host_ssl, None);
assert_eq!(config.verify_peer_ssl, None);
assert_eq!(config.verify_host_ssl, None);
assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok());
}
#[test]
fn test_proxy_config_serialization() {
// Test that proxy config with SSL settings can be serialized/deserialized
let mut config = create_test_proxy_config("https://proxy.example.com:8080");
config.username = Some("user".to_string());
config.password = Some("pass".to_string());
config.ignore_ssl = Some(true);
config.verify_proxy_ssl = Some(false);
config.verify_proxy_host_ssl = Some(false);
config.verify_peer_ssl = Some(true);
config.verify_host_ssl = Some(true);
config.no_proxy = Some(vec!["localhost".to_string(), "*.example.com".to_string()]);
// Serialize to JSON
let json = serde_json::to_string(&config).unwrap();
// Deserialize from JSON
let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
// Verify all fields are preserved
assert_eq!(deserialized.url, config.url);
assert_eq!(deserialized.username, config.username);
assert_eq!(deserialized.password, config.password);
assert_eq!(deserialized.ignore_ssl, config.ignore_ssl);
assert_eq!(deserialized.verify_proxy_ssl, config.verify_proxy_ssl);
assert_eq!(deserialized.verify_proxy_host_ssl, config.verify_proxy_host_ssl);
assert_eq!(deserialized.verify_peer_ssl, config.verify_peer_ssl);
assert_eq!(deserialized.verify_host_ssl, config.verify_host_ssl);
assert_eq!(deserialized.no_proxy, config.no_proxy);
}
#[test]
fn test_download_item_with_ssl_proxy() {
// Test that DownloadItem can be created with SSL proxy configuration
let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080");
proxy_config.ignore_ssl = Some(true);
proxy_config.verify_proxy_ssl = Some(false);
let download_item = DownloadItem {
url: "https://example.com/file.zip".to_string(),
save_path: "downloads/file.zip".to_string(),
proxy: Some(proxy_config),
};
assert!(download_item.proxy.is_some());
let proxy = download_item.proxy.unwrap();
assert_eq!(proxy.ignore_ssl, Some(true));
assert_eq!(proxy.verify_proxy_ssl, Some(false));
}
#[test]
fn test_client_creation_with_ssl_settings() {
// Test client creation with SSL settings
let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080");
proxy_config.ignore_ssl = Some(true);
let download_item = DownloadItem {
url: "https://example.com/file.zip".to_string(),
save_path: "downloads/file.zip".to_string(),
proxy: Some(proxy_config),
};
let header_map = HeaderMap::new();
let result = _get_client_for_item(&download_item, &header_map);
// Should create client successfully even with SSL settings
assert!(result.is_ok());
}
#[test]
fn test_proxy_config_with_http_and_ssl_settings() {
// Test that SSL settings work with HTTP proxy (though not typically used)
let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.ignore_ssl = Some(true);
config.verify_proxy_ssl = Some(false);
assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok());
}
#[test]
fn test_proxy_config_with_socks_and_ssl_settings() {
// Test that SSL settings work with SOCKS proxy
let mut config = create_test_proxy_config("socks5://proxy.example.com:1080");
config.ignore_ssl = Some(false);
config.verify_peer_ssl = Some(true);
config.verify_host_ssl = Some(true);
assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok());
}
}

View File

@ -7,9 +7,8 @@ import { Switch } from '@/components/ui/switch'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { Input } from '@/components/ui/input'
import { EyeOff, Eye } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { useProxyConfig } from '@/hooks/useProxyConfig'
import { configurePullOptions } from '@/services/models'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.https_proxy as any)({
@ -45,62 +44,10 @@ function HTTPSProxy() {
const toggleProxy = useCallback(
(checked: boolean) => {
setProxyEnabled(checked)
configurePullOptions({
proxyUrl,
proxyEnabled: checked,
proxyUsername,
proxyPassword,
proxyIgnoreSSL,
verifyProxySSL,
verifyProxyHostSSL,
verifyPeerSSL,
verifyHostSSL,
noProxy,
})
},
[
noProxy,
proxyIgnoreSSL,
proxyPassword,
proxyUrl,
proxyUsername,
setProxyEnabled,
verifyHostSSL,
verifyPeerSSL,
verifyProxyHostSSL,
verifyProxySSL,
]
[setProxyEnabled]
)
useEffect(() => {
const handler = setTimeout(() => {
configurePullOptions({
proxyUrl,
proxyEnabled,
proxyUsername,
proxyPassword,
proxyIgnoreSSL,
verifyProxySSL,
verifyProxyHostSSL,
verifyPeerSSL,
verifyHostSSL,
noProxy,
})
}, 300)
return () => clearTimeout(handler)
}, [
noProxy,
proxyEnabled,
proxyIgnoreSSL,
proxyPassword,
proxyUrl,
proxyUsername,
verifyHostSSL,
verifyPeerSSL,
verifyProxyHostSSL,
verifyProxySSL,
])
return (
<div className="flex flex-col h-full">
<HeaderPage>

View File

@ -247,32 +247,4 @@ describe('models service', () => {
await expect(startModel(provider, model)).resolves.toBe(undefined)
})
})
describe('configurePullOptions', () => {
it('should configure proxy options', async () => {
const proxyOptions = {
proxyEnabled: true,
proxyUrl: 'http://proxy.com',
proxyUsername: 'user',
proxyPassword: 'pass',
proxyIgnoreSSL: false,
verifyProxySSL: true,
verifyProxyHostSSL: true,
verifyPeerSSL: true,
verifyHostSSL: true,
noProxy: '',
}
// Mock console.log to avoid output during tests
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await configurePullOptions(proxyOptions)
expect(consoleSpy).toHaveBeenCalledWith(
'Configuring proxy options:',
proxyOptions
)
consoleSpy.mockRestore()
})
})
})

View File

@ -158,33 +158,3 @@ export const startModel = async (
throw error
})
}
/**
* Configures the proxy options for model downloads.
* @param param0
*/
export const configurePullOptions = async ({
proxyEnabled,
proxyUrl,
proxyUsername,
proxyPassword,
proxyIgnoreSSL,
verifyProxySSL,
verifyProxyHostSSL,
verifyPeerSSL,
verifyHostSSL,
noProxy,
}: ProxyOptions) => {
console.log('Configuring proxy options:', {
proxyEnabled,
proxyUrl,
proxyUsername,
proxyPassword,
proxyIgnoreSSL,
verifyProxySSL,
verifyProxyHostSSL,
verifyPeerSSL,
verifyHostSSL,
noProxy,
})
}