Repository: supabase-community/supabase-kt (kotlin)

Issue:
[Feature request]: Send standard client headers on all requests
### General Info

- [x] I installed the latest version of Supabase Kt
- [x] I checked for similar feature requests

### Feature request

For improving our internal usage reports, we need to send some client headers in all requests performed by the library.

Please include following headers:

- `x-supabase-client-platform`: which platform of operating system on which the client is running `"Android"`, `"Chrome OS"`, `"Chromium OS"`, `"iOS"`, `"Linux"`, `"macOS"`, `"Windows"`
- `x-supabase-client-platform-version`: version of the platform/operating system which the client is running `11.0.0` 

If value cannot be defined for some reason, send none.

Reference PR for Swift: https://github.com/supabase/supabase-swift/pull/685

### Usecase

_No response_

Pull request:
## What kind of change does this PR introduce?

Feature (closes #925)

## What is the new behavior?

New standard headers providing OS name & version

Patch:
diff --git a/Supabase/src/androidMain/kotlin/io/github/jan/supabase/PlatformTarget.android.kt b/Supabase/src/androidMain/kotlin/io/github/jan/supabase/PlatformTarget.android.kt
new file mode 100644
index 000000000..c5c3096b6
--- /dev/null
+++ b/Supabase/src/androidMain/kotlin/io/github/jan/supabase/PlatformTarget.android.kt
@@ -0,0 +1,6 @@
+package io.github.jan.supabase
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "Android",
+    version = android.os.Build.VERSION.RELEASE
+)
\ No newline at end of file
diff --git a/Supabase/src/appleMain/kotlin/io/supabase/supabase/Utils.kt b/Supabase/src/appleMain/kotlin/io/supabase/supabase/Utils.kt
new file mode 100644
index 000000000..0db8d6028
--- /dev/null
+++ b/Supabase/src/appleMain/kotlin/io/supabase/supabase/Utils.kt
@@ -0,0 +1,22 @@
+package io.supabase.supabase
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.UnsafeNumber
+import kotlinx.cinterop.useContents
+
+@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class)
+internal fun getOSVersion(): String {
+    val processInfo = platform.Foundation.NSProcessInfo.processInfo
+    return processInfo.operatingSystemVersion.useContents {
+        val majorVersion = this.majorVersion
+        val minorVersion = this.minorVersion
+        val patchVersion = this.patchVersion
+        buildString {
+            append(majorVersion)
+            append(".")
+            append(minorVersion)
+            append(".")
+            append(patchVersion)
+        }
+    }
+}
\ No newline at end of file
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/PlatformTarget.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/PlatformTarget.kt
index 6cc798d57..0de3a9a93 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/PlatformTarget.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/PlatformTarget.kt
@@ -1,6 +1,8 @@
 @file:Suppress("UndocumentedPublicProperty")
 package io.github.jan.supabase
 
+import io.github.jan.supabase.logging.e
+
 /**
  * Represents a target platform
  */
@@ -8,7 +10,32 @@ enum class PlatformTarget {
     JVM, ANDROID, JS, WASM_JS, IOS, WINDOWS, MACOS, TVOS, WATCHOS, LINUX;
 }
 
+data class OSInformation(
+    val name: String,
+    val version: String
+) {
+
+    companion object {
+        val CURRENT by lazy {
+            try {
+                getOSInformation()
+            } catch (e: Exception) {
+                SupabaseClient.LOGGER.e(e) {
+                    "Failed to get OS information, please report this issue"
+                }
+                null
+            }
+        }
+    }
+
+}
+
 /**
  * The current target platform
  */
