lingxia-platform 0.8.0

Platform abstraction layer for LingXia framework (Android, iOS, HarmonyOS)
Documentation
use objc2::rc::Retained;
use objc2::runtime::NSObject;
use objc2::runtime::{AnyObject, Sel};
use objc2::{ClassType, msg_send, sel};
use objc2_foundation::{NSBundle, NSData, NSFileManager, NSString, NSURL};
use std::path::Path;
use std::sync::OnceLock;

#[inline]
unsafe fn nsdata_bytes_ptr_unchecked(ns_data: &Retained<NSData>) -> *const u8 {
    // Avoid objc2's debug-time method signature verification for `-[NSData bytes]`.
    //
    // On some Apple OS versions, the runtime type encoding for this method is
    // not `^v` as expected, which can cause a panic in debug builds.
    let obj: *const AnyObject = Retained::as_ptr(ns_data).cast();
    let sel: Sel = sel!(bytes);
    let func: unsafe extern "C" fn(*const AnyObject, Sel) -> *const core::ffi::c_void =
        unsafe { core::mem::transmute(objc2::ffi::objc_msgSend as *const ()) };
    unsafe { func(obj, sel) }.cast()
}

/// Cached bundles for resource lookup (app bundle, SDK bundle, main bundle)
/// Initialized once on first access to avoid repeated bundle detection
fn get_resource_bundles() -> &'static [Retained<NSBundle>] {
    static BUNDLES: OnceLock<Vec<Retained<NSBundle>>> = OnceLock::new();
    BUNDLES.get_or_init(|| {
        let mut bundles = Vec::with_capacity(3);
        unsafe {
            let main_bundle = NSBundle::mainBundle();
            let bundle_type = NSString::from_str("bundle");

            // 1. App bundle (for app.json, lxapp content) - based on bundle identifier
            if let Some(app_bundle) = detect_app_bundle(&main_bundle, &bundle_type) {
                bundles.push(app_bundle);
            }

            // 2. SDK bundle (for webview-bridge.js, 404.html, icons)
            for bundle_name in ["lingxia_lingxia", "LingXia_LingXia"] {
                let bundle_name_ns = NSString::from_str(bundle_name);
                let bundle_path: Option<Retained<NSString>> =
                    msg_send![&main_bundle, pathForResource: &*bundle_name_ns, ofType: &*bundle_type];

                if let Some(path) = bundle_path {
                    let resource_bundle: Option<Retained<NSBundle>> =
                        msg_send![NSBundle::class(), bundleWithPath: &*path];
                    if let Some(bundle) = resource_bundle {
                        bundles.push(bundle);
                        break;
                    }
                }
            }

            // 3. Main bundle is always the final fallback.
            // Avoids duplicate if detect_app_bundle already resolved to main bundle.
            let main_path: Option<Retained<NSString>> = msg_send![&main_bundle, bundlePath];
            let already_added = main_path.as_deref().is_some_and(|mp| {
                bundles.iter().any(|b| {
                    let bp: Option<Retained<NSString>> = msg_send![b, bundlePath];
                    bp.as_deref().map_or(false, |p| p == mp)
                })
            });
            if !already_added {
                bundles.push(main_bundle);
            }
        }
        bundles
    })
}

/// Detect app bundle based on bundle identifier, name, or executable
fn detect_app_bundle(main_bundle: &NSBundle, bundle_type: &NSString) -> Option<Retained<NSBundle>> {
    unsafe {
        // Try 1: Bundle identifier based (e.g., app.lingxia.example.lxapp → lxapp_lxapp)
        let bundle_identifier: Option<Retained<NSString>> =
            msg_send![main_bundle, bundleIdentifier];
        if let Some(identifier) = bundle_identifier {
            let identifier_str = identifier.to_string();
            if let Some(last_component) = identifier_str.split('.').next_back() {
                if let Some(bundle) = try_find_spm_bundle(main_bundle, last_component, bundle_type)
                {
                    return Some(bundle);
                }
            }
        }

        // Try 2: CFBundleName based (e.g., LingXia → LingXia_LingXia)
        let cf_bundle_name_key = NSString::from_str("CFBundleName");
        let bundle_name: Option<Retained<NSString>> =
            msg_send![main_bundle, objectForInfoDictionaryKey: &*cf_bundle_name_key];
        if let Some(name) = bundle_name {
            if let Some(bundle) = try_find_spm_bundle(main_bundle, &name.to_string(), bundle_type) {
                return Some(bundle);
            }
        }

        // Try 3: CFBundleExecutable based (e.g., LingXiaDemo → LingXiaDemo_LingXiaDemo)
        // This handles macOS dev builds where executable name matches SPM target
        let cf_executable_key = NSString::from_str("CFBundleExecutable");
        let executable_name: Option<Retained<NSString>> =
            msg_send![main_bundle, objectForInfoDictionaryKey: &*cf_executable_key];
        if let Some(name) = executable_name {
            if let Some(bundle) = try_find_spm_bundle(main_bundle, &name.to_string(), bundle_type) {
                return Some(bundle);
            }
        }

        None
    }
}

