diff --git a/Cargo.lock b/Cargo.lock index 490540fbb..5b8bce141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5229,6 +5229,14 @@ dependencies = [ "wp_localization_parser", ] +[[package]] +name = "wp_mobile" +version = "0.1.0" +dependencies = [ + "uniffi", + "wp_api", +] + [[package]] name = "wp_rs_web" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dda99ad22..c63351317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "wp_localization_macro", "wp_localization_parser", "wp_localization_validation", + "wp_mobile", "wp_rs_cli", "wp_rs_web", "wp_serde_helper", diff --git a/native/kotlin/api/android/build.gradle.kts b/native/kotlin/api/android/build.gradle.kts index 4da0cdc15..762c21259 100644 --- a/native/kotlin/api/android/build.gradle.kts +++ b/native/kotlin/api/android/build.gradle.kts @@ -84,7 +84,7 @@ dependencies { } val cargoProjectRoot = rootProject.ext.get("cargoProjectRoot")!! -val moduleName = "wp_api" +val moduleName = rootProject.ext.get("rustPrimaryModule").toString() cargo { cargoCommand = rootProject.ext.get("cargoBinaryPath").toString() rustcCommand = rootProject.ext.get("rustcBinaryPath").toString() diff --git a/native/kotlin/api/kotlin/build.gradle.kts b/native/kotlin/api/kotlin/build.gradle.kts index 8ad40b987..8b62a0667 100644 --- a/native/kotlin/api/kotlin/build.gradle.kts +++ b/native/kotlin/api/kotlin/build.gradle.kts @@ -84,11 +84,14 @@ sourceSets { } } +// UniFFI supports generating bindings for multiple crates from a single library file. +// When wp_mobile is built, it includes wp_api as a dependency, so libwp_mobile contains +// metadata for both crates. We generate bindings for each crate from the single library. val generateUniFFIBindingsTask = tasks.register("generateUniFFIBindings") { val cargoProjectRoot = rootProject.ext.get("cargoProjectRoot") val uniffiGeneratedPath = "${layout.buildDirectory.get()}/generated/source/uniffi/java" val nativeLibraryPath = rootProject.ext.get("nativeLibraryPath")!! - val rustModuleName = rootProject.ext.get("rustModuleName") + val rustPrimaryModule = rootProject.ext.get("rustPrimaryModule") dependsOn(rootProject.tasks.named("cargoBuildLibraryRelease")) workingDir(project.rootDir) @@ -115,7 +118,7 @@ val generateUniFFIBindingsTask = tasks.register("generateUniFFIBindings") // Re-generate if our uniffi-bindgen version changes. inputs.file("$cargoProjectRoot/Cargo.lock") // Re-generate if the module source code changes - inputs.dir("$cargoProjectRoot/$rustModuleName/") + inputs.dir("$cargoProjectRoot/$rustPrimaryModule/") } tasks.named("compileKotlin").configure { diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt index 1daf835c9..5f238e2c7 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt @@ -6,12 +6,18 @@ import uniffi.wp_api.SparseUserFieldWithEditContext import uniffi.wp_api.UserListParams import uniffi.wp_api.WpApiParamUsersHasPublishedPosts import uniffi.wp_api.WpErrorCode +import uniffi.wp_mobile.wpMobileCrateWorks import kotlin.test.assertEquals import kotlin.test.assertNull class UsersEndpointTest { private val client = defaultApiClient() + @Test + fun testThatWpMobileCrateWorks() = runTest { + assertEquals("foo is bar", wpMobileCrateWorks("bar")) + } + @Test fun testUserListRequest() = runTest { val userList = client.request { requestBuilder -> diff --git a/native/kotlin/build.gradle.kts b/native/kotlin/build.gradle.kts index 0c48dd9ad..956997e53 100644 --- a/native/kotlin/build.gradle.kts +++ b/native/kotlin/build.gradle.kts @@ -32,6 +32,7 @@ allprojects { // Exclude generated bindings exclude("**/wp_api.kt") exclude("**/wp_localization.kt") + exclude("**/wp_mobile.kt") } tasks.withType().configureEach { @@ -40,6 +41,7 @@ allprojects { // Exclude generated bindings exclude("**/wp_api.kt") exclude("**/wp_localization.kt") + exclude("**/wp_mobile.kt") } dependencies { @@ -52,9 +54,9 @@ val rustcBinaryPath = resolveBinary("rustc") val cargoProjectRoot = "${project.rootDir}/../.." val jniLibsPath = "${layout.buildDirectory.get()}/jniLibs/" val generatedTestResourcesPath = "${layout.buildDirectory.get()}/generatedTestResources/" -val rustModuleName = "wp_api" +val rustPrimaryModule = "wp_mobile" val nativeLibraryPath = - "$cargoProjectRoot/target/release/lib${rustModuleName}${getNativeLibraryExtension()}" + "$cargoProjectRoot/target/release/lib${rustPrimaryModule}${getNativeLibraryExtension()}" rootProject.ext.set("cargoBinaryPath", cargoBinaryPath) rootProject.ext.set("rustcBinaryPath", rustcBinaryPath) @@ -62,18 +64,18 @@ rootProject.ext.set("cargoProjectRoot", cargoProjectRoot) rootProject.ext.set("jniLibsPath", jniLibsPath) rootProject.ext.set("generatedTestResourcesPath", generatedTestResourcesPath) rootProject.ext.set("nativeLibraryPath", nativeLibraryPath) -rootProject.ext.set("rustModuleName", rustModuleName) +rootProject.ext.set("rustPrimaryModule", rustPrimaryModule) setupJniAndBindings() // Separated as a function to have everything in a scope and keep it contained fun setupJniAndBindings() { val nativeLibraryPath = - "$cargoProjectRoot/target/release/lib${rustModuleName}${getNativeLibraryExtension()}" + "$cargoProjectRoot/target/release/lib${rustPrimaryModule}${getNativeLibraryExtension()}" val cargoBuildLibraryReleaseTask = tasks.register("cargoBuildLibraryRelease") { workingDir(rootProject.ext.get("cargoProjectRoot")!!) - commandLine(cargoBinaryPath, "build", "--package", rustModuleName, "--release") + commandLine(cargoBinaryPath, "build", "--package", rustPrimaryModule, "--release") // No inputs.dir added, because we want to always re-run this task and let Cargo handle caching } diff --git a/native/kotlin/example/composeApp/build.gradle.kts b/native/kotlin/example/composeApp/build.gradle.kts index 10ed98e80..379d9cae9 100755 --- a/native/kotlin/example/composeApp/build.gradle.kts +++ b/native/kotlin/example/composeApp/build.gradle.kts @@ -18,18 +18,25 @@ tasks.withType().configureEach { } } +// Desktop resources path - separate from integration test resources to avoid IDE conflicts +val desktopResourcesPath = layout.buildDirectory.dir("desktopResources") + +// Copy resources needed for desktop app +val copyDesktopAppResources = tasks.register("copyDesktopAppResources") { + dependsOn(rootProject.tasks.named("copyDesktopJniLibs")) + dependsOn(rootProject.tasks.named("copyTestCredentials")) + from(rootProject.ext.get("jniLibsPath")) + from(rootProject.ext.get("generatedTestResourcesPath")) + into(desktopResourcesPath) +} + kotlin { androidTarget() jvm("desktop") sourceSets { val desktopMain by getting { - resources.srcDirs( - listOf( - rootProject.ext.get("jniLibsPath"), - rootProject.ext.get("generatedTestResourcesPath") - ) - ) + resources.srcDirs(desktopResourcesPath) } androidMain.dependencies { @@ -71,7 +78,6 @@ android { sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") sourceSets["main"].res.srcDirs("src/androidMain/res") - sourceSets["main"].resources.srcDirs("src/commonMain/resources") defaultConfig { applicationId = "rs.wordpress.example" @@ -106,6 +112,11 @@ compose.desktop { application { mainClass = "rs.wordpress.example.MainKt" + jvmArgs += listOf( + "-Djna.library.path=.", + "-Djava.library.path=." + ) + nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "rs.wordpress.example" @@ -114,7 +125,87 @@ compose.desktop { } } +// Generate BuildConfig with rust module name +val generateBuildConfig = tasks.register("generateBuildConfig") { + val outputDir = layout.buildDirectory.dir("generated/source/buildConfig") + val rustPrimaryModule = rootProject.ext.get("rustPrimaryModule") as String + + outputs.dir(outputDir) + + doLast { + val buildConfigFile = outputDir.get().file("rs/wordpress/example/BuildConfig.kt").asFile + buildConfigFile.parentFile.mkdirs() + buildConfigFile.writeText(""" + package rs.wordpress.example + + object BuildConfig { + const val RUST_PRIMARY_MODULE = "$rustPrimaryModule" + } + """.trimIndent()) + } +} + +// Generate TestCredentials from test_credentials.json +val generateTestCredentials = tasks.register("generateTestCredentials") { + val outputDir = layout.buildDirectory.dir("generated/source/testCredentials") + val cargoProjectRoot = rootProject.ext.get("cargoProjectRoot") as String + val credentialsFile = file("$cargoProjectRoot/test_credentials.json") + + // Only mark as input if file exists - allows build to work without test server running + inputs.files(credentialsFile).optional(true) + outputs.dir(outputDir) + + doLast { + val testCredentialsFile = outputDir.get().file("rs/wordpress/example/TestCredentials.kt").asFile + testCredentialsFile.parentFile.mkdirs() + + if (credentialsFile.exists()) { + val json = groovy.json.JsonSlurper().parseText(credentialsFile.readText()) as Map<*, *> + testCredentialsFile.writeText(""" + package rs.wordpress.example + + object TestCredentials { + val SITE_URL: String? = "${json["site_url"]}" + val ADMIN_USERNAME: String? = "${json["admin_username"]}" + val ADMIN_PASSWORD: String? = "${json["admin_password"]}" + val SUBSCRIBER_USERNAME: String? = "${json["subscriber_username"]}" + val SUBSCRIBER_PASSWORD: String? = "${json["subscriber_password"]}" + } + """.trimIndent()) + } else { + testCredentialsFile.writeText(""" + package rs.wordpress.example + + object TestCredentials { + val SITE_URL: String? = null + val ADMIN_USERNAME: String? = null + val ADMIN_PASSWORD: String? = null + val SUBSCRIBER_USERNAME: String? = null + val SUBSCRIBER_PASSWORD: String? = null + } + """.trimIndent()) + } + } +} + +kotlin.sourceSets.getByName("desktopMain") { + kotlin.srcDir(layout.buildDirectory.dir("generated/source/buildConfig")) +} + +kotlin.sourceSets.getByName("commonMain") { + kotlin.srcDir(layout.buildDirectory.dir("generated/source/testCredentials")) +} + +tasks.named("compileKotlinDesktop").configure { + dependsOn(generateBuildConfig) + dependsOn(generateTestCredentials) +} + +// Ensure test credentials are generated before any Android compilation +tasks.named("preBuild").configure { + dependsOn(generateTestCredentials) +} + tasks.named("desktopProcessResources").configure { - dependsOn(rootProject.tasks.named("copyDesktopJniLibs")) - dependsOn(rootProject.tasks.named("copyTestCredentials")) + dependsOn(copyDesktopAppResources) } diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt index 770780e5b..250d71bd1 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/di/AppModule.kt @@ -1,6 +1,7 @@ package rs.wordpress.example.shared.di import org.koin.dsl.module +import rs.wordpress.example.TestCredentials import rs.wordpress.example.shared.localTestSiteUrl import rs.wordpress.example.shared.repository.AuthenticationRepository import rs.wordpress.example.shared.ui.plugins.PluginListViewModel @@ -9,15 +10,14 @@ import rs.wordpress.example.shared.ui.welcome.WelcomeViewModel val authModule = module { single { - // TODO: Read from test credentials file AuthenticationRepository( localTestSiteUrl = localTestSiteUrl().siteUrl, - localTestSiteUsername = "test@example.com", - // Until this works with the included test credentials, you can grab it from the - // `test_credentials.json` file `make test-server` will generate in the root of the repo - // The key is `admin_password` - localTestSitePassword = "s3N7vlbdrFPDDI3MbyFUvS3P" - ) + localTestSiteUsername = TestCredentials.ADMIN_USERNAME, + localTestSitePassword = TestCredentials.ADMIN_PASSWORD + ).apply { + // Add test site if credentials are available + addTestSiteIfAvailable() + } } } diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/repository/AuthenticationRepository.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/repository/AuthenticationRepository.kt index 11bdd3177..8234ec325 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/repository/AuthenticationRepository.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/repository/AuthenticationRepository.kt @@ -3,22 +3,25 @@ package rs.wordpress.example.shared.repository import rs.wordpress.example.shared.domain.AuthenticatedSite import uniffi.wp_api.WpAuthentication import uniffi.wp_api.wpAuthenticationFromUsernameAndPassword +import java.net.URI import java.net.URL class AuthenticationRepository( - localTestSiteUrl: String, - localTestSiteUsername: String, - localTestSitePassword: String + private val localTestSiteUrl: String, + private val localTestSiteUsername: String?, + private val localTestSitePassword: String? ) { private val authenticatedSites = mutableMapOf() - init { - addAuthenticatedSite( - URL(localTestSiteUrl), - URL("$localTestSiteUrl/wp-json"), - localTestSiteUsername, - localTestSitePassword - ) + fun addTestSiteIfAvailable() { + if (localTestSiteUsername != null && localTestSitePassword != null) { + addAuthenticatedSite( + URI(localTestSiteUrl).toURL(), + URI("$localTestSiteUrl/wp-json").toURL(), + localTestSiteUsername, + localTestSitePassword + ) + } } fun addAuthenticatedSite(siteUrl: URL, apiRootUrl: URL, username: String, password: String): Boolean { diff --git a/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/NativeLibraryLoader.kt b/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/NativeLibraryLoader.kt new file mode 100644 index 000000000..5ccb446b4 --- /dev/null +++ b/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/NativeLibraryLoader.kt @@ -0,0 +1,52 @@ +package rs.wordpress.example + +import java.io.File +import java.nio.file.Files + +object NativeLibraryLoader { + private val tempDir = Files.createTempDirectory("wordpress-rs-libs").toFile().apply { + deleteOnExit() + } + + fun loadLibraries() { + // Determine the library name based on the OS + val osName = System.getProperty("os.name").lowercase() + val moduleName = BuildConfig.RUST_PRIMARY_MODULE + val libName = when { + osName.contains("mac") || osName.contains("darwin") -> "lib${moduleName}.dylib" + osName.contains("linux") -> "lib${moduleName}.so" + osName.contains("windows") -> "${moduleName}.dll" + else -> throw UnsupportedOperationException("Unsupported OS: $osName") + } + + // Extract the native library from jar + try { + val resourceStream = javaClass.classLoader.getResourceAsStream(libName) + if (resourceStream != null) { + val tempFile = File(tempDir, libName).apply { + deleteOnExit() + } + + resourceStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + // Make executable + tempFile.setExecutable(true) + println("Extracted native library: ${tempFile.absolutePath}") + } else { + println("Warning: Could not find $libName in resources") + } + } catch (e: Exception) { + println("Warning: Could not extract $libName: ${e.message}") + } + + // Set JNA library path to our temp directory + System.setProperty("jna.library.path", tempDir.absolutePath) + System.setProperty("java.library.path", tempDir.absolutePath) + + println("Native library path set to: ${tempDir.absolutePath}") + } +} diff --git a/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/main.kt b/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/main.kt index 8c7e7281d..7c40273fc 100755 --- a/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/main.kt +++ b/native/kotlin/example/composeApp/src/desktopMain/kotlin/rs/wordpress/example/main.kt @@ -6,16 +6,21 @@ import org.koin.compose.KoinApplication import rs.wordpress.example.shared.App import rs.wordpress.example.shared.di.commonModules -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = "WordPressRsExample", - ) { - KoinApplication(application = { - modules(commonModules()) - }) { - // Authentication is not supported on Desktop - App(authenticationEnabled = false, authenticateSite = {}) +fun main() { + // Load native libraries before initializing the app + NativeLibraryLoader.loadLibraries() + + application { + Window( + onCloseRequest = ::exitApplication, + title = "WordPressRsExample", + ) { + KoinApplication(application = { + modules(commonModules()) + }) { + // Authentication is not supported on Desktop + App(authenticationEnabled = false, authenticateSite = {}) + } } } } diff --git a/native/kotlin/gradle/libs.versions.toml b/native/kotlin/gradle/libs.versions.toml index 386be84eb..7edade214 100644 --- a/native/kotlin/gradle/libs.versions.toml +++ b/native/kotlin/gradle/libs.versions.toml @@ -8,7 +8,7 @@ androidx-core-ktx = "1.17.0" androidx-material3 = "1.4.0" androidxTestRulesVersion = "1.7.0" androidxTestRunnerVersion = "1.7.0" -compose-plugin = "1.6.11" +compose-plugin = "1.9.0" detekt-plugin = "1.23.8" jna = "5.18.1" junit = "4.13.2" diff --git a/wp_api/uniffi.toml b/wp_api/uniffi.toml index 214bc4f86..bde355adf 100644 --- a/wp_api/uniffi.toml +++ b/wp_api/uniffi.toml @@ -1,7 +1,7 @@ [bindings.kotlin] android = false android_cleaner = false -cdylib_name = "wp_api" +cdylib_name = "wp_mobile" generate_immutable_records = true [bindings.kotlin.custom_types.WpGmtDateTime] diff --git a/wp_mobile/Cargo.toml b/wp_mobile/Cargo.toml new file mode 100644 index 000000000..e506cfc19 --- /dev/null +++ b/wp_mobile/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wp_mobile" +version = "0.1.0" +edition = "2024" + +[features] +reqwest-request-executor = ["wp_api/reqwest-request-executor"] + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] +name = "wp_mobile" + +[dependencies] +uniffi = { workspace = true } +wp_api = { path = "../wp_api" } + +[build-dependencies] +uniffi = { workspace = true , features = [ "build", "cli" ] } diff --git a/wp_mobile/src/lib.rs b/wp_mobile/src/lib.rs new file mode 100644 index 000000000..c14dd3fea --- /dev/null +++ b/wp_mobile/src/lib.rs @@ -0,0 +1,9 @@ +// Re-export wp_api to ensure its bindings are generated +pub use wp_api; + +#[uniffi::export] +fn wp_mobile_crate_works(input: String) -> String { + format!("foo is {}", input) +} + +uniffi::setup_scaffolding!(); diff --git a/wp_mobile/uniffi.toml b/wp_mobile/uniffi.toml new file mode 100644 index 000000000..a03e01f44 --- /dev/null +++ b/wp_mobile/uniffi.toml @@ -0,0 +1,13 @@ +[bindings.kotlin] +android = false +android_cleaner = false +cdylib_name = "wp_mobile" +generate_immutable_records = true + +[bindings.swift] +ffi_module_name = "libwordpressFFI" +ffi_module_filename = "wp_mobile_uniffi" +generate_module_map = false +generate_immutable_records = true +experimental_sendable_value_types = true +omit_localized_error_conformance = true