-expect val CurrentPlatformTarget: PlatformTarget
\ No newline at end of file
+expect val CurrentPlatformTarget: PlatformTarget
+
+/**
+ * The current operating system information
+ */
+internal expect fun getOSInformation(): OSInformation?
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
index 3c423deb8..4348480bc 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClient.kt
@@ -119,7 +119,8 @@ internal class SupabaseClientImpl(
         supabaseKey,
         config.networkConfig.httpConfigOverrides,
         config.networkConfig.requestTimeout.inWholeMilliseconds,
-        config.networkConfig.httpEngine
+        config.networkConfig.httpEngine,
+        config.osInformation
     )
 
     override val pluginManager = PluginManager(config.plugins.toList().associate { (key, value) ->
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
index fd7a00cff..fa2efedd1 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientBuilder.kt
@@ -90,6 +90,11 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab
      */
     var accessToken: AccessTokenProvider? = null
 
+    /**
+     * The current operating system information.
+     */
+    var osInformation: OSInformation? = OSInformation.CURRENT
+
     private val httpConfigOverrides = mutableListOf<HttpConfigOverride>()
     private val plugins = mutableMapOf<String, PluginProvider>()
 
@@ -124,7 +129,8 @@ class SupabaseClientBuilder @PublishedApi internal constructor(private val supab
             defaultSerializer = defaultSerializer,
             coroutineDispatcher = coroutineDispatcher,
             accessToken = accessToken,
-            plugins = plugins
+            plugins = plugins,
+            osInformation = osInformation
         )
         return SupabaseClientImpl(
             config
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt
index 053e2c5b5..3e6983ef1 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/SupabaseClientConfig.kt
@@ -13,7 +13,8 @@ internal data class SupabaseClientConfig(
     val defaultSerializer: SupabaseSerializer,
     val coroutineDispatcher: CoroutineDispatcher,
     val accessToken: AccessTokenProvider?,
-    val plugins: Map<String, PluginProvider>
+    val plugins: Map<String, PluginProvider>,
+    val osInformation: OSInformation?
 )
 
 internal data class SupabaseNetworkConfig(
diff --git a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt
index cc4bdf7f0..2db17767e 100644
--- a/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt
+++ b/Supabase/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt
@@ -2,6 +2,7 @@
 package io.github.jan.supabase.network
 
 import io.github.jan.supabase.BuildConfig
+import io.github.jan.supabase.OSInformation
 import io.github.jan.supabase.SupabaseClient
 import io.github.jan.supabase.annotations.SupabaseInternal
 import io.github.jan.supabase.exceptions.HttpRequestException
@@ -42,7 +43,8 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor(
     private val supabaseKey: String,
     modifiers: List<HttpClientConfig<*>.() -> Unit> = listOf(),
     private val requestTimeout: Long,
-    engine: HttpClientEngine? = null
+    engine: HttpClientEngine? = null,
+    private val osInformation: OSInformation?
 ): SupabaseHttpClient() {
 
     init {
@@ -111,6 +113,10 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor(
                     append("apikey", supabaseKey)
                 }
                 append("X-Client-Info", "supabase-kt/${BuildConfig.PROJECT_VERSION}")
+                osInformation?.let {
+                    append("X-Supabase-Client-Platform", it.name)
+                    append("X-Supabase-Client-Platform-Version", it.version)
+                }
             }
             port = HTTPS_PORT
         }
diff --git a/Supabase/src/iosMain/kotlin/io/github/jan/supabase/PlatformTarget.ios.kt b/Supabase/src/iosMain/kotlin/io/github/jan/supabase/PlatformTarget.ios.kt
new file mode 100644
index 000000000..4de94c171
--- /dev/null
+++ b/Supabase/src/iosMain/kotlin/io/github/jan/supabase/PlatformTarget.ios.kt
@@ -0,0 +1,8 @@
+package io.github.jan.supabase
+
+import io.supabase.supabase.getOSVersion
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "iOS",
+    version = getOSVersion()
+)
\ No newline at end of file
diff --git a/Supabase/src/jsMain/kotlin/io/github/jan/supabase/PlatformTarget.js.kt b/Supabase/src/jsMain/kotlin/io/github/jan/supabase/PlatformTarget.js.kt
new file mode 100644
index 000000000..c492a16d3
--- /dev/null
+++ b/Supabase/src/jsMain/kotlin/io/github/jan/supabase/PlatformTarget.js.kt
@@ -0,0 +1,3 @@
+package io.github.jan.supabase
+
+internal actual fun getOSInformation(): OSInformation? = null
\ No newline at end of file
diff --git a/Supabase/src/jvmMain/kotlin/io/github/jan/supabase/PlatformTarget.jvm.kt b/Supabase/src/jvmMain/kotlin/io/github/jan/supabase/PlatformTarget.jvm.kt
new file mode 100644
index 000000000..075c1ffef
--- /dev/null
+++ b/Supabase/src/jvmMain/kotlin/io/github/jan/supabase/PlatformTarget.jvm.kt
@@ -0,0 +1,6 @@
+package io.github.jan.supabase
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = System.getProperty("os.name"),
+    version = System.getProperty("os.version")
+)
\ No newline at end of file
diff --git a/Supabase/src/linuxMain/kotlin/io/github/jan/supabase/PlatformTarget.linux.kt b/Supabase/src/linuxMain/kotlin/io/github/jan/supabase/PlatformTarget.linux.kt
new file mode 100644
index 000000000..845a7dd65
--- /dev/null
+++ b/Supabase/src/linuxMain/kotlin/io/github/jan/supabase/PlatformTarget.linux.kt
@@ -0,0 +1,28 @@
+package io.github.jan.supabase
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.refTo
+import platform.posix.fclose
+import platform.posix.fopen
+import platform.posix.fread
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "Linux",
+    version = getOSVersion()
+)
+
+private fun getOSVersion(): String = readProcVersion() ?: "Unknown"
+
+@OptIn(ExperimentalForeignApi::class)
+fun readProcVersion(): String? {
+    val path = "/proc/version"
+    val file = fopen(path, "r") ?: return null
+    try {
+        val buffer = ByteArray(1024)
+        val bytesRead = fread(buffer.refTo(0), 1.convert(), buffer.size.convert(), file).toInt()
+        return buffer.decodeToString(endIndex = bytesRead).trim()
+    } finally {
+        fclose(file)
+    }
+}
diff --git a/Supabase/src/macosMain/kotlin/io/github/jan/supabase/PlatformTarget.macos.kt b/Supabase/src/macosMain/kotlin/io/github/jan/supabase/PlatformTarget.macos.kt
new file mode 100644
index 000000000..670779002
--- /dev/null
+++ b/Supabase/src/macosMain/kotlin/io/github/jan/supabase/PlatformTarget.macos.kt
@@ -0,0 +1,8 @@
+package io.github.jan.supabase
+
+import io.supabase.supabase.getOSVersion
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "macOS",
+    version = getOSVersion()
+)
\ No newline at end of file
diff --git a/Supabase/src/mingwMain/kotlin/io/github/jan/supabase/PlatformTarget.mingw.kt b/Supabase/src/mingwMain/kotlin/io/github/jan/supabase/PlatformTarget.mingw.kt
new file mode 100644
index 000000000..b4486fa90
--- /dev/null
+++ b/Supabase/src/mingwMain/kotlin/io/github/jan/supabase/PlatformTarget.mingw.kt
@@ -0,0 +1,17 @@
+package io.github.jan.supabase
+
+import platform.windows.DWORD
+import platform.windows.GetVersion
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "Windows",
+    version = getOSVersion()
+)
+
+private fun getOSVersion(): String {
+    val dwVersion: DWORD = GetVersion()
+
+    val dwMajorVersion = dwVersion and 255u
+    val dwMinorVersion = (dwVersion shr 8) and 255u
+    return "$dwMajorVersion.$dwMinorVersion"
+}
\ No newline at end of file
diff --git a/Supabase/src/tvosMain/kotlin/io/github/jan/supabase/PlatformTarget.tvos.kt b/Supabase/src/tvosMain/kotlin/io/github/jan/supabase/PlatformTarget.tvos.kt
new file mode 100644
index 000000000..97515464b
--- /dev/null
+++ b/Supabase/src/tvosMain/kotlin/io/github/jan/supabase/PlatformTarget.tvos.kt
@@ -0,0 +1,8 @@
+package io.github.jan.supabase
+
+import io.supabase.supabase.getOSVersion
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "tvOS",
+    version = getOSVersion()
+)
\ No newline at end of file
diff --git a/Supabase/src/wasmJsMain/kotlin/io/github/jan/supabase/PlatformTarget.wasmJs.kt b/Supabase/src/wasmJsMain/kotlin/io/github/jan/supabase/PlatformTarget.wasmJs.kt
index ea6e549ee..1bad049fa 100644
--- a/Supabase/src/wasmJsMain/kotlin/io/github/jan/supabase/PlatformTarget.wasmJs.kt
+++ b/Supabase/src/wasmJsMain/kotlin/io/github/jan/supabase/PlatformTarget.wasmJs.kt
@@ -3,4 +3,6 @@ package io.github.jan.supabase
 /**
  * The current target platform
  */
