From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- libs/nak_ffi/src/lib.rs | 692 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 692 insertions(+) create mode 100644 libs/nak_ffi/src/lib.rs (limited to 'libs/nak_ffi/src') diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs new file mode 100644 index 0000000..0e7b4f3 --- /dev/null +++ b/libs/nak_ffi/src/lib.rs @@ -0,0 +1,692 @@ +//! 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; +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()) +} + +// ============================================================================ +// 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 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), + 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 Shortcuts +// ============================================================================ + +/// Result from adding a Steam shortcut +#[repr(C)] +pub struct NakShortcutResult { + pub app_id: u32, + pub prefix_path: *mut c_char, + pub error: *mut c_char, // null on success +} + +/// Add a mod manager as a non-Steam game shortcut +/// +/// Returns a NakShortcutResult. Check `error` field - null means success. +#[no_mangle] +pub unsafe extern "C" fn nak_add_mod_manager_shortcut( + name: *const c_char, + exe_path: *const c_char, + start_dir: *const c_char, + proton_name: *const c_char, +) -> NakShortcutResult { + let name = unsafe { from_cstr(name) }; + let exe = unsafe { from_cstr(exe_path) }; + let dir = unsafe { from_cstr(start_dir) }; + let proton = unsafe { from_cstr(proton_name) }; + + match nak_rust::steam::add_mod_manager_shortcut(name, exe, dir, proton, None, false) { + Ok(result) => NakShortcutResult { + app_id: result.app_id, + prefix_path: to_cstring(&result.prefix_path.to_string_lossy()), + error: ptr::null_mut(), + }, + Err(e) => NakShortcutResult { + app_id: 0, + prefix_path: ptr::null_mut(), + error: error_to_cstring(e), + }, + } +} + +/// Remove a non-Steam game shortcut by AppID +/// +/// Returns null on success, or an error message string (caller must free with nak_string_free). +#[no_mangle] +pub unsafe extern "C" fn nak_remove_steam_shortcut(app_id: u32) -> *mut c_char { + match nak_rust::steam::remove_steam_shortcut(app_id) { + Ok(()) => ptr::null_mut(), + Err(e) => error_to_cstring(e), + } +} + +/// Free a NakShortcutResult +#[no_mangle] +pub unsafe extern "C" fn nak_shortcut_result_free(result: NakShortcutResult) { + free_if_nonnull(result.prefix_path); + free_if_nonnull(result.error); +} + +// ============================================================================ +// Tier 4: 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 5: Managed Prefixes +// ============================================================================ + +/// A managed Wine prefix (C-compatible) +#[repr(C)] +pub struct NakManagedPrefix { + pub app_id: u32, + pub name: *mut c_char, + pub prefix_path: *mut c_char, + pub install_path: *mut c_char, + pub manager_type: *mut c_char, + pub library_path: *mut c_char, + pub created: *mut c_char, + pub proton_config_name: *mut c_char, // null if not set +} + +/// List of managed prefixes +#[repr(C)] +pub struct NakManagedPrefixList { + pub prefixes: *mut NakManagedPrefix, + pub count: usize, +} + +/// Load all managed prefixes +#[no_mangle] +pub extern "C" fn nak_managed_prefixes_load() -> NakManagedPrefixList { + let managed = nak_rust::config::ManagedPrefixes::load(); + + let mut ffi_prefixes: Vec = managed + .prefixes + .iter() + .map(|p| NakManagedPrefix { + app_id: p.app_id, + name: to_cstring(&p.name), + prefix_path: to_cstring(&p.prefix_path), + install_path: to_cstring(&p.install_path), + manager_type: to_cstring(&p.manager_type.to_string()), + library_path: to_cstring(&p.library_path), + created: to_cstring(&p.created.to_rfc3339()), + proton_config_name: to_cstring_opt(p.proton_config_name.as_deref()), + }) + .collect(); + + let list = NakManagedPrefixList { + prefixes: ffi_prefixes.as_mut_ptr(), + count: ffi_prefixes.len(), + }; + std::mem::forget(ffi_prefixes); + list +} + +/// Register a new managed prefix +#[no_mangle] +pub unsafe extern "C" fn nak_managed_prefixes_register( + app_id: u32, + name: *const c_char, + prefix_path: *const c_char, + install_path: *const c_char, + library_path: *const c_char, + proton_config_name: *const c_char, +) { + let name = unsafe { from_cstr(name) }; + let prefix = unsafe { from_cstr(prefix_path) }; + let install = unsafe { from_cstr(install_path) }; + let library = unsafe { from_cstr(library_path) }; + let proton = if proton_config_name.is_null() { + None + } else { + let s = unsafe { from_cstr(proton_config_name) }; + if s.is_empty() { + None + } else { + Some(s) + } + }; + + nak_rust::config::ManagedPrefixes::register( + app_id, + name, + prefix, + install, + nak_rust::config::ManagerType::MO2, + library, + proton, + ); +} + +/// Unregister a managed prefix by AppID +#[no_mangle] +pub extern "C" fn nak_managed_prefixes_unregister(app_id: u32) { + nak_rust::config::ManagedPrefixes::unregister(app_id); +} + +/// Free a NakManagedPrefixList +#[no_mangle] +pub unsafe extern "C" fn nak_managed_prefix_list_free(list: NakManagedPrefixList) { + if list.prefixes.is_null() { + return; + } + let prefixes = unsafe { Vec::from_raw_parts(list.prefixes, list.count, list.count) }; + for p in prefixes { + free_if_nonnull(p.name); + free_if_nonnull(p.prefix_path); + free_if_nonnull(p.install_path); + free_if_nonnull(p.manager_type); + free_if_nonnull(p.library_path); + free_if_nonnull(p.created); + free_if_nonnull(p.proton_config_name); + } +} + +// ============================================================================ +// Tier 6: 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 + let protons = nak_rust::steam::find_steam_protons(); + let proton = match protons + .iter() + .find(|p| p.path.to_string_lossy() == proton_path_str) + { + Some(p) => p.clone(), + 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 protons = nak_rust::steam::find_steam_protons(); + let proton = match protons + .iter() + .find(|p| p.path.to_string_lossy() == proton_path_str) + { + Some(p) => p.clone(), + 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), + } +} + +// ============================================================================ +// Tier 7: 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)); +} + +// ============================================================================ +// 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); +} -- cgit v1.3.1