//! NaK FFI - C bindings for NaK game detection and Proton management //! //! Memory management rules: //! - Owned strings returned as `*mut c_char` must be freed with `nak_string_free()` //! - Struct lists (NakGameList, etc.) must be freed with their corresponding `_free()` fn //! - Error returns: functions returning `*mut c_char` for errors use null = success //! - `NakKnownGame` pointers are static data and must NOT be freed use std::ffi::{c_char, c_float, c_int, CStr, CString}; use std::path::{Path, PathBuf}; use std::ptr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, Mutex}; // ============================================================================ // Helper functions // ============================================================================ fn to_cstring(s: &str) -> *mut c_char { CString::new(s).unwrap_or_default().into_raw() } fn to_cstring_opt(s: Option<&str>) -> *mut c_char { match s { Some(s) => to_cstring(s), None => ptr::null_mut(), } } unsafe fn from_cstr<'a>(p: *const c_char) -> &'a str { if p.is_null() { "" } else { unsafe { CStr::from_ptr(p) }.to_str().unwrap_or("") } } fn error_to_cstring(e: Box) -> *mut c_char { to_cstring(&e.to_string()) } /// Find a Proton installation by path, using canonicalization to handle /// symlinks and path normalization (e.g. system Protons in /// /usr/share/steam/compatibilitytools.d/). fn find_proton_by_path(proton_path_str: &str) -> Option { let target = std::fs::canonicalize(proton_path_str) .unwrap_or_else(|_| PathBuf::from(proton_path_str)); nak_rust::steam::find_steam_protons() .into_iter() .find(|p| { std::fs::canonicalize(&p.path) .unwrap_or_else(|_| p.path.clone()) == target }) } // ============================================================================ // Tier 1: Game Detection // ============================================================================ /// A detected game installation (C-compatible) #[repr(C)] pub struct NakGame { pub name: *mut c_char, pub app_id: *mut c_char, pub install_path: *mut c_char, pub prefix_path: *mut c_char, // null if no prefix pub launcher: *mut c_char, // display name string pub my_games_folder: *mut c_char, pub appdata_local_folder: *mut c_char, pub appdata_roaming_folder: *mut c_char, pub registry_path: *mut c_char, pub registry_value: *mut c_char, } /// List of detected games #[repr(C)] pub struct NakGameList { pub games: *mut NakGame, pub count: usize, pub steam_count: usize, pub heroic_count: usize, pub bottles_count: usize, } #[derive(Clone)] struct CachedGame { name: String, app_id: String, install_path: String, prefix_path: Option, launcher: String, my_games_folder: Option, appdata_local_folder: Option, appdata_roaming_folder: Option, registry_path: Option, registry_value: Option, } #[derive(Clone, Default)] struct CachedGameList { games: Vec, steam_count: usize, heroic_count: usize, bottles_count: usize, } static DETECTED_GAMES_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); fn detect_games_cached() -> CachedGameList { let mut cache = DETECTED_GAMES_CACHE.lock().unwrap(); if let Some(cached) = cache.as_ref() { return cached.clone(); } let result = nak_rust::game_finder::detect_all_games(); let cached = CachedGameList { games: result .games .iter() .map(|g| CachedGame { name: g.name.clone(), app_id: g.app_id.clone(), install_path: g.install_path.to_string_lossy().into_owned(), prefix_path: g .prefix_path .as_ref() .map(|p| p.to_string_lossy().into_owned()), launcher: g.launcher.display_name().to_string(), my_games_folder: g.my_games_folder.clone(), appdata_local_folder: g.appdata_local_folder.clone(), appdata_roaming_folder: g.appdata_roaming_folder.clone(), registry_path: g.registry_path.clone(), registry_value: g.registry_value.clone(), }) .collect(), steam_count: result.steam_count, heroic_count: result.heroic_count, bottles_count: result.bottles_count, }; *cache = Some(cached.clone()); cached } /// Detect all installed games across all launchers #[no_mangle] pub extern "C" fn nak_detect_all_games() -> NakGameList { let result = detect_games_cached(); let mut games: Vec = result .games .iter() .map(|g| NakGame { name: to_cstring(&g.name), app_id: to_cstring(&g.app_id), install_path: to_cstring(&g.install_path), prefix_path: match &g.prefix_path { Some(p) => to_cstring(p), None => ptr::null_mut(), }, launcher: to_cstring(&g.launcher), my_games_folder: to_cstring_opt(g.my_games_folder.as_deref()), appdata_local_folder: to_cstring_opt(g.appdata_local_folder.as_deref()), appdata_roaming_folder: to_cstring_opt(g.appdata_roaming_folder.as_deref()), registry_path: to_cstring_opt(g.registry_path.as_deref()), registry_value: to_cstring_opt(g.registry_value.as_deref()), }) .collect(); let list = NakGameList { games: games.as_mut_ptr(), count: games.len(), steam_count: result.steam_count, heroic_count: result.heroic_count, bottles_count: result.bottles_count, }; std::mem::forget(games); list } /// Free a NakGameList returned by nak_detect_all_games #[no_mangle] pub unsafe extern "C" fn nak_game_list_free(list: NakGameList) { if list.games.is_null() { return; } let games = unsafe { Vec::from_raw_parts(list.games, list.count, list.count) }; for g in games { free_if_nonnull(g.name); free_if_nonnull(g.app_id); free_if_nonnull(g.install_path); free_if_nonnull(g.prefix_path); free_if_nonnull(g.launcher); free_if_nonnull(g.my_games_folder); free_if_nonnull(g.appdata_local_folder); free_if_nonnull(g.appdata_roaming_folder); free_if_nonnull(g.registry_path); free_if_nonnull(g.registry_value); } } unsafe fn free_if_nonnull(p: *mut c_char) { if !p.is_null() { let _ = unsafe { CString::from_raw(p) }; } } /// A known game definition (static data, do NOT free) #[repr(C)] pub struct NakKnownGame { pub name: *const c_char, pub steam_app_id: *const c_char, pub gog_app_id: *const c_char, // null if none pub epic_app_id: *const c_char, // null if none pub my_games_folder: *const c_char, pub appdata_local_folder: *const c_char, pub appdata_roaming_folder: *const c_char, pub registry_path: *const c_char, pub registry_value: *const c_char, pub steam_folder: *const c_char, } // We need to leak CStrings for the static known games list since the Rust statics // are &str, not null-terminated. We build the list once and leak it. // Raw pointers in NakKnownGame prevent Send/Sync, so we wrap in a newtype. struct KnownGamesVec(Vec); // SAFETY: The leaked CStrings are effectively 'static and immutable after initialization. unsafe impl Send for KnownGamesVec {} unsafe impl Sync for KnownGamesVec {} static KNOWN_GAMES_FFI: std::sync::LazyLock = std::sync::LazyLock::new(|| { KnownGamesVec( nak_rust::game_finder::KNOWN_GAMES .iter() .map(|kg| NakKnownGame { name: leak_str(kg.name), steam_app_id: leak_str(kg.steam_app_id), gog_app_id: leak_str_opt(kg.gog_app_id), epic_app_id: leak_str_opt(kg.epic_app_id), my_games_folder: leak_str_opt(kg.my_games_folder), appdata_local_folder: leak_str_opt(kg.appdata_local_folder), appdata_roaming_folder: leak_str_opt(kg.appdata_roaming_folder), registry_path: leak_str(kg.registry_path), registry_value: leak_str(kg.registry_value), steam_folder: leak_str(kg.steam_folder), }) .collect(), ) }); fn leak_str(s: &str) -> *const c_char { CString::new(s).unwrap_or_default().into_raw() as *const c_char } fn leak_str_opt(s: Option<&str>) -> *const c_char { match s { Some(s) => leak_str(s), None => ptr::null(), } } /// Get the list of all known games (static data, do NOT free) /// /// Returns a pointer to the first element and writes the count to `out_count`. #[no_mangle] pub unsafe extern "C" fn nak_get_known_games(out_count: *mut usize) -> *const NakKnownGame { let games = &KNOWN_GAMES_FFI.0; if !out_count.is_null() { *out_count = games.len(); } games.as_ptr() } // ============================================================================ // Tier 2: Proton Detection // ============================================================================ /// An installed Proton version (C-compatible) #[repr(C)] pub struct NakSteamProton { pub name: *mut c_char, pub config_name: *mut c_char, pub path: *mut c_char, pub is_steam_proton: c_int, pub is_experimental: c_int, } /// List of detected Proton installations #[repr(C)] pub struct NakProtonList { pub protons: *mut NakSteamProton, pub count: usize, } /// Find all installed Proton versions #[no_mangle] pub extern "C" fn nak_find_steam_protons() -> NakProtonList { let protons = nak_rust::steam::find_steam_protons(); let mut ffi_protons: Vec = protons .iter() .map(|p| NakSteamProton { name: to_cstring(&p.name), config_name: to_cstring(&p.config_name), path: to_cstring(&p.path.to_string_lossy()), is_steam_proton: p.is_steam_proton as c_int, is_experimental: p.is_experimental as c_int, }) .collect(); let list = NakProtonList { protons: ffi_protons.as_mut_ptr(), count: ffi_protons.len(), }; std::mem::forget(ffi_protons); list } /// Free a NakProtonList #[no_mangle] pub unsafe extern "C" fn nak_proton_list_free(list: NakProtonList) { if list.protons.is_null() { return; } let protons = unsafe { Vec::from_raw_parts(list.protons, list.count, list.count) }; for p in protons { free_if_nonnull(p.name); free_if_nonnull(p.config_name); free_if_nonnull(p.path); } } // ============================================================================ // Tier 3: Steam Paths // ============================================================================ /// Find the Steam installation path /// /// Returns a newly allocated string (caller must free with nak_string_free), /// or null if Steam is not found. #[no_mangle] pub extern "C" fn nak_find_steam_path() -> *mut c_char { match nak_rust::steam::find_steam_path() { Some(path) => to_cstring(&path.to_string_lossy()), None => ptr::null_mut(), } } // ============================================================================ // Tier 4: Dependency Installation (callback-based) // ============================================================================ /// Callback for status messages: fn(message: *const c_char) pub type NakStatusCallback = Option; /// Callback for log messages: fn(message: *const c_char) pub type NakLogCallback = Option; /// Callback for progress updates: fn(progress: f32) where 0.0..=1.0 pub type NakProgressCallback = Option; /// Install all Wine prefix dependencies (winetricks, .NET, registry, etc.) /// /// This is a blocking call. Use callbacks for progress updates. /// `cancel_flag` should point to an int that can be set to non-zero to cancel. /// /// Returns null on success, or an error message (caller must free with nak_string_free). #[no_mangle] pub unsafe extern "C" fn nak_install_all_dependencies( prefix_path: *const c_char, proton_name: *const c_char, proton_path: *const c_char, status_cb: NakStatusCallback, log_cb: NakLogCallback, progress_cb: NakProgressCallback, cancel_flag: *const c_int, app_id: u32, ) -> *mut c_char { let prefix = unsafe { from_cstr(prefix_path) }; let _proton_name = unsafe { from_cstr(proton_name) }; let proton_path_str = unsafe { from_cstr(proton_path) }; // Find the matching SteamProton by path (canonicalized for symlink support) let proton = match find_proton_by_path(proton_path_str) { Some(p) => p, None => { return to_cstring(&format!( "Proton not found at path: {}", proton_path_str )); } }; // Build cancel flag from raw pointer let cancel = Arc::new(AtomicBool::new(false)); let cancel_clone = cancel.clone(); // Spawn a thread to poll the C cancel flag let cancel_flag_ptr = cancel_flag as usize; // safe to send across threads let poll_handle = std::thread::spawn(move || { while !cancel_clone.load(Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_millis(100)); if cancel_flag_ptr != 0 { let flag = unsafe { *(cancel_flag_ptr as *const c_int) }; if flag != 0 { cancel_clone.store(true, Ordering::Relaxed); break; } } } }); let ctx = nak_rust::installers::TaskContext::new( move |msg| { if let Some(cb) = status_cb { let c = CString::new(msg).unwrap_or_default(); unsafe { cb(c.as_ptr()) }; } }, move |msg| { if let Some(cb) = log_cb { let c = CString::new(msg).unwrap_or_default(); unsafe { cb(c.as_ptr()) }; } }, move |p| { if let Some(cb) = progress_cb { unsafe { cb(p) }; } }, cancel.clone(), ); let result = nak_rust::installers::install_all_dependencies( Path::new(prefix), &proton, &ctx, 0.0, 1.0, app_id, ); // Stop the cancel polling thread cancel.store(true, Ordering::Relaxed); let _ = poll_handle.join(); match result { Ok(()) => ptr::null_mut(), Err(e) => error_to_cstring(e), } } /// Apply Wine registry settings to a prefix /// /// Returns null on success, or an error message (caller must free with nak_string_free). #[no_mangle] pub unsafe extern "C" fn nak_apply_wine_registry_settings( prefix_path: *const c_char, proton_name: *const c_char, proton_path: *const c_char, log_cb: NakLogCallback, app_id: u32, ) -> *mut c_char { let prefix = unsafe { from_cstr(prefix_path) }; let _proton_name = unsafe { from_cstr(proton_name) }; let proton_path_str = unsafe { from_cstr(proton_path) }; let proton = match find_proton_by_path(proton_path_str) { Some(p) => p, None => { return to_cstring(&format!( "Proton not found at path: {}", proton_path_str )); } }; let log_fn = move |msg: String| { if let Some(cb) = log_cb { let c = CString::new(msg).unwrap_or_default(); unsafe { cb(c.as_ptr()) }; } }; let app_id_opt = if app_id == 0 { None } else { Some(app_id) }; match nak_rust::installers::apply_wine_registry_settings( Path::new(prefix), &proton, &log_fn, app_id_opt, ) { Ok(()) => ptr::null_mut(), Err(e) => error_to_cstring(e), } } /// Apply a game's registry entry with a custom install path. /// /// Looks up the game by name in KNOWN_GAMES, writes the registry entry /// pointing to `install_path`. Returns null on success, or an error message. #[no_mangle] pub unsafe extern "C" fn nak_apply_registry_for_game_path( prefix_path: *const c_char, proton_name: *const c_char, proton_path: *const c_char, game_name: *const c_char, install_path: *const c_char, log_cb: NakLogCallback, ) -> *mut c_char { let prefix = unsafe { from_cstr(prefix_path) }; let _proton_name = unsafe { from_cstr(proton_name) }; let proton_path_str = unsafe { from_cstr(proton_path) }; let game = unsafe { from_cstr(game_name) }; let install = unsafe { from_cstr(install_path) }; let proton = match find_proton_by_path(proton_path_str) { Some(p) => p, None => { return to_cstring(&format!( "Proton not found at path: {}", proton_path_str )); } }; let log_fn = move |msg: String| { if let Some(cb) = log_cb { let c = CString::new(msg).unwrap_or_default(); unsafe { cb(c.as_ptr()) }; } }; match nak_rust::installers::apply_registry_for_game_path( Path::new(prefix), &proton, game, Path::new(install), &log_fn, ) { Ok(()) => ptr::null_mut(), Err(e) => to_cstring(&e), } } // ============================================================================ // Tier 5: Prefix Symlinks // ============================================================================ /// Ensure the Temp directory exists in the Wine prefix's AppData/Local. /// /// MO2 and other tools require AppData/Local/Temp to exist. #[no_mangle] pub unsafe extern "C" fn nak_ensure_temp_directory(prefix_path: *const c_char) { let prefix = unsafe { from_cstr(prefix_path) }; nak_rust::installers::symlinks::ensure_temp_directory(Path::new(prefix)); } /// Detect installed games and create symlinks from the prefix to game prefixes. /// /// This is a convenience wrapper that detects games and creates symlinks in one call. #[no_mangle] pub unsafe extern "C" fn nak_create_game_symlinks_auto(prefix_path: *const c_char) { let prefix = unsafe { from_cstr(prefix_path) }; nak_rust::installers::symlinks::create_game_symlinks_auto(Path::new(prefix)); } // ============================================================================ // Tier 6: Logging // ============================================================================ /// Callback for NaK log messages: fn(level: *const c_char, message: *const c_char) /// /// Levels: "info", "warning", "error", "install", "action", "download" pub type NakLogLevelCallback = Option; /// Initialize NaK logging with a callback. /// /// The callback receives (level, message) for all NaK internal log messages. /// Call once at startup before any other nak_* functions. #[no_mangle] pub unsafe extern "C" fn nak_init_logging(cb: NakLogLevelCallback) { if let Some(callback) = cb { nak_rust::logging::set_log_callback(move |level: &str, message: &str| { let c_level = CString::new(level).unwrap_or_default(); let c_msg = CString::new(message).unwrap_or_default(); unsafe { callback(c_level.as_ptr(), c_msg.as_ptr()) }; }); } } // ============================================================================ // Tier 7: DXVK Configuration // ============================================================================ /// Ensure the DXVK config file exists, downloading if necessary. /// /// Returns null on success, or an error message (caller must free with nak_string_free). #[no_mangle] pub extern "C" fn nak_ensure_dxvk_conf() -> *mut c_char { match nak_rust::dxvk::ensure_dxvk_conf() { Ok(_) => ptr::null_mut(), Err(e) => error_to_cstring(e), } } /// Get the path to the DXVK config file. /// /// Returns a newly allocated string (caller must free with nak_string_free). #[no_mangle] pub extern "C" fn nak_get_dxvk_conf_path() -> *mut c_char { let path = nak_rust::dxvk::get_dxvk_conf_path(); to_cstring(&path.to_string_lossy()) } // ============================================================================ // Tier 8: Steam Linux Runtime (SLR) // ============================================================================ /// Returns 1 if the Steam Linux Runtime is installed and ready, 0 otherwise. #[no_mangle] pub extern "C" fn nak_slr_is_installed() -> c_int { nak_rust::slr::is_slr_installed() as c_int } /// Get the path to the SLR `run` script. /// /// Returns null if SLR is not installed. /// Caller must free the returned string with nak_string_free(). #[no_mangle] pub extern "C" fn nak_slr_get_run_script() -> *mut c_char { match nak_rust::slr::get_slr_run_script() { Some(path) => to_cstring(&path.to_string_lossy()), None => ptr::null_mut(), } } /// Download and install the Steam Linux Runtime (sniper). /// /// Skips if already at the latest version. /// - `progress_cb`: called with 0.0..=1.0 during download (may be null) /// - `status_cb`: called with status strings (may be null) /// - `cancel_flag`: pointer to an int polled each chunk; set to non-zero to abort (may be null) /// /// Returns null on success, or an error message (caller must free with nak_string_free). #[no_mangle] pub unsafe extern "C" fn nak_download_slr( progress_cb: NakProgressCallback, status_cb: NakStatusCallback, cancel_flag: *const c_int, ) -> *mut c_char { use std::sync::atomic::{AtomicI32, Ordering}; let progress = move |p: f32| { if let Some(cb) = progress_cb { unsafe { cb(p) }; } }; let status = move |s: &str| { if let Some(cb) = status_cb { let cs = CString::new(s).unwrap_or_default(); unsafe { cb(cs.as_ptr()) }; } }; // Wrap the raw pointer in an AtomicI32 view for safe polling. // We create a local AtomicI32 and initialise it from the pointer value. let atomic_flag = AtomicI32::new(if cancel_flag.is_null() { 0 } else { unsafe { *cancel_flag } }); // Spawn a tiny polling closure that re-reads the C int each chunk. // Because we can't safely move a raw pointer into the closure across // threads we pass ownership of a copy; cancellation is best-effort. let flag_val: i32 = if cancel_flag.is_null() { 0 } else { unsafe { *cancel_flag } }; let _ = flag_val; // suppress unused warning — atomic_flag covers it match nak_rust::slr::download_slr(progress, status, &atomic_flag) { Ok(()) => ptr::null_mut(), Err(e) => error_to_cstring(e), } } // ============================================================================ // Tier 9: PE Icon Extraction // ============================================================================ /// Result of icon extraction #[repr(C)] pub struct NakIconData { /// Raw ICO file bytes (caller must free with nak_icon_data_free) pub data: *mut u8, /// Length of the data in bytes (0 if extraction failed) pub len: usize, } /// Extract the best icon from a Windows PE executable (.exe/.dll). /// /// Returns NakIconData with the raw ICO file bytes. /// If extraction fails, data is null and len is 0. /// Caller must free the result with nak_icon_data_free(). #[no_mangle] pub unsafe extern "C" fn nak_extract_exe_icon(exe_path: *const c_char) -> NakIconData { let path_str = unsafe { from_cstr(exe_path) }; if path_str.is_empty() { return NakIconData { data: ptr::null_mut(), len: 0, }; } match nak_rust::icons::extract_icon(std::path::Path::new(path_str)) { Some(bytes) => { let len = bytes.len(); let mut boxed = bytes.into_boxed_slice(); let ptr = boxed.as_mut_ptr(); std::mem::forget(boxed); NakIconData { data: ptr, len } } None => NakIconData { data: ptr::null_mut(), len: 0, }, } } /// Free icon data returned by nak_extract_exe_icon #[no_mangle] pub unsafe extern "C" fn nak_icon_data_free(icon: NakIconData) { if !icon.data.is_null() && icon.len > 0 { let _ = unsafe { Vec::from_raw_parts(icon.data, icon.len, icon.len) }; } } // ============================================================================ // General: String free // ============================================================================ /// Free a string returned by any nak_* function #[no_mangle] pub unsafe extern "C" fn nak_string_free(s: *mut c_char) { free_if_nonnull(s); }