-actual val CurrentPlatformTarget: PlatformTarget = PlatformTarget.WASM_JS
\ No newline at end of file
+actual val CurrentPlatformTarget: PlatformTarget = PlatformTarget.WASM_JS
+
+internal actual fun getOSInformation(): OSInformation? = null
\ No newline at end of file
diff --git a/Supabase/src/watchosMain/kotlin/io/github/jan/supabase/PlatformTarget.watchos.kt b/Supabase/src/watchosMain/kotlin/io/github/jan/supabase/PlatformTarget.watchos.kt
new file mode 100644
index 000000000..97515464b
--- /dev/null
+++ b/Supabase/src/watchosMain/kotlin/io/github/jan/supabase/PlatformTarget.watchos.kt
@@ -0,0 +1,8 @@
+package io.github.jan.supabase
+
+import io.supabase.supabase.getOSVersion
+
+internal actual fun getOSInformation(): OSInformation? = OSInformation(
+    name = "tvOS",
+    version = getOSVersion()
+)
\ No newline at end of file


Test patch:
diff --git a/Supabase/src/jvmTest/kotlin/PlatformTargetTest.kt b/Supabase/src/jvmTest/kotlin/PlatformTargetTest.kt
index 6b9197e43..edfb677fe 100644
--- a/Supabase/src/jvmTest/kotlin/PlatformTargetTest.kt
+++ b/Supabase/src/jvmTest/kotlin/PlatformTargetTest.kt
@@ -1,5 +1,6 @@
 import io.github.jan.supabase.CurrentPlatformTarget
 import io.github.jan.supabase.PlatformTarget
