diff --git a/.github/workflows/template-tauri-build-linux-x64.yml b/.github/workflows/template-tauri-build-linux-x64.yml index d7eeaefec..20663ea69 100644 --- a/.github/workflows/template-tauri-build-linux-x64.yml +++ b/.github/workflows/template-tauri-build-linux-x64.yml @@ -105,7 +105,8 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json if [ "${{ inputs.channel }}" != "stable" ]; then - jq '.bundle.linux.deb.files = {"usr/bin/bun": "resources/bin/bun"}' ./src-tauri/tauri.linux.conf.json > /tmp/tauri.linux.conf.json + jq '.bundle.linux.deb.files = {"usr/bin/bun": "resources/bin/bun", + "usr/lib/Jan-${{ inputs.channel }}/resources/lib/libvulkan.so": "resources/lib/libvulkan.so"}' ./src-tauri/tauri.linux.conf.json > /tmp/tauri.linux.conf.json mv /tmp/tauri.linux.conf.json ./src-tauri/tauri.linux.conf.json fi jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json diff --git a/Makefile b/Makefile index 682df784f..9ea30223a 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ endif dev: install-and-build yarn download:bin + yarn download:lib yarn dev # Linting @@ -40,6 +41,7 @@ lint: install-and-build # Testing test: lint yarn download:bin + yarn download:lib yarn test yarn copy:assets:tauri yarn build:icon @@ -51,6 +53,7 @@ build-and-publish: install-and-build # Build build: install-and-build + yarn download:lib yarn build clean: diff --git a/package.json b/package.json index fab394131..99bf81631 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev:web": "yarn workspace @janhq/web-app dev", "dev:tauri": "yarn build:icon && yarn copy:assets:tauri && cross-env IS_CLEAN=true tauri dev", "copy:assets:tauri": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\"", + "download:lib": "node ./scripts/download-lib.mjs", "download:bin": "node ./scripts/download-bin.mjs", "build:tauri:win32": "yarn download:bin && yarn tauri build", "build:tauri:linux": "yarn download:bin && ./src-tauri/build-utils/shim-linuxdeploy.sh yarn tauri build && ./src-tauri/build-utils/buildAppImage.sh", diff --git a/scripts/download-lib.mjs b/scripts/download-lib.mjs new file mode 100644 index 000000000..d2086b36e --- /dev/null +++ b/scripts/download-lib.mjs @@ -0,0 +1,86 @@ +console.log('Script is running') +// scripts/download-lib.mjs +import https from 'https' +import fs, { mkdirSync } from 'fs' +import os from 'os' +import path from 'path' +import { copySync } from 'cpx' + +function download(url, dest) { + return new Promise((resolve, reject) => { + console.log(`Downloading ${url} to ${dest}`) + const file = fs.createWriteStream(dest) + https + .get(url, (response) => { + console.log(`Response status code: ${response.statusCode}`) + if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + // Handle redirect + const redirectURL = response.headers.location + console.log(`Redirecting to ${redirectURL}`) + download(redirectURL, dest).then(resolve, reject) // Recursive call + return + } else if (response.statusCode !== 200) { + reject(`Failed to get '${url}' (${response.statusCode})`) + return + } + response.pipe(file) + file.on('finish', () => { + file.close(resolve) + }) + }) + .on('error', (err) => { + fs.unlink(dest, () => reject(err.message)) + }) + }) +} + +async function main() { + console.log('Starting main function') + const platform = os.platform() // 'darwin', 'linux', 'win32' + const arch = os.arch() // 'x64', 'arm64', etc. + + if (arch != 'x64') return + + let filename + if (platform == 'linux') + filename = 'libvulkan.so' + else if (platform == 'win32') + filename = 'vulkan-1.dll' + else + return + + const url = `https://catalog.jan.ai/${filename}` + + const libDir = 'src-tauri/resources/lib' + const tempDir = 'scripts/dist' + + try { + mkdirSync('scripts/dist') + } catch (err) { + // Expect EEXIST error if the directory already exists + } + + console.log(`Downloading libvulkan...`) + const savePath = path.join(tempDir, filename) + if (!fs.existsSync(savePath)) { + await download(url, savePath) + } + + // copy to tauri resources + try { + copySync(savePath, libDir) + } catch (err) { + // Expect EEXIST error + } + + console.log('Downloads completed.') +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) diff --git a/src-tauri/src/core/cmd.rs b/src-tauri/src/core/cmd.rs index 2ebe031f1..37937cfe0 100644 --- a/src-tauri/src/core/cmd.rs +++ b/src-tauri/src/core/cmd.rs @@ -303,8 +303,8 @@ pub fn get_user_home_path(app: AppHandle) -> String { return get_app_configurations(app.clone()).data_folder; } -/// Recursively copy a directory from src to dst -fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), io::Error> { +/// Recursively copy a directory from src to dst, excluding specified directories +fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf, exclude_dirs: &[&str]) -> Result<(), io::Error> { if !dst.exists() { fs::create_dir_all(dst)?; } @@ -316,7 +316,13 @@ fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), io::Error> { let dst_path = dst.join(entry.file_name()); if file_type.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; + // Skip excluded directories + if let Some(dir_name) = entry.file_name().to_str() { + if exclude_dirs.contains(&dir_name) { + continue; + } + } + copy_dir_recursive(&src_path, &dst_path, exclude_dirs)?; } else { fs::copy(&src_path, &dst_path)?; } @@ -354,7 +360,7 @@ pub fn change_app_data_folder( "New data folder cannot be a subdirectory of the current data folder".to_string(), ); } - copy_dir_recursive(¤t_data_folder, &new_data_folder_path) + copy_dir_recursive(¤t_data_folder, &new_data_folder_path, &[".uvx", ".npx"]) .map_err(|e| format!("Failed to copy data to new folder: {}", e))?; } else { log::info!("Current data folder does not exist, nothing to copy"); diff --git a/src-tauri/src/core/hardware/amd.rs b/src-tauri/src/core/hardware/amd.rs new file mode 100644 index 000000000..cbaea172d --- /dev/null +++ b/src-tauri/src/core/hardware/amd.rs @@ -0,0 +1,210 @@ +use super::{GpuInfo, GpuUsage}; + +impl GpuInfo { + #[cfg(not(target_os = "linux"))] + #[cfg(not(target_os = "windows"))] + pub fn get_usage_amd(&self) -> GpuUsage { + self.get_usage_unsupported() + } + + #[cfg(target_os = "linux")] + pub fn get_usage_amd(&self) -> GpuUsage { + use std::fs; + use std::path::Path; + + let device_id = match &self.vulkan_info { + Some(vulkan_info) => vulkan_info.device_id, + None => { + log::error!("get_usage_amd called without Vulkan info"); + return self.get_usage_unsupported(); + } + }; + + let closure = || -> Result> { + for subdir in fs::read_dir("/sys/class/drm")? { + let device_path = subdir?.path().join("device"); + + // Check if this is an AMD GPU by looking for amdgpu directory + if !device_path + .join("driver/module/drivers/pci:amdgpu") + .exists() + { + continue; + } + + // match device_id from Vulkan info + let this_device_id_str = fs::read_to_string(device_path.join("device"))?; + let this_device_id = u32::from_str_radix( + this_device_id_str + .strip_prefix("0x") + .unwrap_or(&this_device_id_str) + .trim(), + 16, + )?; + if this_device_id != device_id { + continue; + } + + let read_mem = |path: &Path| -> u64 { + fs::read_to_string(path) + .map(|content| content.trim().parse::().unwrap_or(0)) + .unwrap_or(0) + / 1024 + / 1024 // Convert bytes to MiB + }; + return Ok(GpuUsage { + uuid: self.uuid.clone(), + total_memory: read_mem(&device_path.join("mem_info_vram_total")), + used_memory: read_mem(&device_path.join("mem_info_vram_used")), + }); + } + Err(format!("GPU not found").into()) + }; + + match closure() { + Ok(usage) => usage, + Err(e) => { + log::error!( + "Failed to get memory usage for AMD GPU {:#x}: {}", + device_id, + e + ); + self.get_usage_unsupported() + } + } + } + + #[cfg(target_os = "windows")] + pub fn get_usage_amd(&self) -> GpuUsage { + use std::collections::HashMap; + + let memory_usage_map = windows_impl::get_gpu_usage().unwrap_or_else(|_| { + log::error!("Failed to get AMD GPU memory usage"); + HashMap::new() + }); + + match memory_usage_map.get(&self.name) { + Some(&used_memory) => GpuUsage { + uuid: self.uuid.clone(), + used_memory: used_memory as u64, + total_memory: self.total_memory, + }, + None => self.get_usage_unsupported(), + } + } +} + +// TODO: refactor this into a more egonomic API +#[cfg(target_os = "windows")] +mod windows_impl { + use libc; + use libloading::{Library, Symbol}; + use std::collections::HashMap; + use std::ffi::{c_char, c_int, c_void, CStr}; + use std::mem::{self, MaybeUninit}; + use std::ptr; + + // === FFI Struct Definitions === + #[repr(C)] + #[allow(non_snake_case)] + #[derive(Debug, Copy, Clone)] + pub struct AdapterInfo { + pub iSize: c_int, + pub iAdapterIndex: c_int, + pub strUDID: [c_char; 256], + pub iBusNumber: c_int, + pub iDeviceNumber: c_int, + pub iFunctionNumber: c_int, + pub iVendorID: c_int, + pub strAdapterName: [c_char; 256], + pub strDisplayName: [c_char; 256], + pub iPresent: c_int, + pub iExist: c_int, + pub strDriverPath: [c_char; 256], + pub strDriverPathExt: [c_char; 256], + pub strPNPString: [c_char; 256], + pub iOSDisplayIndex: c_int, + } + + type ADL_MAIN_MALLOC_CALLBACK = Option *mut c_void>; + type ADL_MAIN_CONTROL_CREATE = unsafe extern "C" fn(ADL_MAIN_MALLOC_CALLBACK, c_int) -> c_int; + type ADL_MAIN_CONTROL_DESTROY = unsafe extern "C" fn() -> c_int; + type ADL_ADAPTER_NUMBEROFADAPTERS_GET = unsafe extern "C" fn(*mut c_int) -> c_int; + type ADL_ADAPTER_ADAPTERINFO_GET = unsafe extern "C" fn(*mut AdapterInfo, c_int) -> c_int; + type ADL_ADAPTER_ACTIVE_GET = unsafe extern "C" fn(c_int, *mut c_int) -> c_int; + type ADL_GET_DEDICATED_VRAM_USAGE = + unsafe extern "C" fn(*mut c_void, c_int, *mut c_int) -> c_int; + + // === ADL Memory Allocator === + unsafe extern "C" fn adl_malloc(i_size: i32) -> *mut c_void { + libc::malloc(i_size as usize) + } + + pub fn get_gpu_usage() -> Result, Box> { + unsafe { + let lib = Library::new("atiadlxx.dll").or_else(|_| Library::new("atiadlxy.dll"))?; + + let adl_main_control_create: Symbol = + lib.get(b"ADL_Main_Control_Create")?; + let adl_main_control_destroy: Symbol = + lib.get(b"ADL_Main_Control_Destroy")?; + let adl_adapter_number_of_adapters_get: Symbol = + lib.get(b"ADL_Adapter_NumberOfAdapters_Get")?; + let adl_adapter_adapter_info_get: Symbol = + lib.get(b"ADL_Adapter_AdapterInfo_Get")?; + let adl_adapter_active_get: Symbol = + lib.get(b"ADL_Adapter_Active_Get")?; + let adl_get_dedicated_vram_usage: Symbol = + lib.get(b"ADL2_Adapter_DedicatedVRAMUsage_Get")?; + + // TODO: try to put nullptr here. then we don't need direct libc dep + if adl_main_control_create(Some(adl_malloc), 1) != 0 { + return Err("ADL initialization error!".into()); + } + // NOTE: after this call, we must call ADL_Main_Control_Destroy + // whenver we encounter an error + + let mut num_adapters: c_int = 0; + if adl_adapter_number_of_adapters_get(&mut num_adapters as *mut _) != 0 { + return Err("Cannot get number of adapters".into()); + } + + let mut vram_usages = HashMap::new(); + + if num_adapters > 0 { + let mut adapter_info: Vec = + vec![MaybeUninit::zeroed().assume_init(); num_adapters as usize]; + let ret = adl_adapter_adapter_info_get( + adapter_info.as_mut_ptr(), + mem::size_of::() as i32 * num_adapters, + ); + if ret != 0 { + return Err("Cannot get adapter info".into()); + } + + for adapter in adapter_info.iter() { + let mut is_active = 0; + adl_adapter_active_get(adapter.iAdapterIndex, &mut is_active); + + if is_active != 0 { + let mut vram_mb = 0; + let _ = adl_get_dedicated_vram_usage( + ptr::null_mut(), + adapter.iAdapterIndex, + &mut vram_mb, + ); + // NOTE: adapter name might not be unique? + let name = CStr::from_ptr(adapter.strAdapterName.as_ptr()) + .to_string_lossy() + .into_owned(); + vram_usages.insert(name, vram_mb); + } + } + } + + adl_main_control_destroy(); + + Ok(vram_usages) + } + } +} diff --git a/src-tauri/src/core/hardware.rs b/src-tauri/src/core/hardware/mod.rs similarity index 60% rename from src-tauri/src/core/hardware.rs rename to src-tauri/src/core/hardware/mod.rs index d643d92c0..ea2435cb0 100644 --- a/src-tauri/src/core/hardware.rs +++ b/src-tauri/src/core/hardware/mod.rs @@ -1,6 +1,10 @@ +pub mod amd; +pub mod nvidia; +pub mod vulkan; + use std::sync::OnceLock; use sysinfo::System; -use tauri; +use tauri::{path::BaseDirectory, Manager}; static SYSTEM_INFO: OnceLock = OnceLock::new(); @@ -139,12 +143,90 @@ impl CpuStaticInfo { } } +// https://devicehunt.com/all-pci-vendors +pub const VENDOR_ID_AMD: u32 = 0x1002; +pub const VENDOR_ID_NVIDIA: u32 = 0x10DE; +pub const VENDOR_ID_INTEL: u32 = 0x8086; + +#[derive(Debug, Clone)] +pub enum Vendor { + AMD, + NVIDIA, + Intel, + Unknown(u32), +} + +impl serde::Serialize for Vendor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Vendor::AMD => "AMD".serialize(serializer), + Vendor::NVIDIA => "NVIDIA".serialize(serializer), + Vendor::Intel => "Intel".serialize(serializer), + Vendor::Unknown(vendor_id) => { + let formatted = format!("Unknown (vendor_id: {})", vendor_id); + serializer.serialize_str(&formatted) + } + } + } +} + +impl Vendor { + pub fn from_vendor_id(vendor_id: u32) -> Self { + match vendor_id { + VENDOR_ID_AMD => Vendor::AMD, + VENDOR_ID_NVIDIA => Vendor::NVIDIA, + VENDOR_ID_INTEL => Vendor::Intel, + _ => Vendor::Unknown(vendor_id), + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct GpuInfo { + pub name: String, + pub total_memory: u64, + pub vendor: Vendor, + pub uuid: String, + pub driver_version: String, + pub nvidia_info: Option, + pub vulkan_info: Option, +} + +impl GpuInfo { + pub fn get_usage(&self) -> GpuUsage { + match self.vendor { + Vendor::NVIDIA => self.get_usage_nvidia(), + Vendor::AMD => self.get_usage_amd(), + _ => self.get_usage_unsupported(), + } + } + + pub fn get_usage_unsupported(&self) -> GpuUsage { + GpuUsage { + uuid: self.uuid.clone(), + used_memory: 0, + total_memory: 0, + } + } +} + #[derive(serde::Serialize, Clone, Debug)] pub struct SystemInfo { cpu: CpuStaticInfo, os_type: String, os_name: String, total_memory: u64, + gpus: Vec, +} + +#[derive(serde::Serialize, Clone, Debug)] +pub struct GpuUsage { + uuid: String, + used_memory: u64, + total_memory: u64, } #[derive(serde::Serialize, Clone, Debug)] @@ -152,15 +234,62 @@ pub struct SystemUsage { cpu: f32, used_memory: u64, total_memory: u64, + gpus: Vec, +} + +fn get_jan_libvulkan_path(app: tauri::AppHandle) -> String { + let lib_name = if cfg!(target_os = "windows") { + "vulkan-1.dll" + } else if cfg!(target_os = "linux") { + "libvulkan.so" + } else { + return "".to_string(); + }; + + // NOTE: this does not work in test mode (mock app) + match app.path().resolve( + format!("resources/lib/{}", lib_name), + BaseDirectory::Resource, + ) { + Ok(lib_path) => lib_path.to_string_lossy().to_string(), + Err(_) => "".to_string(), + } } #[tauri::command] -pub fn get_system_info() -> SystemInfo { +pub fn get_system_info(app: tauri::AppHandle) -> SystemInfo { SYSTEM_INFO .get_or_init(|| { let mut system = System::new(); system.refresh_memory(); + let mut gpu_map = std::collections::HashMap::new(); + for gpu in nvidia::get_nvidia_gpus() { + gpu_map.insert(gpu.uuid.clone(), gpu); + } + + // try system vulkan first + let paths = vec!["".to_string(), get_jan_libvulkan_path(app.clone())]; + let mut vulkan_gpus = vec![]; + for path in paths { + vulkan_gpus = vulkan::get_vulkan_gpus(&path); + if !vulkan_gpus.is_empty() { + break; + } + } + + for gpu in vulkan_gpus { + match gpu_map.get_mut(&gpu.uuid) { + // for existing NVIDIA GPUs, add Vulkan info + Some(nvidia_gpu) => { + nvidia_gpu.vulkan_info = gpu.vulkan_info; + } + None => { + gpu_map.insert(gpu.uuid.clone(), gpu); + } + } + } + let os_type = if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { @@ -177,13 +306,14 @@ pub fn get_system_info() -> SystemInfo { os_type: os_type.to_string(), os_name, total_memory: system.total_memory() / 1024 / 1024, // bytes to MiB + gpus: gpu_map.into_values().collect(), } }) .clone() } #[tauri::command] -pub fn get_system_usage() -> SystemUsage { +pub fn get_system_usage(app: tauri::AppHandle) -> SystemUsage { let mut system = System::new(); system.refresh_memory(); @@ -200,22 +330,30 @@ pub fn get_system_usage() -> SystemUsage { cpu: cpu_usage, used_memory: system.used_memory() / 1024 / 1024, // bytes to MiB, total_memory: system.total_memory() / 1024 / 1024, // bytes to MiB, + gpus: get_system_info(app.clone()) + .gpus + .iter() + .map(|gpu| gpu.get_usage()) + .collect(), } } #[cfg(test)] mod tests { use super::*; + use tauri::test::mock_app; #[test] fn test_system_info() { - let info = get_system_info(); + let app = mock_app(); + let info = get_system_info(app.handle().clone()); println!("System Static Info: {:?}", info); } #[test] fn test_system_usage() { - let usage = get_system_usage(); + let app = mock_app(); + let usage = get_system_usage(app.handle().clone()); println!("System Usage Info: {:?}", usage); } } diff --git a/src-tauri/src/core/hardware/nvidia.rs b/src-tauri/src/core/hardware/nvidia.rs new file mode 100644 index 000000000..6dced3448 --- /dev/null +++ b/src-tauri/src/core/hardware/nvidia.rs @@ -0,0 +1,120 @@ +use super::{GpuInfo, GpuUsage, Vendor}; +use nvml_wrapper::{error::NvmlError, Nvml}; +use std::sync::OnceLock; + +static NVML: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, serde::Serialize)] +pub struct NvidiaInfo { + pub index: u32, + pub compute_capability: String, +} + +fn get_nvml() -> Option<&'static Nvml> { + NVML.get_or_init(|| { + let result = Nvml::init().or_else(|e| { + // fallback + if cfg!(target_os = "linux") { + let lib_path = std::ffi::OsStr::new("libnvidia-ml.so.1"); + Nvml::builder().lib_path(lib_path).init() + } else { + Err(e) + } + }); + + // NvmlError doesn't implement Copy, so we have to store an Option in OnceLock + match result { + Ok(nvml) => Some(nvml), + Err(e) => { + log::error!("Unable to initialize NVML: {}", e); + None + } + } + }) + .as_ref() +} + +impl GpuInfo { + pub fn get_usage_nvidia(&self) -> GpuUsage { + let index = match self.nvidia_info { + Some(ref nvidia_info) => nvidia_info.index, + None => { + log::error!("get_usage_nvidia() called on non-NVIDIA GPU"); + return self.get_usage_unsupported(); + } + }; + let closure = || -> Result { + let nvml = get_nvml().ok_or(NvmlError::Unknown)?; + let device = nvml.device_by_index(index)?; + let mem_info = device.memory_info()?; + Ok(GpuUsage { + uuid: self.uuid.clone(), + used_memory: mem_info.used / 1024 / 1024, // bytes to MiB + total_memory: mem_info.total / 1024 / 1024, // bytes to MiB + }) + }; + closure().unwrap_or_else(|e| { + log::error!("Failed to get memory usage for NVIDIA GPU {}: {}", index, e); + self.get_usage_unsupported() + }) + } +} + +pub fn get_nvidia_gpus() -> Vec { + let closure = || -> Result, NvmlError> { + let nvml = get_nvml().ok_or(NvmlError::Unknown)?; + let num_gpus = nvml.device_count()?; + let driver_version = nvml.sys_driver_version()?; + + let mut gpus = Vec::with_capacity(num_gpus as usize); + for i in 0..num_gpus { + let device = nvml.device_by_index(i)?; + gpus.push(GpuInfo { + name: device.name()?, + total_memory: device.memory_info()?.total / 1024 / 1024, // bytes to MiB + vendor: Vendor::NVIDIA, + uuid: { + let mut uuid = device.uuid()?; + if uuid.starts_with("GPU-") { + uuid = uuid[4..].to_string(); + } + uuid + }, + driver_version: driver_version.clone(), + nvidia_info: Some(NvidiaInfo { + index: i, + compute_capability: { + let cc = device.cuda_compute_capability()?; + format!("{}.{}", cc.major, cc.minor) + }, + }), + vulkan_info: None, + }); + } + + Ok(gpus) + }; + + match closure() { + Ok(gpus) => gpus, + Err(e) => { + log::error!("Failed to get NVIDIA GPUs: {}", e); + vec![] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_nvidia_gpus() { + let gpus = get_nvidia_gpus(); + for (i, gpu) in gpus.iter().enumerate() { + println!("GPU {}:", i); + println!(" {:?}", gpu); + println!(" {:?}", gpu.get_usage()); + } + } +} diff --git a/src-tauri/src/core/hardware/vulkan.rs b/src-tauri/src/core/hardware/vulkan.rs new file mode 100644 index 000000000..cba3ed391 --- /dev/null +++ b/src-tauri/src/core/hardware/vulkan.rs @@ -0,0 +1,145 @@ +use super::{GpuInfo, Vendor}; +use ash::{vk, Entry}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct VulkanInfo { + pub index: u64, + pub device_type: String, + pub api_version: String, + pub device_id: u32, +} + +fn parse_uuid(bytes: &[u8; 16]) -> String { + format!( + "{:02x}{:02x}{:02x}{:02x}-\ + {:02x}{:02x}-\ + {:02x}{:02x}-\ + {:02x}{:02x}-\ + {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ) +} + +pub fn get_vulkan_gpus(lib_path: &str) -> Vec { + match get_vulkan_gpus_internal(lib_path) { + Ok(gpus) => gpus, + Err(e) => { + log::error!("Failed to get Vulkan GPUs: {:?}", e); + vec![] + } + } +} + +fn parse_c_string(buf: &[i8]) -> String { + unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_str() + .unwrap_or_default() + .to_string() +} + +fn get_vulkan_gpus_internal(lib_path: &str) -> Result, Box> { + let entry = if lib_path.is_empty() { + unsafe { Entry::load()? } + } else { + unsafe { Entry::load_from(lib_path)? } + }; + let app_info = vk::ApplicationInfo { + api_version: vk::make_api_version(0, 1, 1, 0), + ..Default::default() + }; + let create_info = vk::InstanceCreateInfo { + p_application_info: &app_info, + ..Default::default() + }; + let instance = unsafe { entry.create_instance(&create_info, None)? }; + + let mut device_info_list = vec![]; + + for (i, device) in unsafe { instance.enumerate_physical_devices()? } + .iter() + .enumerate() + { + // create a chain of properties struct for VkPhysicalDeviceProperties2(3) + // https://registry.khronos.org/vulkan/specs/latest/man/html/VkPhysicalDeviceProperties2.html + // props2 -> driver_props -> id_props + let mut id_props = vk::PhysicalDeviceIDProperties::default(); + let mut driver_props = vk::PhysicalDeviceDriverProperties { + p_next: &mut id_props as *mut _ as *mut std::ffi::c_void, + ..Default::default() + }; + let mut props2 = vk::PhysicalDeviceProperties2 { + p_next: &mut driver_props as *mut _ as *mut std::ffi::c_void, + ..Default::default() + }; + unsafe { + instance.get_physical_device_properties2(*device, &mut props2); + } + + let props = props2.properties; + if props.device_type == vk::PhysicalDeviceType::CPU { + continue; + } + + let device_info = GpuInfo { + name: parse_c_string(&props.device_name), + total_memory: unsafe { instance.get_physical_device_memory_properties(*device) } + .memory_heaps + .iter() + .filter(|heap| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL)) + .map(|heap| heap.size / (1024 * 1024)) + .sum(), + vendor: Vendor::from_vendor_id(props.vendor_id), + uuid: parse_uuid(&id_props.device_uuid), + driver_version: parse_c_string(&driver_props.driver_info), + nvidia_info: None, + vulkan_info: Some(VulkanInfo { + index: i as u64, + device_type: format!("{:?}", props.device_type), + api_version: format!( + "{}.{}.{}", + vk::api_version_major(props.api_version), + vk::api_version_minor(props.api_version), + vk::api_version_patch(props.api_version) + ), + device_id: props.device_id, + }), + }; + device_info_list.push(device_info); + } + + unsafe { + instance.destroy_instance(None); + } + + Ok(device_info_list) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_vulkan_gpus() { + let gpus = get_vulkan_gpus(""); + for (i, gpu) in gpus.iter().enumerate() { + println!("GPU {}:", i); + println!(" {:?}", gpu); + println!(" {:?}", gpu.get_usage()); + } + } +} diff --git a/src-tauri/tauri.bundle.windows.nsis.template b/src-tauri/tauri.bundle.windows.nsis.template index 6bbff3c09..2a216c4a6 100644 --- a/src-tauri/tauri.bundle.windows.nsis.template +++ b/src-tauri/tauri.bundle.windows.nsis.template @@ -696,6 +696,8 @@ Section Install ; Copy resources CreateDirectory "$INSTDIR\resources" CreateDirectory "$INSTDIR\resources\pre-install" + SetOutPath $INSTDIR + File /a "/oname=vulkan-1.dll" "D:\a\jan\jan\src-tauri\resources\lib\vulkan-1.dll" SetOutPath "$INSTDIR\resources\pre-install" File /nonfatal /a /r "D:\a\jan\jan\src-tauri\resources\pre-install\" SetOutPath $INSTDIR diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 584e8a28c..48411fd3b 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -10,7 +10,8 @@ }, "deb": { "files": { - "usr/bin/bun": "resources/bin/bun" + "usr/bin/bun": "resources/bin/bun", + "usr/lib/Jan/resources/lib/libvulkan.so": "resources/lib/libvulkan.so" } } } diff --git a/web-app/src/hooks/__tests__/useHardware.test.ts b/web-app/src/hooks/__tests__/useHardware.test.ts index 8c2143822..94c6ed50a 100644 --- a/web-app/src/hooks/__tests__/useHardware.test.ts +++ b/web-app/src/hooks/__tests__/useHardware.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useHardware, HardwareData, OS, RAM } from '../useHardware' +import { + useHardware, + HardwareData, + SystemUsage, + CPU, + GPU, + OS, + RAM, +} from '../useHardware' // Mock dependencies vi.mock('@/constants/localStorage', () => ({ @@ -35,6 +43,7 @@ describe('useHardware', () => { name: '', usage: 0, }, + gpus: [], os_type: '', os_name: '', total_memory: 0, @@ -43,7 +52,9 @@ describe('useHardware', () => { cpu: 0, used_memory: 0, total_memory: 0, + gpus: [], }) + expect(result.current.gpuLoading).toEqual({}) expect(result.current.pollingPaused).toBe(false) }) @@ -63,6 +74,26 @@ describe('useHardware', () => { available: 0, total: 0, }, + gpus: [ + { + name: 'NVIDIA RTX 3080', + total_memory: 10737418240, + vendor: 'NVIDIA', + uuid: 'GPU-12345', + driver_version: '470.57.02', + activated: true, + nvidia_info: { + index: 0, + compute_capability: '8.6', + }, + vulkan_info: { + index: 0, + device_id: 8704, + device_type: 'discrete', + api_version: '1.2.0', + }, + }, + ], os_type: 'linux', os_name: 'Ubuntu', total_memory: 17179869184, @@ -93,6 +124,37 @@ describe('useHardware', () => { expect(result.current.hardwareData.cpu).toEqual(testCPU) }) + it('should set GPUs data', () => { + const { result } = renderHook(() => useHardware()) + + const testGPUs = [ + { + name: 'NVIDIA RTX 3080', + total_memory: 10737418240, + vendor: 'NVIDIA', + uuid: 'GPU-12345', + driver_version: '470.57.02', + activated: true, + nvidia_info: { + index: 0, + compute_capability: '8.6', + }, + vulkan_info: { + index: 0, + device_id: 8704, + device_type: 'discrete', + api_version: '1.2.0', + }, + }, + ] + + act(() => { + result.current.setGPUs(testGPUs) + }) + + expect(result.current.hardwareData.gpus).toEqual(testGPUs) + }) + it('should update system usage', () => { const { result } = renderHook(() => useHardware()) @@ -100,6 +162,13 @@ describe('useHardware', () => { cpu: 45.2, used_memory: 8589934592, total_memory: 17179869184, + gpus: [ + { + uuid: 'GPU-12345', + used_memory: 2147483648, + total_memory: 10737418240, + }, + ], } act(() => { @@ -109,6 +178,48 @@ describe('useHardware', () => { expect(result.current.systemUsage).toEqual(testSystemUsage) }) + it('should manage GPU loading state', () => { + const { result } = renderHook(() => useHardware()) + + // First set up some GPU data so we have a UUID to work with + const testGPUs = [ + { + name: 'NVIDIA RTX 3080', + total_memory: 10737418240, + vendor: 'NVIDIA', + uuid: 'GPU-12345', + driver_version: '470.57.02', + activated: true, + nvidia_info: { + index: 0, + compute_capability: '8.6', + }, + vulkan_info: { + index: 0, + device_id: 8704, + device_type: 'discrete', + api_version: '1.2.0', + }, + }, + ] + + act(() => { + result.current.setGPUs(testGPUs) + }) + + act(() => { + result.current.setGpuLoading(0, true) + }) + + expect(result.current.gpuLoading['GPU-12345']).toBe(true) + + act(() => { + result.current.setGpuLoading(0, false) + }) + + expect(result.current.gpuLoading['GPU-12345']).toBe(false) + }) + it('should manage polling state', () => { const { result } = renderHook(() => useHardware()) @@ -160,4 +271,179 @@ describe('useHardware', () => { expect(result.current.hardwareData.ram).toEqual(ram) }) }) + + describe('updateGPU', () => { + it('should update specific GPU at index', () => { + const { result } = renderHook(() => useHardware()) + + const initialGpus: GPU[] = [ + { + name: 'GPU 1', + total_memory: 8192, + vendor: 'NVIDIA', + uuid: 'gpu-1', + driver_version: '1.0', + activated: false, + nvidia_info: { index: 0, compute_capability: '8.0' }, + vulkan_info: { + index: 0, + device_id: 1, + device_type: 'discrete', + api_version: '1.0', + }, + }, + { + name: 'GPU 2', + total_memory: 4096, + vendor: 'AMD', + uuid: 'gpu-2', + driver_version: '2.0', + activated: false, + nvidia_info: { index: 1, compute_capability: '7.0' }, + vulkan_info: { + index: 1, + device_id: 2, + device_type: 'discrete', + api_version: '1.0', + }, + }, + ] + + act(() => { + result.current.setGPUs(initialGpus) + }) + + const updatedGpu: GPU = { + ...initialGpus[0], + name: 'Updated GPU 1', + activated: true, + } + + act(() => { + result.current.updateGPU(0, updatedGpu) + }) + + expect(result.current.hardwareData.gpus[0].name).toBe('Updated GPU 1') + expect(result.current.hardwareData.gpus[0].activated).toBe(true) + expect(result.current.hardwareData.gpus[1]).toEqual(initialGpus[1]) + }) + + it('should handle invalid index gracefully', () => { + const { result } = renderHook(() => useHardware()) + + const initialGpus: GPU[] = [ + { + name: 'GPU 1', + total_memory: 8192, + vendor: 'NVIDIA', + uuid: 'gpu-1', + driver_version: '1.0', + activated: false, + nvidia_info: { index: 0, compute_capability: '8.0' }, + vulkan_info: { + index: 0, + device_id: 1, + device_type: 'discrete', + api_version: '1.0', + }, + }, + ] + + act(() => { + result.current.setGPUs(initialGpus) + }) + + const updatedGpu: GPU = { + ...initialGpus[0], + name: 'Updated GPU', + } + + act(() => { + result.current.updateGPU(5, updatedGpu) + }) + + expect(result.current.hardwareData.gpus[0]).toEqual(initialGpus[0]) + }) + }) + + describe('setHardwareData with GPU activation', () => { + it('should initialize GPUs as inactive when activated is not specified', () => { + const { result } = renderHook(() => useHardware()) + + const hardwareData: HardwareData = { + cpu: { + arch: 'x86_64', + core_count: 4, + extensions: [], + name: 'CPU', + usage: 0, + }, + gpus: [ + { + name: 'GPU 1', + total_memory: 8192, + vendor: 'NVIDIA', + uuid: 'gpu-1', + driver_version: '1.0', + nvidia_info: { index: 0, compute_capability: '8.0' }, + vulkan_info: { + index: 0, + device_id: 1, + device_type: 'discrete', + api_version: '1.0', + }, + }, + ], + os_type: 'windows', + os_name: 'Windows 11', + total_memory: 16384, + } + + act(() => { + result.current.setHardwareData(hardwareData) + }) + + expect(result.current.hardwareData.gpus[0].activated).toBe(false) + }) + + it('should preserve existing activation states when set', () => { + const { result } = renderHook(() => useHardware()) + + const hardwareData: HardwareData = { + cpu: { + arch: 'x86_64', + core_count: 4, + extensions: [], + name: 'CPU', + usage: 0, + }, + gpus: [ + { + name: 'GPU 1', + total_memory: 8192, + vendor: 'NVIDIA', + uuid: 'gpu-1', + driver_version: '1.0', + activated: true, + nvidia_info: { index: 0, compute_capability: '8.0' }, + vulkan_info: { + index: 0, + device_id: 1, + device_type: 'discrete', + api_version: '1.0', + }, + }, + ], + os_type: 'windows', + os_name: 'Windows 11', + total_memory: 16384, + } + + act(() => { + result.current.setHardwareData(hardwareData) + }) + + expect(result.current.hardwareData.gpus[0].activated).toBe(true) + }) + }) }) diff --git a/web-app/src/hooks/useHardware.ts b/web-app/src/hooks/useHardware.ts index 5467aea06..183fa2490 100644 --- a/web-app/src/hooks/useHardware.ts +++ b/web-app/src/hooks/useHardware.ts @@ -12,6 +12,30 @@ export interface CPU { instructions?: string[] // Cortex migration: ensure instructions data ready } +export interface GPUAdditionalInfo { + compute_cap: string + driver_version: string +} + +export interface GPU { + name: string + total_memory: number + vendor: string + uuid: string + driver_version: string + activated?: boolean + nvidia_info: { + index: number + compute_capability: string + } + vulkan_info: { + index: number + device_id: number + device_type: string + api_version: string + } +} + export interface OS { name: string version: string @@ -24,6 +48,7 @@ export interface RAM { export interface HardwareData { cpu: CPU + gpus: GPU[] os_type: string os_name: string total_memory: number @@ -35,6 +60,11 @@ export interface SystemUsage { cpu: number used_memory: number total_memory: number + gpus: { + uuid: string + used_memory: number + total_memory: number + }[] } // Default values @@ -46,6 +76,7 @@ const defaultHardwareData: HardwareData = { name: '', usage: 0, }, + gpus: [], os_type: '', os_name: '', total_memory: 0, @@ -55,6 +86,7 @@ const defaultSystemUsage: SystemUsage = { cpu: 0, used_memory: 0, total_memory: 0, + gpus: [], } interface HardwareStore { @@ -64,17 +96,22 @@ interface HardwareStore { // Update functions setCPU: (cpu: CPU) => void + setGPUs: (gpus: GPU[]) => void setOS: (os: OS) => void setRAM: (ram: RAM) => void // Update entire hardware data at once setHardwareData: (data: HardwareData) => void + // Update individual GPU + updateGPU: (index: number, gpu: GPU) => void + // Update RAM available updateSystemUsage: (usage: SystemUsage) => void // GPU loading state gpuLoading: { [index: number]: boolean } + setGpuLoading: (index: number, loading: boolean) => void // Polling control pollingPaused: boolean @@ -89,6 +126,13 @@ export const useHardware = create()( systemUsage: defaultSystemUsage, gpuLoading: {}, pollingPaused: false, + setGpuLoading: (index, loading) => + set((state) => ({ + gpuLoading: { + ...state.gpuLoading, + [state.hardwareData.gpus[index].uuid]: loading, + }, + })), pausePolling: () => set({ pollingPaused: true }), resumePolling: () => set({ pollingPaused: false }), @@ -100,6 +144,14 @@ export const useHardware = create()( }, })), + setGPUs: (gpus) => + set((state) => ({ + hardwareData: { + ...state.hardwareData, + gpus, + }, + })), + setOS: (os) => set((state) => ({ hardwareData: { @@ -129,9 +181,27 @@ export const useHardware = create()( available: 0, total: 0, }, + gpus: data.gpus.map((gpu) => ({ + ...gpu, + activated: gpu.activated ?? false, + })), }, }), + updateGPU: (index, gpu) => + set((state) => { + const newGPUs = [...state.hardwareData.gpus] + if (index >= 0 && index < newGPUs.length) { + newGPUs[index] = gpu + } + return { + hardwareData: { + ...state.hardwareData, + gpus: newGPUs, + }, + } + }), + updateSystemUsage: (systemUsage) => set(() => ({ systemUsage, diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index a5ce5ec26..ab19deccf 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -173,6 +173,7 @@ function General() { setSelectedNewPath(null) setIsDialogOpen(false) } catch (error) { + console.error(error) toast.error( error instanceof Error ? error.message