/// Try to find SPM bundle with format Name_Name.bundle
fn try_find_spm_bundle(
    main_bundle: &NSBundle,
    name: &str,
    bundle_type: &NSString,
) -> Option<Retained<NSBundle>> {
    unsafe {
        let spm_bundle_name = format!("{}_{}", name, name);
        let bundle_name_ns = NSString::from_str(&spm_bundle_name);
        let bundle_path: Option<Retained<NSString>> =
            msg_send![main_bundle, pathForResource: &*bundle_name_ns, ofType: &*bundle_type];

        if let Some(path) = bundle_path {
            let resource_bundle: Option<Retained<NSBundle>> =
                msg_send![NSBundle::class(), bundleWithPath: &*path];
            return resource_bundle;
        }
        None
    }
}

/// Read asset data from the bundle resources
/// Returns the asset data as bytes, or empty Vec if not found
pub fn read_asset_data(path: &str) -> Vec<u8> {
    unsafe {
        // Clean the path - remove leading slash if present
        let clean_path = path.strip_prefix('/').unwrap_or(path);
        if clean_path.is_empty() {
            return Vec::new();
        }

        let fallback_path = format!("Resources/{}", clean_path);

        // Try cached bundles (app bundle first, then SDK bundle)
        for bundle in get_resource_bundles() {
            // Try the path as-is first, then fallback to Resources/ subdirectory
            for try_path in [clean_path, fallback_path.as_str()] {
                let (subdirectory, filename) = match try_path.rsplit_once('/') {
                    Some((subdir, file)) if !subdir.is_empty() => (Some(subdir), file),
                    _ => (None, try_path),
                };

                let path_extension = Path::new(filename)
                    .extension()
                    .and_then(|ext| ext.to_str())
                    .unwrap_or("");
                let name_without_extension = Path::new(filename)
                    .file_stem()
                    .and_then(|stem| stem.to_str())
                    .unwrap_or(filename);

                // Create NSString objects
                let name_ns = NSString::from_str(name_without_extension);
                let extension_ns = if path_extension.is_empty() {
                    None
                } else {
                    Some(NSString::from_str(path_extension))
                };
                let subdirectory_ns = subdirectory.map(NSString::from_str);

                // Try to find the resource URL
                let resource_url: Option<Retained<NSURL>> = if let Some(subdir_ns) =
                    &subdirectory_ns
                {
                    if let Some(ext_ns) = &extension_ns {
                        msg_send![bundle, URLForResource: &*name_ns, withExtension: &**ext_ns, subdirectory: &**subdir_ns]
                    } else {
                        msg_send![bundle, URLForResource: &*name_ns, withExtension: std::ptr::null::<NSString>(), subdirectory: &**subdir_ns]
                    }
                } else if let Some(ext_ns) = &extension_ns {
                    msg_send![bundle, URLForResource: &*name_ns, withExtension: &**ext_ns]
                } else {
                    msg_send![bundle, URLForResource: &*name_ns, withExtension: std::ptr::null::<NSString>()]
                };

                if let Some(url) = resource_url {
                    // Try to read the data
                    let data: Option<Retained<NSData>> =
                        msg_send![NSData::class(), dataWithContentsOfURL: &*url];

                    if let Some(ns_data) = data {
                        let length: usize = msg_send![&ns_data, length];
                        if length > 0 {
                            let bytes_ptr: *const u8 = nsdata_bytes_ptr_unchecked(&ns_data);
                            let slice = std::slice::from_raw_parts(bytes_ptr.cast::<u8>(), length);
                            return slice.to_vec();
                        }
                    }
                }
            }
        }

        Vec::new()
    }
}

/// List contents of an asset directory
/// Returns array of file/directory names in the directory
pub fn list_asset_directory(dir_path: &str) -> Vec<String> {
    unsafe {
        let clean_path = dir_path.strip_prefix('/').unwrap_or(dir_path);

        for bundle in get_resource_bundles() {
            let bundle_resource_path: Option<Retained<NSString>> = msg_send![bundle, resourcePath];

            if let Some(resource_path) = bundle_resource_path {
                // Build full path
                let full_path = if clean_path.is_empty() {
                    resource_path.to_string()
                } else {
                    format!("{}/{}", resource_path, clean_path)
                };

                let full_path_ns = NSString::from_str(&full_path);

                // Get file manager
                let file_manager = NSFileManager::defaultManager();

                // Try to get directory contents
                let contents: Option<Retained<objc2_foundation::NSArray<NSString>>> = msg_send![
                    &file_manager,
                    contentsOfDirectoryAtPath: &*full_path_ns,
                    error: std::ptr::null_mut::<*mut NSObject>()
                ];

                if let Some(contents_array) = contents {
                    let count: usize = msg_send![&contents_array, count];
                    let mut result = Vec::with_capacity(count);

                    for i in 0..count {
                        let item: Retained<NSString> = msg_send![&contents_array, objectAtIndex: i];
                        let item_str = item.to_string();

                        result.push(item_str);
                    }

                    return result;
                }
            }
        }

        Vec::new()
    }
}