+import io.github.jan.supabase.getOSInformation
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
@@ -10,4 +11,13 @@ actual class PlatformTargetTest {
         assertEquals(PlatformTarget.JVM, CurrentPlatformTarget, "The current platform target should be DESKTOP")
     }
 
+    @Test
+    fun testGetOSInformation() {
+        System.setProperty("os.name", "Linux")
+        System.setProperty("os.version", "5.4.0")
+        val osInfo = getOSInformation()
+        assertEquals("Linux", osInfo?.name, "OS name should be Linux")
+        assertEquals("5.4.0", osInfo?.version, "OS version should be 5.4.0")
+    }
+
 }
\ No newline at end of file
diff --git a/test-common/src/commonTest/kotlin/SupabaseClientTest.kt b/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
index 50a896852..edc916d8b 100644
--- a/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
+++ b/test-common/src/commonTest/kotlin/SupabaseClientTest.kt
@@ -1,4 +1,5 @@
 import io.github.jan.supabase.BuildConfig
+import io.github.jan.supabase.OSInformation
 import io.github.jan.supabase.SupabaseClient
 import io.github.jan.supabase.logging.LogLevel
 import io.github.jan.supabase.testing.createMockedSupabaseClient
@@ -30,6 +31,36 @@ class SupabaseClientTest {
         }
     }
 
+    @Test
+    fun testOSVersionHeader() {
+        runTest {
+            val client = createMockedSupabaseClient(
+                supabaseUrl = "https://example.supabase.co",
+                supabaseKey = "somekey",
+                requestHandler = {
+                    assertEquals(
+                        "TestOS",
+                        it.headers["X-Supabase-Client-Platform"],
+                        "X-Supabase-Client-Platform header should be set to 'TestOS'"
+                    )
+                    assertEquals(
+                        "1.0.0",
+                        it.headers["X-Supabase-Client-Platform-Version"],
+                        "X-Supabase-Client-Platform-Version header should be set to '1.0.0'"
+                    )
+                    respond("")
+                },
+                configuration = {
+                    osInformation = OSInformation(
+                        name = "TestOS",
+                        version = "1.0.0",
+                    )
+                }
+            )
+            client.httpClient.get("")
+        }
+    }
+
     @Test
     fun testAccessTokenProvider() {
         runTest {