diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 6e178f22..4770b4b1 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -12,6 +12,7 @@ kotlin { publishLibraryVariants("release", "debug") } + jvm() iosX64() iosArm64() iosSimulatorArm64() diff --git a/compose/src/jvmMain/kotlin/com/powersync/compose/DatabaseDriverFactory.compose.jvm.kt b/compose/src/jvmMain/kotlin/com/powersync/compose/DatabaseDriverFactory.compose.jvm.kt new file mode 100644 index 00000000..2fd3b830 --- /dev/null +++ b/compose/src/jvmMain/kotlin/com/powersync/compose/DatabaseDriverFactory.compose.jvm.kt @@ -0,0 +1,12 @@ +package com.powersync.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.powersync.DatabaseDriverFactory + +@Composable +public actual fun rememberDatabaseDriverFactory(): DatabaseDriverFactory { + return remember { + DatabaseDriverFactory() + } +} \ No newline at end of file diff --git a/connectors/supabase/build.gradle.kts b/connectors/supabase/build.gradle.kts index bfc3a466..936d59b0 100644 --- a/connectors/supabase/build.gradle.kts +++ b/connectors/supabase/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { } } + jvm() iosX64() iosArm64() iosSimulatorArm64() diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 78fbb70e..de868b86 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,6 +1,7 @@ +import com.powersync.plugins.sonatype.setupGithubRepository import de.undercouch.gradle.tasks.download.Download +import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import com.powersync.plugins.sonatype.setupGithubRepository plugins { alias(libs.plugins.kotlinMultiplatform) @@ -14,8 +15,15 @@ plugins { val sqliteVersion = "3450000" val sqliteReleaseYear = "2024" -val sqliteSrcFolder = - project.layout.buildDirectory.dir("interop/sqlite").get() +val osName = when { + Os.isFamily(Os.FAMILY_WINDOWS) -> "Windows" + Os.isFamily(Os.FAMILY_MAC) -> "macOS" + Os.isFamily(Os.FAMILY_UNIX) -> "Linux" + else -> "Unknown" +} + +val sqliteSrcFolder = project.layout.buildDirectory.dir("interop/sqlite").get() +val jvmNativeBuildFolder = "${layout.buildDirectory.get().asFile}/native/powersync-sqlite" val downloadSQLiteSources by tasks.registering(Download::class) { val zipFileName = "sqlite-amalgamation-$sqliteVersion.zip" @@ -59,6 +67,19 @@ val buildCInteropDef by tasks.registering { outputs.files(defFile) } +tasks.create("buildNative") { + group = "build" + + outputs.dir(jvmNativeBuildFolder) + workingDir = file(jvmNativeBuildFolder) + + environment("TARGET", "$osName/cmake") + environment("SOURCE_PATH", "$projectDir/src/jvmMain/cpp") + environment("INTEROP_PATH", "$sqliteSrcFolder") + + commandLine("$projectDir/src/jvmMain/cpp/build.sh") +} + kotlin { androidTarget { publishLibraryVariants("release", "debug") @@ -67,6 +88,13 @@ kotlin { iosX64() iosArm64() iosSimulatorArm64() + jvm { + val processResources = compilations["main"].processResourcesTaskName + (tasks[processResources] as ProcessResources).apply { + dependsOn("buildNative") + from("$jvmNativeBuildFolder/${osName}/output") + } + } targets.withType { compilations.getByName("main") { @@ -113,6 +141,11 @@ kotlin { implementation(libs.ktor.client.ios) } + jvmMain.dependencies { + implementation(libs.sqldelight.driver.desktop) + implementation(libs.ktor.client.okhttp) + } + commonTest.dependencies { implementation(libs.kotlin.test) } @@ -180,4 +213,3 @@ afterEvaluate { } setupGithubRepository() - diff --git a/core/src/jvmMain/cpp/CMakeLists.txt b/core/src/jvmMain/cpp/CMakeLists.txt new file mode 100644 index 00000000..9d4d89f0 --- /dev/null +++ b/core/src/jvmMain/cpp/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.18.1) + +project(powersync-sqlite) + +set(PACKAGE_NAME "powersync-sqlite") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/../output) + +find_package(JNI REQUIRED) + +include_directories(${JNI_INCLUDE_DIRS}) +include_directories(${SQLITE3_INTEROP_DIR}) + +add_library(sqlite3_static STATIC + "${SQLITE3_INTEROP_DIR}/sqlite3.c" +) + +add_library( + ${PACKAGE_NAME} + SHARED + sqlite_bindings.cpp +) + +target_link_libraries( + ${PACKAGE_NAME} + PRIVATE + sqlite3_static +) \ No newline at end of file diff --git a/core/src/jvmMain/cpp/build.sh b/core/src/jvmMain/cpp/build.sh new file mode 100755 index 00000000..649a603d --- /dev/null +++ b/core/src/jvmMain/cpp/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +[[ -z "$TARGET" ]] && echo "Please set the PLATFORM variable" && exit 1 +[[ -z "$SOURCE_PATH" ]] && echo "Please set the SOURCE_PATH variable" && exit 1 +[[ -z "$INTEROP_PATH" ]] && echo "Please set the INTEROP_PATH variable" && exit 1 + +mkdir -p "$TARGET" +cd "$TARGET" + +cmake -DSQLITE3_INTEROP_DIR="$INTEROP_PATH" "$SOURCE_PATH" +cmake --build . \ No newline at end of file diff --git a/core/src/jvmMain/cpp/sqlite_bindings.cpp b/core/src/jvmMain/cpp/sqlite_bindings.cpp new file mode 100644 index 00000000..cdd78edb --- /dev/null +++ b/core/src/jvmMain/cpp/sqlite_bindings.cpp @@ -0,0 +1,91 @@ +#include +#include +#include +#include + +typedef struct context { + JavaVM *javaVM; + jobject bindingsObj; + jclass bindingsClz; +} Context; +Context g_ctx; + +extern "C" { + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + JNIEnv *env; + memset(&g_ctx, 0, sizeof(g_ctx)); + g_ctx.javaVM = vm; + + if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) { + return JNI_ERR; // JNI version not supported. + } + + return JNI_VERSION_1_6; +} + +static void +update_hook_callback(void *pData, int opCode, char const *pDbName, char const *pTableName, + sqlite3_int64 iRow) { + // Get JNIEnv for the current thread + JNIEnv *env; + JavaVM *javaVM = g_ctx.javaVM; + javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); + + if (g_ctx.bindingsClz) { + jmethodID updateId = env->GetMethodID( + g_ctx.bindingsClz, "onTableUpdate", "(Ljava/lang/String;)V"); + + jstring tableString = env->NewStringUTF(std::string(pTableName).c_str()); + env->CallVoidMethod(g_ctx.bindingsObj, updateId, tableString); + } +} + +static jint +commit_hook(void *pool) { + // Get JNIEnv for the current thread + JNIEnv *env; + JavaVM *javaVM = g_ctx.javaVM; + javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); + + if (g_ctx.bindingsClz) { + jmethodID methodId = env->GetMethodID( + g_ctx.bindingsClz, "onTransactionCommit", "(Z)V"); + + env->CallVoidMethod(g_ctx.bindingsObj, methodId, JNI_TRUE); + } + + return 0; +} + +static void rollback_hook(void *pool) { + // Get JNIEnv for the current thread + JNIEnv *env; + JavaVM *javaVM = g_ctx.javaVM; + javaVM->GetEnv((void **) &env, JNI_VERSION_1_6); + + if (g_ctx.bindingsClz) { + jmethodID methodId = env->GetMethodID( + g_ctx.bindingsClz, "onTransactionCommit", "(Z)V"); + + env->CallVoidMethod(g_ctx.bindingsObj, methodId, JNI_FALSE); + } +} + +jint powersync_init(sqlite3 *db, char **pzErrMsg, + const sqlite3_api_routines *pApi) { + + sqlite3_update_hook(db, update_hook_callback, NULL); + sqlite3_commit_hook(db, commit_hook, NULL); + sqlite3_rollback_hook(db, rollback_hook, NULL); + + return SQLITE_OK; +} + +JNIEXPORT void JNICALL +Java_com_powersync_DatabaseDriverFactory_setupSqliteBinding(JNIEnv *env, jobject thiz) { + jclass clz = env->GetObjectClass(thiz); + g_ctx.bindingsClz = (jclass) env->NewGlobalRef(clz); + g_ctx.bindingsObj = env->NewGlobalRef(thiz); +} +} diff --git a/core/src/jvmMain/kotlin/BuidConfig.kt b/core/src/jvmMain/kotlin/BuidConfig.kt new file mode 100644 index 00000000..4f9d45c6 --- /dev/null +++ b/core/src/jvmMain/kotlin/BuidConfig.kt @@ -0,0 +1,6 @@ +public actual object BuildConfig { + public actual val isDebug: Boolean + // TODO: need to determine a good way to set this on JVM presuming we don't want to bundle BuildKonfig in the + // library. + get() = true +} \ No newline at end of file diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt new file mode 100644 index 00000000..770a4142 --- /dev/null +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -0,0 +1,50 @@ +package com.powersync + +import app.cash.sqldelight.async.coroutines.synchronous +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.powersync.db.internal.InternalSchema +import java.util.Properties +import kotlinx.coroutines.CoroutineScope + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +public actual class DatabaseDriverFactory { + private var driver: PsSqlDriver? = null + private external fun setupSqliteBinding() + + @Suppress("unused") + private fun onTableUpdate(tableName: String) { + driver?.updateTable(tableName) + } + + @Suppress("unused") + private fun onTransactionCommit(success: Boolean) { + driver?.also { driver -> + if (success) { + driver.fireTableUpdates() + } else { + driver.clearTableUpdates() + } + } + } + + public actual fun createDriver( + scope: CoroutineScope, + dbFilename: String + ): PsSqlDriver { + val schema = InternalSchema.synchronous() + this.driver = PsSqlDriver( + scope = scope, + driver = JdbcSqliteDriver("jdbc:sqlite:$dbFilename", Properties(), schema) + ) + setupSqliteBinding() + return this.driver as PsSqlDriver + } + + public companion object { + init { + // There is presumably a better way to load the library from the jar. + @Suppress("UnsafeDynamicallyLoadedCode") + System.load(OSXLibraryLoader().loadLibraryFromResources()) + } + } +} \ No newline at end of file diff --git a/core/src/jvmMain/kotlin/com/powersync/LibraryLoader.kt b/core/src/jvmMain/kotlin/com/powersync/LibraryLoader.kt new file mode 100644 index 00000000..eb39eba5 --- /dev/null +++ b/core/src/jvmMain/kotlin/com/powersync/LibraryLoader.kt @@ -0,0 +1,38 @@ +package com.powersync + +import com.powersync.LibraryLoader.Companion.SQLITE_BINARY_FILENAME +import java.io.File +import java.io.InputStream + +private interface LibraryLoader { + companion object { + const val SQLITE_BINARY_FILENAME: String = "libpowersync-sqlite" + } + + fun loadLibraryFromResources(): String + + fun createTempFile(prefix: String, suffix: String): File { + val dir = System.getProperty("java.io.tmpdir") + return File.createTempFile(prefix, suffix, File(dir)) + } +} + +// TODO: Need to create and test implementations of this for windows/linux, or even better implement a cleaner +// of extracting the shared library. +public class OSXLibraryLoader : LibraryLoader { + override fun loadLibraryFromResources(): String { + val path = "/$SQLITE_BINARY_FILENAME.dylib" + val tempFile = createTempFile(SQLITE_BINARY_FILENAME, ".dylib") + tempFile.deleteOnExit() + + val inputStream: InputStream = LibraryLoader::class.java.getResourceAsStream(path) + ?: throw IllegalArgumentException("File $path not found in resources") + inputStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + return tempFile.absolutePath + } +} \ No newline at end of file diff --git a/demos/hello-powersync/composeApp/build.gradle.kts b/demos/hello-powersync/composeApp/build.gradle.kts index b2a73d15..8688571f 100644 --- a/demos/hello-powersync/composeApp/build.gradle.kts +++ b/demos/hello-powersync/composeApp/build.gradle.kts @@ -14,6 +14,8 @@ plugins { kotlin { androidTarget() + jvm() + // iosX64() // uncomment to enable iOS x64 iosArm64() iosSimulatorArm64() diff --git a/demos/hello-powersync/desktop/build.gradle.kts b/demos/hello-powersync/desktop/build.gradle.kts new file mode 100644 index 00000000..be2d6d81 --- /dev/null +++ b/demos/hello-powersync/desktop/build.gradle.kts @@ -0,0 +1,28 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + // Gradle complains when I attempt to re-use the plugin definition from the project toml. Something to do + // with the way the sonatype module is set up I presume. Need to figure this out before merge + id("org.jetbrains.kotlin.jvm") + alias(projectLibs.plugins.jetbrainsCompose) +} + +dependencies { + implementation(project(":composeApp")) + implementation(compose.desktop.currentOs) +} + +group = "com.powersync" +version = "1.0.0" + +compose.desktop { + application { + mainClass = "com.powersync.demos.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "PowerSync Demo" + packageVersion = project.version as String + } + } +} \ No newline at end of file diff --git a/demos/hello-powersync/desktop/src/main/kotlin/com/powersync/demos/Main.kt b/demos/hello-powersync/desktop/src/main/kotlin/com/powersync/demos/Main.kt new file mode 100644 index 00000000..185f83f5 --- /dev/null +++ b/demos/hello-powersync/desktop/src/main/kotlin/com/powersync/demos/Main.kt @@ -0,0 +1,15 @@ +package com.powersync.demos + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState + +fun main() = application { + val windowState = rememberWindowState(size = DpSize(1200.dp, 900.dp)) + + Window(state = windowState, onCloseRequest = ::exitApplication, title = "PowerSync Demo") { + App() + } +} \ No newline at end of file diff --git a/demos/hello-powersync/settings.gradle.kts b/demos/hello-powersync/settings.gradle.kts index 46c9a39d..c3db0458 100644 --- a/demos/hello-powersync/settings.gradle.kts +++ b/demos/hello-powersync/settings.gradle.kts @@ -28,6 +28,7 @@ dependencyResolutionManagement { rootProject.name = "hello-powersync" include(":composeApp") +include(":desktop") includeBuild("../..") { dependencySubstitution { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db6295ff..82bbb3a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,6 +72,7 @@ ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotia ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +sqldelight-driver-desktop = { module = "app.cash.sqldelight:sqlite-driver", version.ref ="sqlDelight" } sqldelight-driver-ios = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } requery-sqlite-android = { module = "com.github.requery:sqlite-android", version.ref = "sqlite-android" } diff --git a/persistence/build.gradle.kts b/persistence/build.gradle.kts index dadee4b7..0b345af3 100644 --- a/persistence/build.gradle.kts +++ b/persistence/build.gradle.kts @@ -12,6 +12,7 @@ kotlin { iosX64() iosArm64() iosSimulatorArm64() + jvm() explicitApi() @@ -30,6 +31,10 @@ kotlin { iosMain.dependencies { api(libs.sqldelight.driver.ios) } + + jvmMain.dependencies { + implementation(libs.sqldelight.driver.desktop) + } } }