├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .github
├── dependabot.yml
└── workflows
│ ├── publish-on-maven-central.yml
│ └── publish-dokka-doc.yml
├── platformtools
├── core
│ ├── src
│ │ ├── jsMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.js.kt
│ │ │ │ └── OsProvider.js.kt
│ │ ├── jvmMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.jvm.kt
│ │ │ │ ├── CacheProvider.jvm.kt
│ │ │ │ ├── VersionProvider.jvm.kt
│ │ │ │ ├── OsProvider.jvm.kt
│ │ │ │ └── LinuxEnvironment.kt
│ │ ├── iosMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.ios.kt
│ │ │ │ └── OsProvider.ios.kt
│ │ ├── androidMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.android.kt
│ │ │ │ ├── OsProvider.android.kt
│ │ │ │ ├── CacheProvider.android.kt
│ │ │ │ └── VersionProvider.android.kt
│ │ ├── wasmJsMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.wasmJs.kt
│ │ │ │ └── OsProvider.wasmJs.kt
│ │ ├── macosMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.macos.kt
│ │ │ │ └── OsProvider.macos.kt
│ │ ├── linuxX64Main
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.linuxX64.kt
│ │ │ │ └── OsProvider.linuxX64.kt
│ │ ├── mingwX64Main
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── PlatformProvider.mingwX64.kt
│ │ │ │ └── OsProvider.mingwX64.kt
│ │ ├── androidJvmMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ ├── VersionProvider.kt
│ │ │ │ └── CacheProvider.kt
│ │ └── commonMain
│ │ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── kdroidfilter
│ │ │ └── platformtools
│ │ │ ├── OsProvider.kt
│ │ │ └── PlatformProvider.kt
│ └── build.gradle.kts
├── releasefetcher
│ ├── src
│ │ ├── wasmJsMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── releasefetcher
│ │ │ │ ├── github
│ │ │ │ └── VersionUtils.wasmJs.kt
│ │ │ │ └── config
│ │ │ │ └── Client.wasmJs.kt
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── releasefetcher
│ │ │ │ ├── github
│ │ │ │ ├── VersionUtils.kt
│ │ │ │ ├── model
│ │ │ │ │ ├── Asset.kt
│ │ │ │ │ ├── Release.kt
│ │ │ │ │ ├── Author.kt
│ │ │ │ │ └── Uploader.kt
│ │ │ │ └── GitHubReleaseFetcher.kt
│ │ │ │ ├── config
│ │ │ │ └── Client.kt
│ │ │ │ └── downloader
│ │ │ │ └── ReleaseFetcherConfig.kt
│ │ ├── androidJvmMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── releasefetcher
│ │ │ │ └── github
│ │ │ │ └── VersionUtils.androidJvm.kt
│ │ ├── jvmMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── releasefetcher
│ │ │ │ └── config
│ │ │ │ └── Client.jvm.kt
│ │ └── androidMain
│ │ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── kdroidfilter
│ │ │ └── platformtools
│ │ │ └── releasefetcher
│ │ │ └── config
│ │ │ └── Client.android.kt
│ └── build.gradle.kts
├── clipboardmanager
│ ├── src
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── clipboardmanager
│ │ │ │ ├── ClipboardListener.kt
│ │ │ │ ├── ClipboardMonitorFactory.kt
│ │ │ │ ├── ClipboardMonitor.kt
│ │ │ │ └── ClipboardContent.kt
│ │ ├── iosMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── clipboardmanager
│ │ │ │ └── ClipboardMonitorFactory.ios.kt
│ │ ├── jvmMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── clipboardmanager
│ │ │ │ ├── ClipboardMonitorFactory.kt
│ │ │ │ ├── windows
│ │ │ │ ├── User32Extended.kt
│ │ │ │ └── WindowsClipboardMonitor.kt
│ │ │ │ └── AwtOSClipboardMonitor.kt
│ │ └── androidMain
│ │ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── kdroidfilter
│ │ │ └── platformtools
│ │ │ └── clipboardmanager
│ │ │ ├── ClipboardMonitorFactory.android.kt
│ │ │ └── AndroidClipboardMonitor.kt
│ └── build.gradle.kts
├── darkmodedetector
│ ├── src
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── darkmodedetector
│ │ │ │ └── IsSystemInDarkMode.kt
│ │ ├── jsMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── darkmodedetector
│ │ │ │ └── IsSystemInDarkMode.js.kt
│ │ ├── nativeMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── darkmodedetector
│ │ │ │ └── IsSystemInDarkMode.native.kt
│ │ ├── wasmJsMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── darkmodedetector
│ │ │ │ └── IsSystemInDarkMode.wasmJs.kt
│ │ ├── androidMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── darkmodedetector
│ │ │ │ └── IsSystemInDarkMode.android.kt
│ │ └── jvmMain
│ │ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── kdroidfilter
│ │ │ └── platformtools
│ │ │ └── darkmodedetector
│ │ │ ├── linux
│ │ │ ├── XfceThemeDetector.kt
│ │ │ ├── MateThemeDetector.kt
│ │ │ ├── CinnamonThemeDetector.kt
│ │ │ ├── LinuxThemeDetector.kt
│ │ │ ├── GnomeThemeDetector.kt
│ │ │ └── KdeThemeDetector.kt
│ │ │ ├── windows
│ │ │ ├── Dwmapi.kt
│ │ │ └── WindowsThemeDetector.kt
│ │ │ ├── IsSystemInDarkMode.jvm.kt
│ │ │ └── mac
│ │ │ ├── MacOSTitleBar.kt
│ │ │ └── MacOSThemeDetector.kt
│ └── build.gradle.kts
├── appmanager
│ ├── src
│ │ ├── androidMain
│ │ │ ├── res
│ │ │ │ └── xml
│ │ │ │ │ └── file_paths.xml
│ │ │ ├── kotlin
│ │ │ │ └── io
│ │ │ │ │ └── github
│ │ │ │ │ └── kdroidfilter
│ │ │ │ │ └── platformtools
│ │ │ │ │ └── appmanager
│ │ │ │ │ ├── AppRestarter.android.kt
│ │ │ │ │ └── restartappmanager
│ │ │ │ │ ├── RestartManagerActivity.kt
│ │ │ │ │ ├── RestartManagerService.kt
│ │ │ │ │ └── ProcessRestarter.kt
│ │ │ └── AndroidManifest.xml
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── kdroidfilter
│ │ │ │ └── platformtools
│ │ │ │ └── appmanager
│ │ │ │ ├── AppRestarter.kt
│ │ │ │ └── AppVersionChecker.kt
│ │ └── jvmMain
│ │ │ └── kotlin
│ │ │ └── io
│ │ │ └── github
│ │ │ └── kdroidfilter
│ │ │ └── platformtools
│ │ │ └── appmanager
│ │ │ ├── AppRestarter.jvm.kt
│ │ │ ├── AppInstaller.kt
│ │ │ ├── WindowsPrivilegeHelper.kt
│ │ │ └── AppInstaller.jvm.kt
│ └── build.gradle.kts
└── rtlwindows
│ ├── src
│ └── jvmMain
│ │ └── kotlin
│ │ └── io
│ │ └── github
│ │ └── kdroidfilter
│ │ └── platformtools
│ │ └── rtlwindows
│ │ ├── User32.kt
│ │ └── WindowsRtlLayout.kt
│ └── build.gradle.kts
├── sample
├── composeApp
│ ├── src
│ │ ├── androidMain
│ │ │ ├── kotlin
│ │ │ │ └── sample
│ │ │ │ │ └── app
│ │ │ │ │ ├── LinuxInfoSection.android.kt
│ │ │ │ │ └── main.kt
│ │ │ └── AndroidManifest.xml
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── sample
│ │ │ │ └── app
│ │ │ │ ├── LinuxInfoSection.common.kt
│ │ │ │ ├── ReleaseFetcherDemo.kt
│ │ │ │ ├── CoreDemo.kt
│ │ │ │ ├── AppManagerDemo.kt
│ │ │ │ ├── PermissionHandlerDemo.kt
│ │ │ │ ├── ClipboardDemo.kt
│ │ │ │ ├── App.kt
│ │ │ │ └── GitHubRepoFetcherDemo.kt
│ │ └── jvmMain
│ │ │ └── kotlin
│ │ │ └── sample
│ │ │ └── app
│ │ │ ├── main.kt
│ │ │ └── LinuxInfoSection.jvm.kt
│ └── build.gradle.kts
└── terminalApp
│ ├── src
│ └── commonMain
│ │ └── kotlin
│ │ └── main.kt
│ └── build.gradle.kts
├── .gitignore
├── gradle.properties
├── LICENSE
├── settings.gradle.kts
├── AGENTS.md
├── gradlew.bat
└── gradlew
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kdroidFilter/Platform-Tools/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gradle"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
--------------------------------------------------------------------------------
/platformtools/core/src/jsMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.js.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.JS
--------------------------------------------------------------------------------
/platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.JVM
--------------------------------------------------------------------------------
/platformtools/core/src/iosMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.ios.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.IOS_NATIVE
--------------------------------------------------------------------------------
/platformtools/core/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.ANDROID
--------------------------------------------------------------------------------
/platformtools/core/src/wasmJsMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.WASM_JS
--------------------------------------------------------------------------------
/sample/composeApp/src/androidMain/kotlin/sample/app/LinuxInfoSection.android.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | actual fun LinuxInfoSection() {
7 | }
--------------------------------------------------------------------------------
/platformtools/core/src/macosMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.macos.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.MAC_OS_NATIVE
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/LinuxInfoSection.common.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | internal expect fun LinuxInfoSection()
7 |
--------------------------------------------------------------------------------
/platformtools/core/src/iosMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.ios.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem = OperatingSystem.IOS
4 |
--------------------------------------------------------------------------------
/platformtools/core/src/linuxX64Main/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.linuxX64.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.LINUX_NATIVE
--------------------------------------------------------------------------------
/platformtools/core/src/mingwX64Main/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.mingwX64.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getPlatform(): Platform = Platform.WINDOWS_NATIVE
--------------------------------------------------------------------------------
/platformtools/core/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem = OperatingSystem.ANDROID
--------------------------------------------------------------------------------
/platformtools/core/src/linuxX64Main/kotlin/io/github/kdroidfilter/platformtools/OsProvider.linuxX64.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem = OperatingSystem.LINUX
--------------------------------------------------------------------------------
/platformtools/core/src/macosMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.macos.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem = OperatingSystem.MACOS
4 |
--------------------------------------------------------------------------------
/platformtools/core/src/mingwX64Main/kotlin/io/github/kdroidfilter/platformtools/OsProvider.mingwX64.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem = OperatingSystem.WINDOWS
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | *.iml
3 | .gradle
4 | .idea
5 | .kotlin
6 | .DS_Store
7 | build
8 | */build
9 | captures
10 | .externalNativeBuild
11 | .cxx
12 | local.properties
13 | xcuserdata/
14 | Pods/
15 | *.jks
16 | *.gpg
17 | *yarn.lock
18 |
--------------------------------------------------------------------------------
/sample/terminalApp/src/commonMain/kotlin/main.kt:
--------------------------------------------------------------------------------
1 | import io.github.kdroidfilter.platformtools.getOperatingSystem
2 |
3 | fun main() {
4 | println("The Operating System is " + getOperatingSystem().name.lowercase().replaceFirstChar { it.uppercase()})
5 | }
--------------------------------------------------------------------------------
/platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/CacheProvider.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 |
4 | import java.io.File
5 |
6 | actual fun getCacheDir(): File = File(System.getProperty("java.io.tmpdir"))
7 |
--------------------------------------------------------------------------------
/platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/VersionProvider.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getAppVersion(): String {
4 | return System.getProperty("jpackage.app-version") ?: "0.1.0"
5 | }
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/wasmJsMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/VersionUtils.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github
2 |
3 | actual fun getCurrentAppVersion(): String = "0.0.0"
4 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardListener.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | interface ClipboardListener {
4 | fun onClipboardChange(content: ClipboardContent)
5 | }
6 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/VersionUtils.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github
2 |
3 | /** Returns the current application version. */
4 | expect fun getCurrentAppVersion(): String
5 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | expect fun isSystemInDarkMode() : Boolean
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/wasmJsMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/config/Client.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.config
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.js.Js
5 |
6 | actual val client = HttpClient(Js)
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardMonitorFactory.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | expect object ClipboardMonitorFactory {
4 | fun create(listener: ClipboardListener): ClipboardMonitor
5 | }
6 |
--------------------------------------------------------------------------------
/platformtools/core/src/androidJvmMain/kotlin/io/github/kdroidfilter/platformtools/VersionProvider.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | /**
4 | * Retrieves the version of the application.
5 | *
6 | * @return A string representing the application version.
7 | */
8 | expect fun getAppVersion(): String
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/androidJvmMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/VersionUtils.androidJvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github
2 |
3 | import io.github.kdroidfilter.platformtools.getAppVersion
4 |
5 | actual fun getCurrentAppVersion(): String = getAppVersion()
6 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardMonitor.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | interface ClipboardMonitor {
4 | fun start()
5 | fun stop()
6 | fun isRunning(): Boolean
7 | fun getCurrentContent(): ClipboardContent
8 | }
9 |
--------------------------------------------------------------------------------
/platformtools/core/src/androidJvmMain/kotlin/io/github/kdroidfilter/platformtools/CacheProvider.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | import java.io.File
4 |
5 | /**
6 | * Provides the directory used for storing cached data.
7 | *
8 | * @return A File object representing the cache directory.
9 | */
10 | expect fun getCacheDir(): File
11 |
--------------------------------------------------------------------------------
/platformtools/core/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/CacheProvider.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 |
4 | import com.kdroid.androidcontextprovider.ContextProvider
5 | import java.io.File
6 |
7 | actual fun getCacheDir(): File {
8 | val context = ContextProvider.getContext()
9 | return context.cacheDir
10 | }
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jsMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.js.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | actual fun isSystemInDarkMode(): Boolean = isSystemInDarkTheme()
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/nativeMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.native.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | actual fun isSystemInDarkMode(): Boolean = isSystemInDarkTheme()
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/wasmJsMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | actual fun isSystemInDarkMode(): Boolean = isSystemInDarkTheme()
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | actual fun isSystemInDarkMode(): Boolean = isSystemInDarkTheme()
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardContent.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | data class ClipboardContent(
4 | val text: String? = null,
5 | val html: String? = null,
6 | val rtf: String? = null,
7 | val files: List? = null,
8 | val imageAvailable: Boolean = false,
9 | val timestamp: Long
10 | )
11 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/config/Client.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.config
2 |
3 | import io.github.kdroidfilter.platformtools.releasefetcher.downloader.ReleaseFetcherConfig
4 | import io.ktor.client.*
5 | import io.ktor.client.engine.cio.*
6 | import io.ktor.client.plugins.*
7 |
8 | // Global Ktor client configuration
9 | expect val client : HttpClient
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/downloader/ReleaseFetcherConfig.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.downloader
2 |
3 | import io.ktor.client.plugins.HttpTimeoutConfig
4 |
5 | /**
6 | * Configuration object for managing settings used during operations.
7 | */
8 | object ReleaseFetcherConfig {
9 | var clientTimeOut: Long = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
10 | }
11 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Gradle
2 | org.gradle.jvmargs=-Xmx4G
3 | org.gradle.caching=true
4 | org.gradle.configuration-cache=true
5 | org.gradle.daemon=true
6 | org.gradle.parallel=true
7 |
8 | #Kotlin
9 | kotlin.code.style=official
10 | kotlin.daemon.jvmargs=-Xmx4G
11 |
12 | #Android
13 | android.useAndroidX=true
14 | android.nonTransitiveRClass=true
15 |
16 | #Compose
17 | org.jetbrains.compose.experimental.jscanvas.enabled=true
18 | org.jetbrains.compose.experimental.macos.enabled=true
19 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppRestarter.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import com.kdroid.androidcontextprovider.ContextProvider
4 | import io.github.kdroidfilter.platformtools.appmanager.restartappmanager.ProcessRestarter
5 |
6 | actual fun restartApplication() {
7 | val context = ContextProvider.getContext()
8 | ProcessRestarter.triggerRebirth(context);
9 | }
10 |
11 |
--------------------------------------------------------------------------------
/sample/terminalApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.multiplatform)
3 | }
4 |
5 | kotlin {
6 | listOf(
7 | macosX64(),
8 | macosArm64(),
9 | linuxX64(),
10 | mingwX64(),
11 | ).forEach {
12 | it.binaries.executable {
13 | entryPoint = "main"
14 | }
15 | }
16 |
17 | sourceSets {
18 | commonMain.dependencies {
19 | implementation(project(":platformtools:core"))
20 | }
21 | }
22 | }
23 |
24 |
25 |
--------------------------------------------------------------------------------
/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 |
7 | class AppActivity : ComponentActivity() {
8 | override fun onCreate(savedInstanceState: Bundle?) {
9 | super.onCreate(savedInstanceState)
10 | io.github.kdroidfilter.platformtools.clipboardmanager.ClipboardMonitorFactory.init(this)
11 |
12 | setContent {
13 | App()
14 | }
15 | }
16 | }
17 |
18 |
19 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/iosMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardMonitorFactory.ios.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | actual object ClipboardMonitorFactory {
4 | actual fun create(listener: ClipboardListener): ClipboardMonitor {
5 | // TODO: Implement iOS ClipboardMonitor using UIPasteboard and periodic polling or notifications
6 | throw NotImplementedError("iOS ClipboardMonitor is not implemented yet. TODO: Implement using UIPasteboard.generalPasteboard")
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem {
4 | val osName = System.getProperty("os.name").lowercase()
5 | return when {
6 | osName.contains("win") -> OperatingSystem.WINDOWS
7 | osName.contains("mac") -> OperatingSystem.MACOS
8 | osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> OperatingSystem.LINUX
9 | else -> OperatingSystem.UNKNOWN
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppRestarter.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | /**
4 | * Restarts the application.
5 | *
6 | * This function triggers a full application restart. It is generally used in scenarios where
7 | * a restart is necessary, such as after applying critical updates or configuration changes.
8 | *
9 | * Implementation is platform-specific and may utilize different mechanisms to restart the
10 | * application depending on the operating system and environment.
11 | */
12 | expect fun restartApplication()
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/config/Client.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.config
2 |
3 | import io.github.kdroidfilter.platformtools.releasefetcher.downloader.ReleaseFetcherConfig
4 | import io.ktor.client.HttpClient
5 | import io.ktor.client.engine.cio.CIO
6 | import io.ktor.client.plugins.HttpTimeout
7 |
8 | actual val client: HttpClient = HttpClient(CIO) {
9 | followRedirects = true
10 |
11 | install(HttpTimeout) {
12 | requestTimeoutMillis = ReleaseFetcherConfig.clientTimeOut
13 | }
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/config/Client.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.config
2 |
3 | import io.github.kdroidfilter.platformtools.releasefetcher.downloader.ReleaseFetcherConfig
4 | import io.ktor.client.HttpClient
5 | import io.ktor.client.engine.cio.CIO
6 | import io.ktor.client.plugins.HttpTimeout
7 |
8 | actual val client: HttpClient = HttpClient(CIO) {
9 | followRedirects = true
10 |
11 | install(HttpTimeout) {
12 | requestTimeoutMillis = ReleaseFetcherConfig.clientTimeOut
13 | }
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/XfceThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import java.io.BufferedReader
4 | import java.io.InputStreamReader
5 |
6 | internal fun detectXfceDarkTheme(): Boolean? {
7 | return try {
8 | val p = Runtime.getRuntime().exec(arrayOf("xfconf-query", "-c", "xsettings", "-p", "/Net/ThemeName"))
9 | val theme = BufferedReader(InputStreamReader(p.inputStream)).use { it.readLine()?.trim() }
10 | theme?.contains("dark", ignoreCase = true)
11 | } catch (_: Exception) {
12 | null
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/MateThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import java.io.BufferedReader
4 | import java.io.InputStreamReader
5 |
6 | internal fun detectMateDarkTheme(): Boolean? {
7 | return try {
8 | val p = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.mate.interface", "gtk-theme"))
9 | val theme = BufferedReader(InputStreamReader(p.inputStream)).use { it.readLine()?.trim('\'', '"') }
10 | theme?.contains("dark", ignoreCase = true)
11 | } catch (_: Exception) {
12 | null
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/model/Asset.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github.model
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Asset(
7 | val url: String,
8 | val id: Int,
9 | val node_id: String,
10 | val name: String,
11 | val label: String? = null,
12 | val uploader: Uploader,
13 | val content_type: String,
14 | val state: String,
15 | val size: Int,
16 | val download_count: Int,
17 | val created_at: String,
18 | val updated_at: String,
19 | val browser_download_url: String
20 | )
21 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/CinnamonThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import java.io.BufferedReader
4 | import java.io.InputStreamReader
5 |
6 | internal fun detectCinnamonDarkTheme(): Boolean? {
7 | return try {
8 | val p = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.cinnamon.desktop.interface", "gtk-theme"))
9 | val theme = BufferedReader(InputStreamReader(p.inputStream)).use { it.readLine()?.trim('\'', '"') }
10 | theme?.contains("dark", ignoreCase = true)
11 | } catch (_: Exception) {
12 | null
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardMonitorFactory.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | import com.sun.jna.Platform
4 | import io.github.kdroidfilter.platformtools.clipboardmanager.windows.WindowsClipboardMonitor
5 |
6 | actual object ClipboardMonitorFactory {
7 | actual fun create(listener: ClipboardListener): ClipboardMonitor {
8 | return when {
9 | Platform.isWindows() -> WindowsClipboardMonitor(listener)
10 | Platform.isMac() || Platform.isLinux() -> AwtOSClipboardMonitor(listener)
11 | else -> error("Unsupported OS type: ${Platform.getOSType()}")
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/windows/Dwmapi.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.windows
2 |
3 | import com.sun.jna.Library
4 | import com.sun.jna.Native
5 | import com.sun.jna.Pointer
6 | import com.sun.jna.platform.win32.WinDef.HWND
7 | import com.sun.jna.win32.W32APIOptions
8 |
9 | internal interface DwmApi : Library {
10 | companion object {
11 | val INSTANCE: DwmApi = Native.load("dwmapi", DwmApi::class.java, W32APIOptions.DEFAULT_OPTIONS)
12 | const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20
13 | }
14 |
15 | fun DwmSetWindowAttribute(
16 | hwnd: HWND,
17 | dwAttribute: Int,
18 | pvAttribute: Pointer,
19 | cbAttribute: Int
20 | ): Int
21 | }
--------------------------------------------------------------------------------
/platformtools/appmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppRestarter.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import java.io.File
4 | import kotlin.system.exitProcess
5 |
6 | object AppManager {
7 | val applicationExecutablePath: String by lazy {
8 | try {
9 | File(ProcessHandle.current().info().command().get()).absolutePath
10 | } catch (e: Exception) {
11 | e.printStackTrace()
12 | throw RuntimeException("Failed to get application executable path")
13 | }
14 | }
15 | }
16 |
17 | actual fun restartApplication() {
18 | try {
19 | val processBuilder = ProcessBuilder(AppManager.applicationExecutablePath)
20 | processBuilder.start()
21 | exitProcess(0)
22 | } catch (e: Exception) {
23 | e.printStackTrace()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/model/Release.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github.model
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Release(
7 | val url: String,
8 | val assets_url: String,
9 | val upload_url: String,
10 | val html_url: String,
11 | val id: Int,
12 | val author: Author,
13 | val node_id: String,
14 | val tag_name: String,
15 | val target_commitish: String,
16 | val name: String,
17 | val draft: Boolean,
18 | val prerelease: Boolean,
19 | val created_at: String,
20 | val published_at: String,
21 | val assets: List,
22 | val tarball_url: String,
23 | val zipball_url: String,
24 | val body: String? = null,
25 | val mentions_count: Int? = null
26 | )
--------------------------------------------------------------------------------
/platformtools/core/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/VersionProvider.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | import com.kdroid.androidcontextprovider.ContextProvider
4 |
5 | actual fun getAppVersion(): String {
6 | val context = ContextProvider.getContext()
7 | return try {
8 | val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
9 | packageInfo.versionName ?: "Unknown"
10 | } catch (e: Exception) {
11 | "Error: ${e.message}"
12 | }
13 | }
14 |
15 | fun getAppVersion(packageName: String): String {
16 | val context = ContextProvider.getContext()
17 | return try {
18 | val packageInfo = context.packageManager.getPackageInfo(packageName, 0)
19 | packageInfo.versionName ?: "Unknown"
20 | } catch (e: Exception) {
21 | "Error: ${e.message}"
22 | }
23 | }
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/model/Author.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github.model
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Author(
7 | val login: String,
8 | val id: Int,
9 | val node_id: String,
10 | val avatar_url: String,
11 | val gravatar_id: String,
12 | val url: String,
13 | val html_url: String,
14 | val followers_url: String,
15 | val following_url: String,
16 | val gists_url: String,
17 | val starred_url: String,
18 | val subscriptions_url: String,
19 | val organizations_url: String,
20 | val repos_url: String,
21 | val events_url: String,
22 | val received_events_url: String,
23 | val type: String,
24 | val user_view_type: String,
25 | val site_admin: Boolean
26 | )
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/LinuxThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import androidx.compose.runtime.Composable
4 | import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment
5 | import io.github.kdroidfilter.platformtools.detectLinuxDesktopEnvironment
6 |
7 |
8 | @Composable
9 | fun isLinuxInDarkMode(): Boolean {
10 | return when (detectLinuxDesktopEnvironment()) {
11 | LinuxDesktopEnvironment.KDE -> isKdeInDarkMode()
12 | LinuxDesktopEnvironment.GNOME -> isGnomeInDarkMode()
13 | LinuxDesktopEnvironment.XFCE -> detectXfceDarkTheme() ?: false
14 | LinuxDesktopEnvironment.CINNAMON -> detectCinnamonDarkTheme() ?: false
15 | LinuxDesktopEnvironment.MATE -> detectMateDarkTheme() ?: false
16 | else -> false
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/model/Uploader.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github.model
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class Uploader(
7 | val login: String,
8 | val id: Int,
9 | val node_id: String,
10 | val avatar_url: String,
11 | val gravatar_id: String,
12 | val url: String,
13 | val html_url: String,
14 | val followers_url: String,
15 | val following_url: String,
16 | val gists_url: String,
17 | val starred_url: String,
18 | val subscriptions_url: String,
19 | val organizations_url: String,
20 | val repos_url: String,
21 | val events_url: String,
22 | val received_events_url: String,
23 | val type: String,
24 | val user_view_type: String,
25 | val site_admin: Boolean
26 | )
27 |
28 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/ClipboardMonitorFactory.android.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | import android.content.Context
4 |
5 | /**
6 | * Android actual for ClipboardMonitorFactory.
7 | * You MUST call init(context) once (e.g., in Application.onCreate) before create(...).
8 | */
9 | actual object ClipboardMonitorFactory {
10 |
11 | private lateinit var appContext: Context
12 |
13 | fun init(context: Context) {
14 | appContext = context.applicationContext
15 | }
16 |
17 | actual fun create(listener: ClipboardListener): ClipboardMonitor {
18 | check(::appContext.isInitialized) {
19 | "ClipboardMonitorFactory.init(context) must be called before create(listener)."
20 | }
21 | return AndroidClipboardMonitor(appContext, listener)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/windows/User32Extended.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager.windows
2 |
3 | import com.sun.jna.Native
4 | import com.sun.jna.platform.win32.User32
5 | import com.sun.jna.platform.win32.WinDef.DWORD
6 | import com.sun.jna.platform.win32.WinDef
7 | import com.sun.jna.win32.W32APIOptions
8 |
9 | internal interface User32Extended : User32 {
10 | fun AddClipboardFormatListener(hWnd: WinDef.HWND): Boolean
11 | fun RemoveClipboardFormatListener(hWnd: WinDef.HWND): Boolean
12 | fun GetClipboardSequenceNumber(): DWORD
13 |
14 | companion object {
15 | // W32APIOptions.DEFAULT_OPTIONS sets Unicode + useLastError=true,
16 | // so Kernel32.GetLastError() will return the right code.
17 | val INSTANCE: User32Extended =
18 | Native.load("user32", User32Extended::class.java, W32APIOptions.DEFAULT_OPTIONS)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/platformtools/core/src/jsMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.js.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | actual fun getOperatingSystem(): OperatingSystem {
4 | val userAgent = js("navigator.userAgent") as String
5 | val platform = js("navigator.platform") as String
6 |
7 | return when {
8 | userAgent.contains("Windows", ignoreCase = true) -> OperatingSystem.WINDOWS
9 | userAgent.contains("Macintosh", ignoreCase = true) || platform.contains("Mac", ignoreCase = true) -> OperatingSystem.MACOS
10 | userAgent.contains("Linux", ignoreCase = true) -> OperatingSystem.LINUX
11 | userAgent.contains("Android", ignoreCase = true) -> OperatingSystem.ANDROID
12 | userAgent.contains("iPhone", ignoreCase = true) ||
13 | userAgent.contains("iPad", ignoreCase = true) ||
14 | userAgent.contains("iPod", ignoreCase = true) -> OperatingSystem.IOS
15 |
16 | else -> OperatingSystem.UNKNOWN
17 | }
18 | }
--------------------------------------------------------------------------------
/.github/workflows/publish-on-maven-central.yml:
--------------------------------------------------------------------------------
1 | name: Publish to Maven Central
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | publish:
10 | runs-on: macos-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v3
15 |
16 | - name: Set up JDK
17 | uses: actions/setup-java@v3
18 | with:
19 | java-version: '17'
20 | distribution: 'temurin'
21 |
22 | - name: Set up Publish to Maven Central
23 | run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
24 | env:
25 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVENCENTRALUSERNAME }}
26 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVENCENTRALPASSWORD }}
27 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNINGINMEMORYKEY }}
28 | ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNINGKEYID }}
29 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNINGPASSWORD }}
30 |
31 |
--------------------------------------------------------------------------------
/platformtools/core/src/wasmJsMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | fun getUserAgent(): String =
4 | js("window.navigator.userAgent")
5 |
6 | fun getOs(): String =
7 | js("window.navigator.platform")
8 |
9 | actual fun getOperatingSystem(): OperatingSystem {
10 | val userAgent = getUserAgent()
11 | val platform = getOs()
12 |
13 | return when {
14 | userAgent.contains("Windows", ignoreCase = true) -> OperatingSystem.WINDOWS
15 | userAgent.contains("Macintosh", ignoreCase = true) || platform.contains("Mac", ignoreCase = true) -> OperatingSystem.MACOS
16 | userAgent.contains("Linux", ignoreCase = true) -> OperatingSystem.LINUX
17 | userAgent.contains("Android", ignoreCase = true) -> OperatingSystem.ANDROID
18 | userAgent.contains("iPhone", ignoreCase = true) ||
19 | userAgent.contains("iPad", ignoreCase = true) ||
20 | userAgent.contains("iPod", ignoreCase = true) -> OperatingSystem.IOS
21 |
22 | else -> OperatingSystem.UNKNOWN
23 | }
24 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Elie G.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/platformtools/core/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/OsProvider.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | /**
4 | * Represents the operating systems a device or environment can run.
5 | *
6 | * This enum class is used to identify the underlying platform or operating system
7 | * being utilized by an application, and it is commonly utilized in platform-specific logic.
8 | *
9 | * Enum Constants:
10 | * - `WINDOWS`: Represents the Microsoft Windows operating system.
11 | * - `MACOS`: Represents the macOS operating system from Apple.
12 | * - `LINUX`: Represents Linux-based operating systems.
13 | * - `ANDROID`: Represents the Android operating system.
14 | * - `IOS`: Represents the iOS operating system from Apple.
15 | * - `UNKNOWN`: Represents an unrecognized or unsupported operating system.
16 | */
17 | enum class OperatingSystem {
18 | WINDOWS, MACOS, LINUX, ANDROID, IOS, UNKNOWN
19 | }
20 |
21 | /**
22 | * Determines the operating system on which the application is currently running.
23 | *
24 | * @return An instance of [OperatingSystem] representing the current platform or operating system.
25 | */
26 | expect fun getOperatingSystem(): OperatingSystem
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "Platform-Tools"
2 |
3 | pluginManagement {
4 | repositories {
5 | google {
6 | content {
7 | includeGroupByRegex("com\\.android.*")
8 | includeGroupByRegex("com\\.google.*")
9 | includeGroupByRegex("androidx.*")
10 | includeGroupByRegex("android.*")
11 | }
12 | }
13 | gradlePluginPortal()
14 | mavenCentral()
15 | }
16 | }
17 |
18 | dependencyResolutionManagement {
19 | repositories {
20 | google {
21 | content {
22 | includeGroupByRegex("com\\.android.*")
23 | includeGroupByRegex("com\\.google.*")
24 | includeGroupByRegex("androidx.*")
25 | includeGroupByRegex("android.*")
26 | }
27 | }
28 | mavenCentral()
29 | }
30 | }
31 | include(":platformtools:core")
32 | include(":platformtools:appmanager")
33 | include(":platformtools:releasefetcher")
34 | include(":platformtools:darkmodedetector")
35 | include(":platformtools:rtlwindows")
36 | include(":platformtools:clipboardmanager")
37 | include(":sample:composeApp")
38 | include(":sample:terminalApp")
39 |
40 |
41 |
--------------------------------------------------------------------------------
/platformtools/core/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/LinuxEnvironment.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | /** Most common Linux desktop environments. */
4 | enum class LinuxDesktopEnvironment {
5 | GNOME, KDE, XFCE, CINNAMON, MATE, UNKNOWN
6 | }
7 |
8 | /**
9 | * Detect the Linux Desktop Environment.
10 | *
11 | * - Returns **null** if the OS is not Linux (avoids unnecessary work).
12 | * - Uses common environment variables: `XDG_CURRENT_DESKTOP`, `DESKTOP_SESSION`.
13 | */
14 | fun detectLinuxDesktopEnvironment(): LinuxDesktopEnvironment? {
15 | if (getOperatingSystem() != OperatingSystem.LINUX) return null
16 |
17 | val combinedEnv = buildList {
18 | System.getenv("XDG_CURRENT_DESKTOP")?.let(::add)
19 | System.getenv("DESKTOP_SESSION")?.let(::add)
20 | }.joinToString("|").lowercase()
21 |
22 | return when {
23 | "gnome" in combinedEnv -> LinuxDesktopEnvironment.GNOME
24 | "kde" in combinedEnv || "plasma" in combinedEnv -> LinuxDesktopEnvironment.KDE
25 | "xfce" in combinedEnv -> LinuxDesktopEnvironment.XFCE
26 | "cinnamon" in combinedEnv -> LinuxDesktopEnvironment.CINNAMON
27 | "mate" in combinedEnv -> LinuxDesktopEnvironment.MATE
28 | else -> LinuxDesktopEnvironment.UNKNOWN
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/platformtools/core/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/PlatformProvider.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools
2 |
3 | /**
4 | * Represents the various platforms or environments where an application can run.
5 | *
6 | * This enum class is used to identify the target runtime platform, and it is
7 | * commonly utilized for platform-specific logic or feature implementation.
8 | *
9 | * Enum Constants:
10 | * - `ANDROID`: Represents the Android platform.
11 | * - `JVM`: Represents the Java Virtual Machine environment.
12 | * - `IOS_NATIVE`: Represents the iOS native platform.
13 | * - `JS`: Represents the JavaScript environment.
14 | * - `WASM_JS`: Represents the WebAssembly JavaScript platform.
15 | * - `LINUX_NATIVE`: Represents Linux native platforms.
16 | * - `MAC_OS_NATIVE`: Represents the macOS native platform.
17 | * - `WINDOWS_NATIVE`: Represents Windows native platforms.
18 | */
19 | enum class Platform {
20 | ANDROID, JVM, IOS_NATIVE, JS, WASM_JS, LINUX_NATIVE, MAC_OS_NATIVE, WINDOWS_NATIVE
21 | }
22 |
23 | /**
24 | * Determines the platform on which the application is currently running.
25 | *
26 | * @return An instance of [Platform] representing the specific platform or environment, such as Android, JVM, Native, or JavaScript.
27 | */
28 | expect fun getPlatform(): Platform
--------------------------------------------------------------------------------
/sample/composeApp/src/jvmMain/kotlin/sample/app/main.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.unit.dp
2 | import androidx.compose.ui.window.Window
3 | import androidx.compose.ui.window.application
4 | import androidx.compose.ui.window.rememberWindowState
5 | import io.github.kdroidfilter.platformtools.darkmodedetector.mac.setMacOsAdaptiveTitleBar
6 | import io.github.kdroidfilter.platformtools.darkmodedetector.windows.setWindowsAdaptiveTitleBar
7 | import io.github.kdroidfilter.platformtools.rtlwindows.setWindowsRtlLayout
8 | import sample.app.App
9 | import java.awt.Dimension
10 |
11 | fun main() {
12 | // Set macOS adaptive title bar before application starts
13 | setMacOsAdaptiveTitleBar() // Default is AUTO which uses system setting
14 | // You can also use DARK or LIGHT mode:
15 | // setMacOsAdaptiveTitleBar(MacOSTitleBarMode.DARK)
16 | // setMacOsAdaptiveTitleBar(MacOSTitleBarMode.LIGHT)
17 |
18 | application {
19 | Window(
20 | title = "sample",
21 | state = rememberWindowState(width = 800.dp, height = 600.dp),
22 | onCloseRequest = ::exitApplication,
23 | ) {
24 | window.minimumSize = Dimension(350, 600)
25 | window.setWindowsAdaptiveTitleBar()
26 | window.setWindowsRtlLayout()
27 | App()
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/IsSystemInDarkMode.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.platform.LocalInspectionMode
6 | import io.github.kdroidfilter.platformtools.OperatingSystem
7 | import io.github.kdroidfilter.platformtools.darkmodedetector.linux.isLinuxInDarkMode
8 | import io.github.kdroidfilter.platformtools.darkmodedetector.mac.isMacOsInDarkMode
9 | import io.github.kdroidfilter.platformtools.darkmodedetector.windows.isWindowsInDarkMode
10 | import io.github.kdroidfilter.platformtools.getOperatingSystem
11 |
12 | /**
13 | * Composable function that returns whether the system is in dark mode.
14 | * It handles macOS, Windows, and Linux.
15 | */
16 | @Composable
17 | actual fun isSystemInDarkMode(): Boolean {
18 | val isInPreview = LocalInspectionMode.current
19 | if (isInPreview) {
20 | return isSystemInDarkTheme()
21 | }
22 |
23 | return when (getOperatingSystem()) {
24 | OperatingSystem.MACOS -> isMacOsInDarkMode()
25 | OperatingSystem.WINDOWS -> isWindowsInDarkMode()
26 | OperatingSystem.LINUX -> isLinuxInDarkMode()
27 | else -> false
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/platformtools/rtlwindows/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/rtlwindows/User32.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.rtlwindows
2 |
3 | import com.sun.jna.Native
4 | import com.sun.jna.platform.win32.BaseTSD.LONG_PTR
5 | import com.sun.jna.platform.win32.WinDef
6 | import com.sun.jna.win32.StdCallLibrary
7 | import com.sun.jna.win32.W32APIOptions
8 |
9 | /* ---------- User32 bindings ---------- */
10 | internal interface User32 : StdCallLibrary {
11 | companion object {
12 | val INSTANCE: User32 =
13 | Native.load("user32", User32::class.java, W32APIOptions.DEFAULT_OPTIONS)
14 | }
15 |
16 | fun GetWindowLongPtr(hWnd: WinDef.HWND, nIndex: Int): LONG_PTR
17 | fun SetWindowLongPtr(hWnd: WinDef.HWND, nIndex: Int, dwNewLong: LONG_PTR): LONG_PTR
18 | fun SetWindowPos(
19 | hWnd: WinDef.HWND,
20 | hWndInsertAfter: WinDef.HWND?,
21 | X: Int,
22 | Y: Int,
23 | cx: Int,
24 | cy: Int,
25 | uFlags: Int
26 | ): Boolean
27 | }
28 |
29 | const val GWL_EXSTYLE = -20
30 | const val WS_EX_LAYOUTRTL = 0x0040_0000 // Mirror the entire window
31 | const val WS_EX_RTLREADING = 0x0000_2000 // RTL title-bar text
32 | const val SWP_NOMOVE = 0x0001
33 | const val SWP_NOSIZE = 0x0002
34 | const val SWP_NOZORDER = 0x0004
35 | const val SWP_FRAMECHANGED = 0x0020
--------------------------------------------------------------------------------
/platformtools/rtlwindows/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/rtlwindows/WindowsRtlLayout.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.rtlwindows
2 |
3 | import com.sun.jna.Native
4 | import com.sun.jna.platform.win32.BaseTSD.LONG_PTR
5 | import com.sun.jna.platform.win32.WinDef
6 | import io.github.kdroidfilter.platformtools.OperatingSystem
7 | import io.github.kdroidfilter.platformtools.getOperatingSystem
8 | import java.awt.Window
9 |
10 |
11 | /**
12 | * Apply or remove RTL mirroring on the given AWT/Compose [Window].
13 | *
14 | */
15 | fun Window.setWindowsRtlLayout() {
16 | if (getOperatingSystem() != OperatingSystem.WINDOWS) return
17 |
18 | val isRtl = !this.componentOrientation.isLeftToRight
19 | // Obtain HWND from AWT component
20 | val hwnd = WinDef.HWND(Native.getComponentPointer(this))
21 |
22 | val current = User32.INSTANCE.GetWindowLongPtr(hwnd, GWL_EXSTYLE).toLong()
23 | val newStyle = if (isRtl) {
24 | current or WS_EX_LAYOUTRTL.toLong() or WS_EX_RTLREADING.toLong()
25 | } else {
26 | current and WS_EX_LAYOUTRTL.inv().toLong() and WS_EX_RTLREADING.inv().toLong()
27 | }
28 |
29 | if (newStyle != current) {
30 | User32.INSTANCE.SetWindowLongPtr(hwnd, GWL_EXSTYLE, LONG_PTR(newStyle))
31 | // Tell the window manager to re-evaluate styles
32 | User32.INSTANCE.SetWindowPos(
33 | hwnd, null, 0, 0, 0, 0,
34 | SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_FRAMECHANGED
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/mac/MacOSTitleBar.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.mac
2 |
3 | import io.github.kdroidfilter.platformtools.OperatingSystem
4 | import io.github.kdroidfilter.platformtools.getOperatingSystem
5 |
6 | /**
7 | * Enum class representing the different appearance modes for macOS title bar.
8 | */
9 | enum class MacOSTitleBarMode {
10 | /**
11 | * Uses the system setting for appearance
12 | */
13 | AUTO,
14 |
15 | /**
16 | * Forces dark mode using NSAppearanceNameDarkAqua
17 | */
18 | DARK,
19 |
20 | /**
21 | * Forces light mode using NSAppearanceNameAqua
22 | */
23 | LIGHT
24 | }
25 |
26 | /**
27 | * Sets the macOS adaptive title bar appearance.
28 | *
29 | * This function sets the system property that controls the appearance of the title bar
30 | * in macOS applications. It should be called before the application window is created.
31 | *
32 | * @param mode The appearance mode to use. Default is [MacOSTitleBarMode.AUTO].
33 | */
34 | fun setMacOsAdaptiveTitleBar(mode: MacOSTitleBarMode = MacOSTitleBarMode.AUTO) {
35 | if (getOperatingSystem() != OperatingSystem.MACOS) {
36 | return
37 | }
38 |
39 | val appearanceValue = when (mode) {
40 | MacOSTitleBarMode.DARK -> "NSAppearanceNameDarkAqua"
41 | MacOSTitleBarMode.LIGHT -> "NSAppearanceNameAqua"
42 | MacOSTitleBarMode.AUTO -> "system"
43 | }
44 |
45 | System.setProperty("apple.awt.application.appearance", appearanceValue)
46 | }
47 |
48 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/ReleaseFetcherDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.material3.*
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 | import io.github.kdroidfilter.platformtools.releasefetcher.github.GitHubReleaseFetcher
10 | import io.github.kdroidfilter.platformtools.releasefetcher.github.model.Release
11 | import kotlinx.coroutines.launch
12 |
13 | @OptIn(ExperimentalMaterial3Api::class)
14 | @Composable
15 | fun ReleaseFetcherDemo() {
16 | val scope = rememberCoroutineScope()
17 | val fetcher = remember { GitHubReleaseFetcher("kdroidFilter", "KmpRealTimeLogger") }
18 |
19 | var release by remember { mutableStateOf(null) }
20 |
21 | LazyColumn(
22 | modifier = Modifier
23 | .fillMaxSize()
24 | .padding(16.dp),
25 | verticalArrangement = Arrangement.spacedBy(16.dp)
26 | ) {
27 | item {
28 | Button(onClick = {
29 | scope.launch {
30 | release = fetcher.getLatestRelease()
31 | }
32 | }) {
33 | Text("Fetch Latest Release")
34 | }
35 | }
36 | item {
37 | release?.let {
38 | Text("Latest Version: ${it.tag_name}", style = MaterialTheme.typography.bodyLarge)
39 | Text("Changelog: ${it.body}", style = MaterialTheme.typography.bodyLarge)
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/CoreDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.*
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 | import io.github.kdroidfilter.platformtools.getAppVersion
12 | import io.github.kdroidfilter.platformtools.getCacheDir
13 | import io.github.kdroidfilter.platformtools.getOperatingSystem
14 | import io.github.kdroidfilter.platformtools.getPlatform
15 |
16 | @OptIn(ExperimentalMaterial3Api::class)
17 | @Composable
18 | fun CoreDemo() {
19 | var operatingSystem = getOperatingSystem()
20 | var platform = getPlatform()
21 | var cacheDir = getCacheDir()
22 | var appVersion = getAppVersion()
23 |
24 | Column(
25 | modifier = Modifier
26 | .fillMaxSize()
27 | .padding(16.dp),
28 | verticalArrangement = Arrangement.spacedBy(16.dp)
29 | ) {
30 | Text("Operating System: $operatingSystem", style = MaterialTheme.typography.bodyLarge)
31 | Text("Platform: $platform", style = MaterialTheme.typography.bodyLarge)
32 | Text("Cache Directory: ${cacheDir.absolutePath}", style = MaterialTheme.typography.bodyLarge)
33 | Text("App Version: $appVersion", style = MaterialTheme.typography.bodyLarge)
34 |
35 | // Linux Desktop Environment & Dark Theme (JVM/Linux)
36 | LinuxInfoSection()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/releasefetcher/github/GitHubReleaseFetcher.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.releasefetcher.github
2 |
3 | import io.github.kdroidfilter.platformtools.releasefetcher.config.client
4 | import io.github.kdroidfilter.platformtools.releasefetcher.github.model.Release
5 | import io.ktor.client.call.*
6 | import io.ktor.client.*
7 | import io.ktor.client.request.*
8 | import io.ktor.client.statement.*
9 | import io.ktor.http.*
10 | import kotlinx.serialization.json.Json
11 |
12 | class GitHubReleaseFetcher(
13 | private val owner: String,
14 | private val repo: String,
15 | private val httpClient: HttpClient = client,
16 | ) {
17 |
18 | private val json = Json {
19 | ignoreUnknownKeys = true
20 | coerceInputValues = true
21 | }
22 |
23 | /**
24 | * Fetches the latest release from the GitHub API using Ktor.
25 | */
26 | suspend fun getLatestRelease(): Release? {
27 | return try {
28 | val response: HttpResponse =
29 | httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest")
30 |
31 | if (response.status == HttpStatusCode.OK) {
32 | val responseBody: String = response.body()
33 | json.decodeFromString(responseBody)
34 | } else {
35 | // Handle different response codes as needed
36 | null
37 | }
38 | } catch (e: Exception) {
39 | // Log or handle the error as necessary
40 | e.printStackTrace()
41 | null
42 | }
43 | }
44 |
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 |
12 |
18 |
19 |
24 |
25 |
30 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/sample/composeApp/src/jvmMain/kotlin/sample/app/LinuxInfoSection.jvm.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.*
7 | import io.github.kdroidfilter.platformtools.*
8 | import io.github.kdroidfilter.platformtools.darkmodedetector.linux.isLinuxInDarkMode
9 | import io.github.kdroidfilter.platformtools.darkmodedetector.linux.rememberKdeDarkModeState
10 |
11 | @Composable
12 | internal actual fun LinuxInfoSection() {
13 | // Only relevant on Linux
14 | if (getOperatingSystem() != OperatingSystem.LINUX) return
15 |
16 | val de: LinuxDesktopEnvironment? = detectLinuxDesktopEnvironment()
17 | val dark = isLinuxInDarkMode()
18 |
19 | Column {
20 | Text("Linux Desktop Environment: ${de ?: "Unknown/Not detected"}", style = MaterialTheme.typography.bodyLarge)
21 | Text("Linux Dark Theme: $dark", style = MaterialTheme.typography.bodyLarge)
22 |
23 | if (de == LinuxDesktopEnvironment.KDE) {
24 | val s = rememberKdeDarkModeState()
25 | if (s != null) {
26 | Text("KDE Window dark: ${s.windowTheme}", style = MaterialTheme.typography.bodyLarge)
27 | Text("KDE Panel dark: ${s.panelTheme}", style = MaterialTheme.typography.bodyLarge)
28 | if (s.isMixed) {
29 | Text("KDE theme is mixed (window vs panel)", style = MaterialTheme.typography.bodyLarge)
30 | }
31 | } else {
32 | Text("KDE theme state: Unknown", style = MaterialTheme.typography.bodyLarge)
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/AppManagerDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Button
8 | import androidx.compose.material3.ExperimentalMaterial3Api
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.unit.dp
14 | import io.github.kdroidfilter.platformtools.appmanager.hasAppVersionChanged
15 | import io.github.kdroidfilter.platformtools.appmanager.isFirstInstallation
16 | import io.github.kdroidfilter.platformtools.appmanager.restartApplication
17 |
18 | @OptIn(ExperimentalMaterial3Api::class)
19 | @Composable
20 | fun AppManagerDemo() {
21 | Column(
22 | modifier = Modifier
23 | .fillMaxSize()
24 | .padding(16.dp),
25 | verticalArrangement = Arrangement.spacedBy(16.dp)
26 | ) {
27 | Text(if (isFirstInstallation()) "This is the first Installation !" else "This is not the first Installation !", style = MaterialTheme.typography.bodyLarge)
28 |
29 | Text(if (hasAppVersionChanged()) "The app was updated !" else "The app was not updated !", style = MaterialTheme.typography.bodyLarge)
30 | Button(onClick = { restartApplication() }) {
31 | Text("Restart Application")
32 | }
33 | Text("For detailed usage of this module, refer to https://github.com/kdroidFilter/AppwithAutoUpdater", style = MaterialTheme.typography.bodyLarge)
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/PermissionHandlerDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material3.*
5 | import androidx.compose.runtime.*
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.unit.dp
8 |
9 |
10 | @OptIn(ExperimentalMaterial3Api::class)
11 | @Composable
12 | fun PermissionHandlerDemo(screens: List ) {
13 |
14 | var currentScreenIndex by remember { mutableStateOf(0) }
15 |
16 | Scaffold(
17 | topBar = {
18 | TopAppBar(
19 | title = { Text("Permission Examples") },
20 | )
21 | }
22 | ) { paddingValues ->
23 | Column(
24 | modifier = Modifier
25 | .fillMaxSize()
26 | .padding(paddingValues)
27 | ) {
28 | // Navigation Tabs
29 | ScrollableTabRow(
30 | selectedTabIndex = currentScreenIndex,
31 | modifier = Modifier.fillMaxWidth()
32 | ) {
33 | screens.forEachIndexed { index, screen ->
34 | Tab(
35 | selected = currentScreenIndex == index,
36 | onClick = { currentScreenIndex = index },
37 | text = { Text(screen.title) }
38 | )
39 | }
40 | }
41 |
42 | // Content
43 | Box(
44 | modifier = Modifier
45 | .fillMaxSize()
46 | .padding(16.dp)
47 | ) {
48 | screens[currentScreenIndex].content()
49 | }
50 | }
51 | }
52 | }
53 |
54 | data class PermissionScreen(
55 | val title: String,
56 | val content: @Composable () -> Unit
57 | )
58 |
59 |
--------------------------------------------------------------------------------
/.github/workflows/publish-dokka-doc.yml:
--------------------------------------------------------------------------------
1 | name: "Automatic deployment of Dokka doc"
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | # Prevents multiple simultaneous deployments
15 | concurrency:
16 | group: "pages"
17 | cancel-in-progress: true
18 |
19 | jobs:
20 | # 1) Build the Dokka documentation
21 | build:
22 | runs-on: ubuntu-latest
23 |
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v4
27 |
28 | - name: Set up Java (Temurin 17)
29 | uses: actions/setup-java@v4
30 | with:
31 | distribution: temurin
32 | java-version: 17
33 |
34 | # Generate Dokka documentation
35 | - name: Generate Dokka documentation
36 | run: |
37 | ./gradlew dokkaHtmlMultiModule
38 |
39 | # Prepare files for deployment
40 | - name: Prepare files for deployment
41 | run: |
42 | mkdir -p build/final
43 | # Copy the documentation to build/final (site root)
44 | cp -r platformtools/build/dokka/htmlMultiModule/* build/final
45 |
46 | # Upload to the "pages" artifact to make it available for the next job
47 | - name: Upload artifact for GitHub Pages
48 | uses: actions/upload-pages-artifact@v3
49 | with:
50 | path: build/final
51 |
52 | # 2) Deploy to GitHub Pages
53 | deploy:
54 | needs: build
55 | runs-on: ubuntu-latest
56 | environment:
57 | name: github-pages
58 | # The final URL will be output in page_url
59 | url: ${{ steps.deployment.outputs.page_url }}
60 | steps:
61 | - name: Deploy to GitHub Pages
62 | id: deployment
63 | uses: actions/deploy-pages@v4
64 | with:
65 | path: build/final
66 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/restartappmanager/RestartManagerActivity.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is inspired by the ProcessPhoenix project by Jake Wharton.
3 | * Source: https://github.com/JakeWharton/ProcessPhoenix/blob/trunk/process-phoenix/src/main/java/com/jakewharton/processphoenix/PhoenixActivity.java
4 | */
5 |
6 | package io.github.kdroidfilter.platformtools.appmanager.restartappmanager
7 |
8 | import android.app.Activity
9 | import android.content.Intent
10 | import android.os.Build
11 | import android.os.Bundle
12 | import android.os.Process
13 | import android.os.StrictMode
14 |
15 | class RestartManagerActivity : Activity() {
16 |
17 | override fun onCreate(savedInstanceState: Bundle?) {
18 | super.onCreate(savedInstanceState)
19 |
20 | // Kill original main process
21 | val mainProcessPid = intent.getIntExtra(ProcessRestarter.KEY_MAIN_PROCESS_PID, -1)
22 | if (mainProcessPid != -1) {
23 | Process.killProcess(mainProcessPid)
24 | }
25 |
26 | val intents = intent.getParcelableArrayListExtra(ProcessRestarter.KEY_RESTART_INTENTS)?.toTypedArray()
27 | if (intents != null) {
28 | if (Build.VERSION.SDK_INT > 31) {
29 | // Disable strict mode complaining about out-of-process intents. Normally you save and restore
30 | // the original policy, but this process will die almost immediately after the offending call.
31 | StrictMode.setVmPolicy(
32 | StrictMode.VmPolicy.Builder(StrictMode.getVmPolicy())
33 | .permitUnsafeIntentLaunch()
34 | .build()
35 | )
36 | }
37 |
38 | startActivities(intents)
39 | }
40 |
41 | finish()
42 | Runtime.getRuntime().exit(0) // Kill kill kill!
43 | }
44 | }
45 |
46 |
47 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppInstaller.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import java.io.File
4 |
5 | interface AppInstaller {
6 |
7 | /**
8 | * Installs an application from the specified file.
9 | *
10 | * This method handles the installation of an APK file. It requires permission to install
11 | * applications from unknown sources, which should be granted before invoking this method.
12 | * The installation result is communicated via the provided callback.
13 | *
14 | * @param appFile The APK file to be installed.
15 | * @param onResult A callback invoked with the installation result. The callback
16 | * parameters are:
17 | * - [success]: Indicates whether the installation succeeded.
18 | * - [message]: An optional message providing additional context, typically in case of an error.
19 | */
20 | suspend fun installApp(appFile: File, onResult: (success: Boolean, message: String?) -> Unit)
21 |
22 | /**
23 | * Uninstalls an application based on the provided package name.
24 | *
25 | * @param packageName The package name of the application to be uninstalled.
26 | * @param onResult A callback invoked with the uninstallation result. The callback
27 | * parameters are:
28 | * - [success]: Indicates whether the uninstallation succeeded.
29 | * - [message]: An optional message providing additional context, typically in case of an error.
30 | */
31 | suspend fun uninstallApp(packageName: String, onResult: (success: Boolean, message: String?) -> Unit)
32 |
33 | /**
34 | * Uninstalls the actual application from the device.
35 | *
36 | * @param onResult A callback invoked with the uninstallation result.
37 | * The callback parameters are:
38 | * - [success]: Indicates whether the uninstallation succeeded.
39 | * - [message]: An optional message providing additional context, typically in case of an error.
40 | */
41 | suspend fun uninstallApp(onResult: (success: Boolean, message: String?) -> Unit)
42 | }
--------------------------------------------------------------------------------
/platformtools/appmanager/src/commonMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppVersionChecker.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import io.github.kdroidfilter.platformtools.getAppVersion
4 | import io.github.kdroidfilter.platformtools.getCacheDir
5 | import java.io.File
6 |
7 | private const val VERSION_FILE_NAME = "app_version.txt"
8 |
9 | /**
10 | * Checks if the app version has changed since the last execution.
11 | * @return `true` if the version has changed (updated), `false` otherwise.
12 | */
13 | fun hasAppVersionChanged(): Boolean {
14 | val currentVersion = getAppVersion()
15 | val versionFile = File(getCacheDir(), VERSION_FILE_NAME)
16 |
17 | val oldVersion: String? = if (versionFile.exists()) {
18 | versionFile.readText().trim()
19 | } else null
20 |
21 | // 1) If this is the first installation, we store the current version and return false
22 | if (oldVersion.isNullOrEmpty()) {
23 | versionFile.writeText(currentVersion)
24 | return false
25 | }
26 |
27 | // 2) Otherwise, we compare
28 | val hasChanged = oldVersion != currentVersion
29 |
30 | // 3) If it has changed, we update the file
31 | if (hasChanged) {
32 | versionFile.writeText(currentVersion)
33 | }
34 |
35 | return hasChanged
36 | }
37 |
38 |
39 | /**
40 | * Determines if the application is being installed for the first time.
41 | *
42 | * The method checks the presence of a dedicated version file in the cache directory.
43 | * If the file does not exist, it saves the current application version in the file
44 | * and returns `true`, indicating that this is the first installation. Otherwise, it
45 | * returns `false` to indicate that the application has been installed previously.
46 | *
47 | * @return `true` if this is the first installation of the application, `false` otherwise.
48 | */
49 | fun isFirstInstallation(): Boolean {
50 | val versionFile = File(getCacheDir(), VERSION_FILE_NAME)
51 |
52 | // If the file does not exist, this is the first install
53 | if (!versionFile.exists()) {
54 | // Write the current version (or any content)
55 | versionFile.writeText(getAppVersion())
56 | return true
57 | }
58 |
59 | // Otherwise, not the first installation
60 | return false
61 | }
--------------------------------------------------------------------------------
/sample/composeApp/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
19 |
22 |
25 |
26 |
27 |
28 |
29 |
30 |
34 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/restartappmanager/RestartManagerService.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is inspired by the ProcessPhoenix project by Jake Wharton.
3 | * Source: https://github.com/JakeWharton/ProcessPhoenix/blob/trunk/process-phoenix/src/main/java/com/jakewharton/processphoenix/PhoenixService.java
4 | */
5 |
6 | package io.github.kdroidfilter.platformtools.appmanager.restartappmanager
7 |
8 | import android.content.Context
9 | import android.content.Intent
10 | import android.os.Build
11 | import android.os.Process
12 | import android.os.StrictMode
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.Job
16 | import kotlinx.coroutines.withContext
17 | import kotlin.coroutines.CoroutineContext
18 |
19 | class RestartManagerService : CoroutineScope {
20 |
21 | private val job = Job()
22 | override val coroutineContext: CoroutineContext
23 | get() = Dispatchers.IO + job
24 |
25 | suspend fun handleIntent(context: Context, intent: Intent?) {
26 | if (intent == null) {
27 | return
28 | }
29 |
30 | withContext(Dispatchers.IO) {
31 | val mainProcessPid = intent.getIntExtra(ProcessRestarter.KEY_MAIN_PROCESS_PID, -1)
32 | if (mainProcessPid != -1) {
33 | Process.killProcess(mainProcessPid) // Kill original main process
34 | }
35 |
36 | val nextIntent: Intent? = if (Build.VERSION.SDK_INT >= 33) {
37 | intent.getParcelableExtra(ProcessRestarter.KEY_RESTART_INTENT, Intent::class.java)
38 | } else {
39 | @Suppress("DEPRECATION")
40 | intent.getParcelableExtra(ProcessRestarter.KEY_RESTART_INTENT)
41 | }
42 |
43 | if (nextIntent != null) {
44 | if (Build.VERSION.SDK_INT > 31) {
45 | // Disable strict mode for out-of-process intents
46 | StrictMode.setVmPolicy(
47 | StrictMode.VmPolicy.Builder(StrictMode.getVmPolicy())
48 | .permitUnsafeIntentLaunch()
49 | .build()
50 | )
51 | }
52 |
53 | if (Build.VERSION.SDK_INT >= 26) {
54 | context.startForegroundService(nextIntent)
55 | } else {
56 | context.startService(nextIntent)
57 | }
58 | }
59 |
60 | Runtime.getRuntime().exit(0) // Terminate process
61 | }
62 | }
63 |
64 | fun stop() {
65 | job.cancel()
66 | }
67 | }
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/sample/composeApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
2 |
3 | plugins {
4 | alias(libs.plugins.multiplatform)
5 | alias(libs.plugins.compose.compiler)
6 | alias(libs.plugins.compose)
7 | alias(libs.plugins.android.application)
8 | }
9 |
10 | kotlin {
11 | jvmToolchain(17)
12 |
13 | androidTarget()
14 | jvm()
15 |
16 |
17 | sourceSets {
18 | commonMain.dependencies {
19 | implementation(compose.runtime)
20 | implementation(compose.foundation)
21 | implementation(compose.material3)
22 | implementation(compose.ui)
23 | implementation(compose.materialIconsExtended)
24 | implementation(compose.components.resources)
25 | implementation(compose.components.uiToolingPreview)
26 | implementation(libs.navigation.compose)
27 | implementation(libs.ktor.client.cio)
28 | implementation(project(":platformtools:core"))
29 | implementation(project(":platformtools:appmanager"))
30 | implementation(project(":platformtools:releasefetcher"))
31 | implementation(project(":platformtools:darkmodedetector"))
32 | implementation(project(":platformtools:rtlwindows"))
33 | implementation(project(":platformtools:clipboardmanager"))
34 | }
35 |
36 | androidMain.dependencies {
37 | implementation(libs.androidx.activityCompose)
38 | }
39 |
40 | jvmMain.dependencies {
41 | implementation(compose.desktop.currentOs)
42 | }
43 |
44 | }
45 | }
46 |
47 | android {
48 | namespace = "sample.app"
49 | compileSdk = 35
50 |
51 | defaultConfig {
52 | minSdk = 24
53 | targetSdk = 35
54 |
55 | applicationId = "sample.app.androidApp"
56 | versionCode = 1
57 | versionName = "1.0.1"
58 | }
59 |
60 | packaging {
61 | resources {
62 | excludes += "/META-INF/AL2.0"
63 | excludes += "/META-INF/LGPL2.1"
64 | }
65 | }
66 |
67 | }
68 |
69 | compose.desktop {
70 | application {
71 | mainClass = "MainKt"
72 |
73 | nativeDistributions {
74 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
75 | packageName = "sample"
76 | packageVersion = "1.0.1"
77 | macOS {
78 | jvmArgs(
79 | "-Dapple.awt.application.appearance=system"
80 | )
81 | }
82 | jvmArgs(
83 | "-Dorg.slf4j.simpleLogger.defaultLogLevel=debug"
84 | )
85 | }
86 |
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines
2 |
3 | Use this guide to align contributions with PlatformTools’ Kotlin Multiplatform setup before opening a pull request.
4 |
5 | ## Project Structure & Module Organization
6 | - `platformtools/core`, `appmanager`, `releasefetcher`, `darkmodedetector`, and `rtlwindows` follow the KMP layout with `src/commonMain`, platform-specific `src/Main`, and optional `src/commonTest`. Keep shared logic in `commonMain` and add `expect/actual` pairs only when necessary.
7 | - `sample/composeApp` hosts the Compose multiplatform demo (Android + Desktop), while `sample/terminalApp` builds native CLIs that showcase the core APIs.
8 | - `kotlin-js-store/wasm` stores generated JS/WASM artifacts required by the sample apps; do not hand-edit its contents.
9 | - Treat `build/` directories as disposable Gradle outputs.
10 |
11 | ## Build, Test, and Development Commands
12 | - `./gradlew build` compiles all modules and runs the default verification suite.
13 | - `./gradlew check` executes platform-specific tests (e.g., `:platformtools:releasefetcher:allTests`) and Dokka validation.
14 | - `./gradlew :sample:composeApp:run` launches the desktop demo; use `installDebug` for Android when a device/emulator is present.
15 | - `./gradlew dokkaHtmlMultiModule` regenerates API docs for Maven publishing previews.
16 | - `./gradlew publishToMavenLocal` publishes every library module with the version inferred from `GITHUB_REF`.
17 |
18 | ## Coding Style & Naming Conventions
19 | - Kotlin 2.1 defaults: 4-space indentation, trailing commas where idiomatic, and explicit visibility (`internal` within modules).
20 | - Namespaces mirror directory structure (`io.github.kdroidfilter.platformtools.`); files with multiple expect/actual declarations should group related APIs.
21 | - Use UpperCamelCase for classes/object singletons, lowerCamelCase for functions and properties, and `snake_case` only for native interop identifiers.
22 |
23 | ## Testing Guidelines
24 | - Place new tests under the matching `src/commonTest/kotlin` package; add target-specific suites (e.g., `jvmTest`) when behaviour diverges.
25 | - Use `kotlin.test` assertions and descriptive method names such as `fun isSystemInDarkMode_returnsTrue_onWindowsInDarkTheme()`.
26 | - When adding public APIs, cover at least the happy path and one failure path per supported platform. Update demos to exercise new primitives if UI confirmation is required.
27 |
28 | ## Commit & Pull Request Guidelines
29 | - Follow the existing history: short, imperative commit subjects (`Add Windows title bar helper`, `Fix release fetcher cache`). Reference affected module(s) when helpful.
30 | - Each PR should include: a concise summary, linked issue (if any), testing notes (`./gradlew check` output or emulator/device evidence), and screenshots when UI changes affect the samples.
31 | - Keep PRs atomic—version bumps, feature work, and doc updates should land separately to simplify Maven publishing.
32 |
--------------------------------------------------------------------------------
/platformtools/rtlwindows/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.dokka.gradle.DokkaTask
2 |
3 | plugins {
4 | alias(libs.plugins.multiplatform)
5 | alias(libs.plugins.compose)
6 | alias(libs.plugins.compose.compiler)
7 | alias(libs.plugins.vannitktech.maven.publish)
8 | }
9 | val libVersion : String by rootProject.extra
10 |
11 | group = "io.github.kdroidfilter.platformtools.rtlwindows"
12 | version = libVersion
13 |
14 | kotlin {
15 | jvmToolchain(17)
16 |
17 | jvm()
18 |
19 |
20 | sourceSets {
21 | commonMain.dependencies {
22 | implementation(project(":platformtools:core"))
23 | implementation(libs.kotlinx.coroutines.core)
24 | implementation(libs.kermit)
25 | implementation(libs.jna.jpms)
26 | implementation(libs.jna.platform.jpms)
27 | }
28 |
29 | commonTest.dependencies {
30 | implementation(kotlin("test"))
31 | }
32 |
33 | jvmMain.dependencies {
34 | implementation(compose.foundation)
35 | }
36 |
37 |
38 |
39 | }
40 |
41 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
42 | targets.withType {
43 | compilations["main"].compileTaskProvider.configure {
44 | compilerOptions {
45 | freeCompilerArgs.add("-Xexport-kdoc")
46 | }
47 | }
48 | }
49 |
50 | }
51 |
52 |
53 | mavenPublishing {
54 | coordinates(
55 | groupId = "io.github.kdroidfilter",
56 | artifactId = "platformtools.rtlwindows",
57 | version = version.toString()
58 | )
59 |
60 | pom {
61 | name.set("PlatformTools Rtl Windows Fix")
62 | description.set("Fix in Windows OS bug that the Title bar not display correctly in rtl mode")
63 | inceptionYear.set("2025")
64 | url.set("https://github.com/kdroidFilter/")
65 |
66 | licenses {
67 | license {
68 | name.set("MIT License")
69 | url.set("https://opensource.org/licenses/MIT")
70 | }
71 | }
72 |
73 | developers {
74 | developer {
75 | id.set("kdroidfilter")
76 | name.set("Elie Gambache")
77 | email.set("elyahou.hadass@gmail.com")
78 | }
79 | }
80 |
81 | scm {
82 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
83 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
84 | url.set("https://github.com/kdroidFilter/platformtools")
85 | }
86 | }
87 |
88 | publishToMavenCentral()
89 |
90 | signAllPublications()
91 | }
92 |
93 | tasks.withType().configureEach {
94 | moduleName.set("Platforms Tools")
95 | offlineMode.set(true)
96 | }
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.dokka.gradle.DokkaTask
2 |
3 | plugins {
4 | alias(libs.plugins.multiplatform)
5 | alias(libs.plugins.android.library)
6 | alias(libs.plugins.vannitktech.maven.publish)
7 | }
8 |
9 | val libVersion: String by rootProject.extra
10 |
11 | group = "io.github.kdroidfilter.platformtools.clipboardmanager"
12 | version = libVersion
13 |
14 | kotlin {
15 | jvmToolchain(17)
16 |
17 | androidTarget { publishLibraryVariants("release") }
18 | jvm()
19 | iosX64()
20 | iosArm64()
21 | iosSimulatorArm64()
22 |
23 | sourceSets {
24 | commonMain.dependencies {
25 | implementation(project(":platformtools:core"))
26 | implementation(libs.kotlinx.coroutines.core)
27 | implementation(libs.kermit)
28 | }
29 | commonTest.dependencies {
30 | implementation(kotlin("test"))
31 | }
32 | jvmMain.dependencies {
33 | implementation(libs.jna)
34 | implementation(libs.jna.platform)
35 | }
36 | }
37 |
38 |
39 | targets.withType {
40 | compilations["main"].compileTaskProvider.configure {
41 | compilerOptions {
42 | freeCompilerArgs.add("-Xexport-kdoc")
43 | }
44 | }
45 | }
46 | }
47 |
48 | android {
49 | namespace = "io.github.kdroidfilter.platformtools.clipboardmanager"
50 | compileSdk = 35
51 |
52 | defaultConfig {
53 | minSdk = 21
54 | }
55 | }
56 |
57 | mavenPublishing {
58 | coordinates(
59 | groupId = "io.github.kdroidfilter",
60 | artifactId = "platformtools.clipboardmanager",
61 | version = version.toString()
62 | )
63 |
64 | pom {
65 | name.set("PlatformTools ClipboardManager")
66 | description.set("Clipboard manager module for PlatformTools. JVM, Android, and iOS targets.")
67 | inceptionYear.set("2025")
68 | url.set("https://github.com/kdroidFilter/")
69 |
70 | licenses {
71 | license {
72 | name.set("MIT License")
73 | url.set("https://opensource.org/licenses/MIT")
74 | }
75 | }
76 |
77 | developers {
78 | developer {
79 | id.set("kdroidfilter")
80 | name.set("Elie Gambache")
81 | email.set("elyahou.hadass@gmail.com")
82 | }
83 | }
84 |
85 | scm {
86 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
87 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
88 | url.set("https://github.com/kdroidFilter/platformtools")
89 | }
90 | }
91 |
92 | publishToMavenCentral()
93 |
94 | signAllPublications()
95 | }
96 |
97 | tasks.withType().configureEach {
98 | moduleName.set("Platforms Tools")
99 | offlineMode.set(true)
100 | }
101 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo. 1>&2
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48 | echo. 1>&2
49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50 | echo location of your Java installation. 1>&2
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo. 1>&2
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62 | echo. 1>&2
63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64 | echo location of your Java installation. 1>&2
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 |
3 | androidcontextprovider = "1.0.1"
4 | jfa = "1.2.0"
5 | jna = "5.18.1"
6 | kermit = "2.0.6"
7 | kotlin = "2.2.20"
8 | agp = "8.9.3"
9 | compose = "1.9.0"
10 | androidx-activityCompose = "1.10.1"
11 | kotlinxBrowserWasmJs = "0.5.0"
12 | kotlinxCoroutinesCore = "1.10.2"
13 | core = "1.16.0"
14 | activityKtx = "1.10.1"
15 | ktor = "3.3.1"
16 | navigationCompose = "2.9.0"
17 | slf4jSimple = "2.0.17"
18 |
19 |
20 | [libraries]
21 |
22 | androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
23 | androidx-core = { group = "androidx.core", name = "core", version.ref = "core" }
24 | androidcontextprovider = { module = "io.github.kdroidfilter:androidcontextprovider", version.ref = "androidcontextprovider" }
25 | androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
26 | jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
27 | jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
28 | jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
29 | jna-jpms = { module = "net.java.dev.jna:jna-jpms", version.ref = "jna" }
30 | jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna" }
31 | kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
32 | kotlinx-browser-wasm-js = { module = "org.jetbrains.kotlinx:kotlinx-browser-wasm-js", version.ref = "kotlinxBrowserWasmJs" }
33 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
34 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" }
35 | ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
36 | ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor"}
37 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
38 | ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
39 | ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }
40 | ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
41 | navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
42 | semver = { module = "io.github.z4kn4fein:semver", version = "3.0.0" }
43 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" }
44 |
45 | [plugins]
46 |
47 | multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
48 | android-library = { id = "com.android.library", version.ref = "agp" }
49 | compose = { id = "org.jetbrains.compose", version.ref = "compose" }
50 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
51 | android-application = { id = "com.android.application", version.ref = "agp" }
52 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
53 | vannitktech-maven-publish = {id = "com.vanniktech.maven.publish", version = "0.34.0"}
54 | dokka = { id = "org.jetbrains.dokka" , version = "2.0.0"}
55 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/build.gradle.kts:
--------------------------------------------------------------------------------
1 |
2 | plugins {
3 | alias(libs.plugins.multiplatform)
4 | alias(libs.plugins.android.library)
5 | alias(libs.plugins.compose.compiler)
6 | alias(libs.plugins.compose)
7 | alias(libs.plugins.vannitktech.maven.publish)
8 | }
9 |
10 | val libVersion : String by rootProject.extra
11 |
12 | group = "io.github.kdroidfilter.platformtools.darkmodedetector"
13 | version = libVersion
14 |
15 |
16 | kotlin {
17 | jvmToolchain(17)
18 |
19 | androidTarget()
20 | jvm()
21 | js { browser() }
22 | wasmJs { browser() }
23 | iosX64()
24 | iosArm64()
25 | iosSimulatorArm64()
26 | macosX64()
27 | macosArm64()
28 |
29 | sourceSets {
30 | commonMain.dependencies {
31 | implementation(project(":platformtools:core"))
32 | implementation(compose.runtime)
33 | implementation(compose.foundation)
34 | }
35 |
36 | androidMain.dependencies {
37 | }
38 |
39 | jvmMain.dependencies {
40 | implementation(libs.jna.jpms)
41 | implementation(libs.jna.platform.jpms)
42 | implementation("de.jangassen:jfa:1.2.0") {
43 | exclude(group = "net.java.dev.jna", module = "jna")
44 | exclude(group = "net.java.dev.jna", module = "jna-platform")
45 | exclude(group = "net.java.dev.jna", module = "jna-jpms")
46 | exclude(group = "net.java.dev.jna", module = "jna-platform-jpms")
47 | }
48 | implementation(libs.kermit)
49 | }
50 |
51 |
52 |
53 | }
54 | }
55 |
56 | android {
57 | namespace = "io.github.kdroidfilter.platformtools.darkmodedetector"
58 | compileSdk = 35
59 |
60 | defaultConfig {
61 | minSdk = 21
62 | }
63 | }
64 |
65 | mavenPublishing {
66 | coordinates(
67 | groupId = "io.github.kdroidfilter",
68 | artifactId = "platformtools.darkmodedetector",
69 | version = version.toString()
70 | )
71 |
72 | pom {
73 | name.set("PlatformTools Dark Mode")
74 | description.set("Dark Mode Detection module for PlatformTools, a Kotlin Multiplatform library for managing platform-specific utilities and tools.")
75 | inceptionYear.set("2025")
76 | url.set("https://github.com/kdroidFilter/")
77 |
78 | licenses {
79 | license {
80 | name.set("MIT License")
81 | url.set("https://opensource.org/licenses/MIT")
82 | }
83 | }
84 |
85 | developers {
86 | developer {
87 | id.set("kdroidfilter")
88 | name.set("Elie Gambache")
89 | email.set("elyahou.hadass@gmail.com")
90 | }
91 | }
92 |
93 | scm {
94 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
95 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
96 | url.set("https://github.com/kdroidFilter/platformtools")
97 | }
98 | }
99 |
100 | publishToMavenCentral()
101 |
102 | signAllPublications()
103 | }
104 |
--------------------------------------------------------------------------------
/platformtools/appmanager/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.dokka.gradle.DokkaTask
2 |
3 | plugins {
4 | alias(libs.plugins.multiplatform)
5 | alias(libs.plugins.android.library)
6 | alias(libs.plugins.vannitktech.maven.publish)
7 | }
8 | val libVersion : String by rootProject.extra
9 |
10 | group = "io.github.kdroidfilter.platformtools.appmanager"
11 | version = libVersion
12 |
13 | kotlin {
14 | jvmToolchain(17)
15 |
16 | androidTarget { publishLibraryVariants("release") }
17 | jvm()
18 |
19 |
20 | sourceSets {
21 | commonMain.dependencies {
22 | implementation(project(":platformtools:core"))
23 | implementation(libs.kotlinx.coroutines.core)
24 | implementation(libs.kermit)
25 | }
26 |
27 | commonTest.dependencies {
28 | implementation(kotlin("test"))
29 | }
30 |
31 | jvmMain.dependencies {
32 | implementation(libs.jna.jpms)
33 | implementation(libs.jna.platform.jpms)
34 | }
35 |
36 | androidMain.dependencies {
37 | implementation(libs.androidx.core)
38 | implementation(libs.androidx.activity.ktx)
39 | implementation(libs.androidcontextprovider)
40 | }
41 |
42 |
43 | }
44 |
45 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
46 | targets.withType {
47 | compilations["main"].compileTaskProvider.configure {
48 | compilerOptions {
49 | freeCompilerArgs.add("-Xexport-kdoc")
50 | }
51 | }
52 | }
53 |
54 | }
55 |
56 | android {
57 | namespace = "io.github.kdroidfilter.platformtools.appmanager"
58 | compileSdk = 35
59 |
60 | defaultConfig {
61 | minSdk = 21
62 | }
63 | }
64 |
65 | mavenPublishing {
66 | coordinates(
67 | groupId = "io.github.kdroidfilter",
68 | artifactId = "platformtools.appmanager",
69 | version = version.toString()
70 | )
71 |
72 | pom {
73 | name.set("PlatformTools AppManager")
74 | description.set("Application manager module for PlatformTools, a Kotlin Library to install and update Desktop and Android Applications")
75 | inceptionYear.set("2025")
76 | url.set("https://github.com/kdroidFilter/")
77 |
78 | licenses {
79 | license {
80 | name.set("MIT License")
81 | url.set("https://opensource.org/licenses/MIT")
82 | }
83 | }
84 |
85 | developers {
86 | developer {
87 | id.set("kdroidfilter")
88 | name.set("Elie Gambache")
89 | email.set("elyahou.hadass@gmail.com")
90 | }
91 | }
92 |
93 | scm {
94 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
95 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
96 | url.set("https://github.com/kdroidFilter/platformtools")
97 | }
98 | }
99 |
100 | publishToMavenCentral()
101 |
102 | signAllPublications()
103 | }
104 |
105 | tasks.withType().configureEach {
106 | moduleName.set("Platforms Tools")
107 | offlineMode.set(true)
108 | }
109 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/ClipboardDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material3.Card
10 | import androidx.compose.material3.CardDefaults
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.DisposableEffect
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.mutableStateOf
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.runtime.setValue
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.unit.dp
21 | import io.github.kdroidfilter.platformtools.clipboardmanager.ClipboardContent
22 | import io.github.kdroidfilter.platformtools.clipboardmanager.ClipboardListener
23 | import io.github.kdroidfilter.platformtools.clipboardmanager.ClipboardMonitor
24 | import io.github.kdroidfilter.platformtools.clipboardmanager.ClipboardMonitorFactory
25 |
26 | @Composable
27 | fun ClipboardDemo() {
28 | var clipboardContent by remember { mutableStateOf(null) }
29 |
30 | DisposableEffect(Unit) {
31 | val listener = object : ClipboardListener {
32 | override fun onClipboardChange(content: ClipboardContent) {
33 | clipboardContent = content
34 | }
35 | }
36 | val monitor: ClipboardMonitor = ClipboardMonitorFactory.create(listener)
37 | monitor.start()
38 | runCatching { clipboardContent = monitor.getCurrentContent() }.onFailure { /* ignore */ }
39 | onDispose {
40 | monitor.stop()
41 | }
42 | }
43 |
44 | Column(modifier = Modifier.padding(16.dp)) {
45 | Text("Presse-papiers", style = MaterialTheme.typography.headlineSmall)
46 | Spacer(Modifier.height(12.dp))
47 |
48 | Card(colors = CardDefaults.cardColors()) {
49 | Column(Modifier.padding(16.dp).fillMaxWidth()) {
50 | if (clipboardContent == null) {
51 | Text("En attente des changements du presse-papiers…")
52 | } else {
53 | ClipboardContentView(clipboardContent!!)
54 | }
55 | }
56 | }
57 | }
58 | }
59 |
60 | @Composable
61 | private fun ClipboardContentView(content: ClipboardContent) {
62 | Column(Modifier.fillMaxWidth()) {
63 | InfoRow("Texte", content.text ?: "")
64 | Spacer(Modifier.height(8.dp))
65 | InfoRow("HTML", content.html ?: "")
66 | Spacer(Modifier.height(8.dp))
67 | InfoRow("RTF", content.rtf ?: "")
68 | Spacer(Modifier.height(8.dp))
69 | InfoRow("Fichiers", content.files?.joinToString() ?: "")
70 | Spacer(Modifier.height(8.dp))
71 | InfoRow("Image disponible", if (content.imageAvailable) "Oui" else "Non")
72 | Spacer(Modifier.height(8.dp))
73 | InfoRow("Horodatage", content.timestamp.toString())
74 | }
75 | }
76 |
77 | @Composable
78 | private fun InfoRow(label: String, value: String) {
79 | Row(Modifier.fillMaxWidth()) {
80 | Text("$label: ", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
81 | Text(value, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f))
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/platformtools/core/build.gradle.kts:
--------------------------------------------------------------------------------
1 |
2 | plugins {
3 | alias(libs.plugins.multiplatform)
4 | alias(libs.plugins.android.library)
5 | alias(libs.plugins.vannitktech.maven.publish)
6 | }
7 |
8 | val libVersion : String by rootProject.extra
9 |
10 | group = "io.github.kdroidfilter.platformtools.core"
11 | version = libVersion
12 |
13 | kotlin {
14 | jvmToolchain(17)
15 |
16 | androidTarget { publishLibraryVariants("release") }
17 | jvm()
18 | js { browser() }
19 | wasmJs { browser() }
20 | iosX64()
21 | iosArm64()
22 | iosSimulatorArm64()
23 | macosX64()
24 | macosArm64()
25 | linuxX64()
26 | mingwX64()
27 |
28 | sourceSets {
29 | commonMain.dependencies {
30 | }
31 |
32 | commonTest.dependencies {
33 | implementation(kotlin("test"))
34 | }
35 |
36 | val androidJvmMain by creating {
37 | dependsOn(commonMain.get())
38 | }
39 |
40 | jvmMain {
41 | dependsOn(androidJvmMain)
42 | }
43 |
44 | androidMain {
45 | dependsOn(androidJvmMain)
46 | dependencies {
47 | implementation(libs.androidcontextprovider)
48 | }
49 | }
50 |
51 | val ios = listOf(
52 | iosX64(),
53 | iosArm64(),
54 | iosSimulatorArm64()
55 | )
56 |
57 | val macos = listOf(
58 | macosX64(),
59 | macosArm64()
60 | )
61 |
62 | val iosMain by creating {
63 | dependsOn(commonMain.get())
64 | }
65 |
66 | val macosMain by creating {
67 | dependsOn(commonMain.get())
68 | }
69 |
70 | ios.forEach {
71 | it.compilations["main"].defaultSourceSet {
72 | dependsOn(iosMain)
73 | }
74 | }
75 |
76 | macos.forEach {
77 | it.compilations["main"].defaultSourceSet {
78 | dependsOn(macosMain)
79 | }
80 | }
81 |
82 |
83 |
84 | }
85 |
86 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
87 | targets.withType {
88 | compilations["main"].compileTaskProvider.configure {
89 | compilerOptions {
90 | freeCompilerArgs.add("-Xexport-kdoc")
91 | }
92 | }
93 | }
94 |
95 | }
96 |
97 | android {
98 | namespace = "io.github.kdroidfilter.platformtools.core"
99 | compileSdk = 35
100 |
101 | defaultConfig {
102 | minSdk = 21
103 | }
104 | }
105 |
106 | mavenPublishing {
107 | coordinates(
108 | groupId = "io.github.kdroidfilter",
109 | artifactId = "platformtools.core",
110 | version = version.toString()
111 | )
112 |
113 | pom {
114 | name.set("PlatformTools Core")
115 | description.set(" Core of PlatformTools, a Kotlin Multiplatform library to manage platform-specific utilities and tools.")
116 | inceptionYear.set("2025") // Change si la création du projet est plus ancienne.
117 | url.set("https://github.com/kdroidFilter/")
118 |
119 | licenses {
120 | license {
121 | name.set("MIT License")
122 | url.set("https://opensource.org/licenses/MIT")
123 | }
124 | }
125 |
126 | developers {
127 | developer {
128 | id.set("kdroidfilter")
129 | name.set("Elie Gambache")
130 | email.set("elyahou.hadass@gmail.com")
131 | }
132 | }
133 |
134 | scm {
135 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
136 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
137 | url.set("https://github.com/kdroidFilter/platformtools")
138 | }
139 | }
140 |
141 | publishToMavenCentral()
142 |
143 | signAllPublications()
144 | }
145 |
146 |
--------------------------------------------------------------------------------
/platformtools/releasefetcher/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.dokka.gradle.DokkaTask
2 |
3 | plugins {
4 | alias(libs.plugins.multiplatform)
5 | alias(libs.plugins.android.library)
6 | alias(libs.plugins.vannitktech.maven.publish)
7 | alias(libs.plugins.kotlinx.serialization)
8 | }
9 |
10 | val libVersion : String by rootProject.extra
11 |
12 | group = "io.github.kdroidfilter.platformtools.releasefetcher"
13 | version = libVersion
14 |
15 | kotlin {
16 | jvmToolchain(17)
17 |
18 | androidTarget { publishLibraryVariants("release") }
19 | jvm()
20 | wasmJs { browser() }
21 |
22 | sourceSets {
23 | commonMain.dependencies {
24 | implementation(project(":platformtools:core"))
25 | implementation(libs.kotlinx.serialization.json)
26 | implementation(libs.kotlinx.coroutines.core)
27 | compileOnly(libs.ktor.client.core)
28 | compileOnly(libs.ktor.client.content.negotiation)
29 | compileOnly(libs.ktor.client.serialization)
30 | compileOnly(libs.ktor.client.logging)
31 | compileOnly(libs.ktor.client.cio)
32 | api(libs.semver)
33 | implementation(libs.kermit)
34 |
35 | }
36 |
37 | commonTest.dependencies {
38 | implementation(kotlin("test"))
39 | }
40 |
41 | val androidJvmMain by creating {
42 | dependsOn(commonMain.get())
43 | }
44 |
45 | jvmMain {
46 | dependsOn(androidJvmMain)
47 | dependencies {
48 | implementation(libs.slf4j.simple)
49 | }
50 | }
51 |
52 | androidMain {
53 | dependsOn(androidJvmMain)
54 | dependencies {
55 | implementation(libs.androidcontextprovider)
56 | }
57 | }
58 |
59 | wasmJsMain.dependencies {
60 | compileOnly(libs.ktor.client.js)
61 | api(libs.ktor.client.js)
62 | api(libs.ktor.client.core)
63 | api(libs.ktor.client.content.negotiation)
64 | api(libs.ktor.client.serialization)
65 | api(libs.ktor.client.logging)
66 | api(libs.ktor.client.cio)
67 | }
68 |
69 |
70 | }
71 |
72 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers
73 | targets.withType {
74 | compilations["main"].compileTaskProvider.configure {
75 | compilerOptions {
76 | freeCompilerArgs.add("-Xexport-kdoc")
77 | }
78 | }
79 | }
80 |
81 | }
82 |
83 | android {
84 | namespace = "io.github.kdroidfilter.platformtools.releasefetcher"
85 | compileSdk = 35
86 |
87 | defaultConfig {
88 | minSdk = 21
89 | }
90 | }
91 |
92 | mavenPublishing {
93 | coordinates(
94 | groupId = "io.github.kdroidfilter",
95 | artifactId = "platformtools.releasefetcher",
96 | version = version.toString()
97 | )
98 |
99 | pom {
100 | name.set("PlatformTools ReleaseFetcher")
101 | description.set("A module for Platform Tools library to manage and fetch releases from many sources (Only Github for now).")
102 | inceptionYear.set("2025")
103 | url.set("https://github.com/kdroidFilter/")
104 |
105 | licenses {
106 | license {
107 | name.set("MIT License")
108 | url.set("https://opensource.org/licenses/MIT")
109 | }
110 | }
111 |
112 | developers {
113 | developer {
114 | id.set("kdroidfilter")
115 | name.set("Elie Gambache")
116 | email.set("elyahou.hadass@gmail.com")
117 | }
118 | }
119 |
120 | scm {
121 | connection.set("scm:git:git://github.com/kdroidFilter/platformtools.git")
122 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/platformtools.git")
123 | url.set("https://github.com/kdroidFilter/platformtools")
124 | }
125 | }
126 |
127 | publishToMavenCentral()
128 |
129 | signAllPublications()
130 | }
131 |
132 | tasks.withType().configureEach {
133 | moduleName.set("Platforms Tools")
134 | offlineMode.set(true)
135 | }
136 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 |
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.Menu
9 | import androidx.compose.material3.*
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.rememberCoroutineScope
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.unit.dp
14 | import androidx.compose.ui.unit.sp
15 | import androidx.navigation.NavHostController
16 | import androidx.navigation.compose.NavHost
17 | import androidx.navigation.compose.composable
18 | import androidx.navigation.compose.rememberNavController
19 | import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode
20 | import kotlinx.coroutines.CoroutineScope
21 | import kotlinx.coroutines.launch
22 |
23 | data class Route(
24 | val title: String,
25 | val destination: String,
26 | val content : @Composable () -> Unit
27 | )
28 |
29 |
30 | @OptIn(ExperimentalMaterial3Api::class)
31 | @Composable
32 | fun App() {
33 | val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
34 | val navController = rememberNavController()
35 | val scope = rememberCoroutineScope()
36 |
37 | val routes = listOf(
38 | Route("Core", "core", { CoreDemo() }),
39 | Route("App Manager", "appmanager", { AppManagerDemo() }),
40 | Route("Release Fetcher", "releasefetcher", { ReleaseFetcherDemo() }),
41 | Route("GitHub Repo Fetcher", "githubrepo", { GitHubRepoFetcherDemo() }),
42 | Route("Clipboard", "clipboard", { ClipboardDemo() }),
43 | )
44 |
45 | MaterialTheme(
46 | colorScheme = if (isSystemInDarkMode()) darkColorScheme() else lightColorScheme(),
47 | typography = Typography(),
48 | content = {
49 | ModalNavigationDrawer(
50 | drawerContent = {
51 | DrawerContent(navController, scope, drawerState, routes)
52 | },
53 | drawerState = drawerState,
54 | ) {
55 | Scaffold(
56 | topBar = {
57 | TopAppBar(
58 | title = { Text("Platform Tools Demo") },
59 | navigationIcon = {
60 | IconButton(onClick = {
61 | scope.launch {
62 | drawerState.open()
63 | }
64 | }) {
65 | Icon(Icons.Default.Menu, contentDescription = "Open Drawer")
66 | }
67 | }
68 | )
69 | }
70 | ) { innerPadding ->
71 | Box(modifier = Modifier.padding(innerPadding)) {
72 | NavHost(navController = navController, startDestination = routes.first().destination) {
73 | routes.forEach { route ->
74 | composable(route.destination) {
75 | route.content()
76 | }
77 | }
78 | }
79 | }
80 | }
81 | }
82 | }
83 | )
84 | }
85 |
86 | @Composable
87 | private fun DrawerContent(
88 | navController: NavHostController,
89 | scope: CoroutineScope,
90 | drawerState: DrawerState,
91 | routes: List
92 | ) {
93 | ModalDrawerSheet {
94 | Column(modifier = Modifier.padding(16.dp)) {
95 | Text(text = "Modules", fontSize = 24.sp, modifier = Modifier.padding(bottom = 16.dp))
96 | HorizontalDivider()
97 |
98 | routes.forEach { route ->
99 | NavigationDrawerItem(
100 | label = { Text(route.title, fontSize = 18.sp) },
101 | selected = false,
102 | onClick = {
103 | scope.launch {
104 | drawerState.close()
105 | }
106 | navController.navigate(route.destination) {
107 | launchSingleTop = true
108 | }
109 | },
110 | modifier = Modifier.padding(vertical = 8.dp)
111 | )
112 | }
113 | }
114 | }
115 | }
116 |
117 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/restartappmanager/ProcessRestarter.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is inspired by the ProcessPhoenix project by Jake Wharton.
3 | * Source: https://github.com/JakeWharton/ProcessPhoenix/blob/trunk/process-phoenix/src/main/java/com/jakewharton/processphoenix/ProcessPhoenix.java
4 | */
5 |
6 | package io.github.kdroidfilter.platformtools.appmanager.restartappmanager
7 |
8 | import android.app.Activity
9 | import android.app.ActivityManager
10 | import android.app.Service
11 | import android.content.Context
12 | import android.content.Intent
13 | import android.content.pm.PackageManager
14 | import android.os.Process
15 | import io.github.kdroidfilter.platformtools.appmanager.restartappmanager.ProcessRestarter.triggerRebirth
16 |
17 | /**
18 | * ProcessRestarter facilitates restarting your application process. This should only be used for
19 | * things like fundamental state changes in your debug builds (e.g., changing from staging to
20 | * production).
21 | *
22 | * Trigger process recreation by calling [triggerRebirth] with a [Context] instance.
23 | */
24 | object ProcessRestarter {
25 |
26 | internal const val KEY_RESTART_INTENT = "process_restart_intent"
27 | internal const val KEY_RESTART_INTENTS = "process_restart_intents"
28 | internal const val KEY_MAIN_PROCESS_PID = "process_main_process_pid"
29 |
30 | /**
31 | * Call to restart the application process using the default activity as an intent.
32 | *
33 | * Behavior of the current process after invoking this method is undefined.
34 | */
35 | fun triggerRebirth(context: Context) {
36 | triggerRebirth(context, getRestartIntent(context))
37 | }
38 |
39 | /**
40 | * Call to restart the application process using the provided Activity Class.
41 | *
42 | * Behavior of the current process after invoking this method is undefined.
43 | */
44 | fun triggerRebirth(context: Context, targetClass: Class) {
45 | val nextIntent = Intent(context, targetClass)
46 | triggerRebirth(context, nextIntent)
47 | }
48 |
49 | /**
50 | * Call to restart the application process using the specified intents.
51 | *
52 | * Behavior of the current process after invoking this method is undefined.
53 | */
54 | fun triggerRebirth(context: Context, vararg nextIntents: Intent) {
55 | require(nextIntents.isNotEmpty()) { "Intents cannot be empty" }
56 |
57 | nextIntents[0].addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
58 |
59 | val intent = Intent(context, RestartManagerActivity::class.java).apply {
60 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
61 | putParcelableArrayListExtra(KEY_RESTART_INTENTS, ArrayList(nextIntents.toList()))
62 | putExtra(KEY_MAIN_PROCESS_PID, Process.myPid())
63 | }
64 |
65 | context.startActivity(intent)
66 | }
67 |
68 | /**
69 | * Call to restart the application process using the provided Service Class.
70 | *
71 | * Behavior of the current process after invoking this method is undefined.
72 | */
73 | fun triggerServiceRebirth(context: Context, targetClass: Class) {
74 | val nextIntent = Intent(context, targetClass)
75 | triggerServiceRebirth(context, nextIntent)
76 | }
77 |
78 | /**
79 | * Call to restart the application process using the specified Service intent.
80 | *
81 | * Behavior of the current process after invoking this method is undefined.
82 | */
83 | fun triggerServiceRebirth(context: Context, nextIntent: Intent) {
84 | val intent = Intent(context, RestartManagerService::class.java).apply {
85 | putExtra(KEY_RESTART_INTENT, nextIntent)
86 | putExtra(KEY_MAIN_PROCESS_PID, Process.myPid())
87 | }
88 |
89 | context.startService(intent)
90 | }
91 |
92 | private fun getRestartIntent(context: Context): Intent {
93 | val packageName = context.packageName
94 | val packageManager = context.packageManager
95 |
96 | val defaultIntent = if (packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
97 | ) {
98 | packageManager.getLeanbackLaunchIntentForPackage(packageName)
99 | } else {
100 | packageManager.getLaunchIntentForPackage(packageName)
101 | }
102 |
103 | return defaultIntent ?: throw IllegalStateException(
104 | "Unable to determine default activity for $packageName. " +
105 | "Does an activity specify the DEFAULT category in its intent filter?"
106 | )
107 | }
108 |
109 | /**
110 | * Checks if the current process is a temporary Process.
111 | * This can be used to avoid initialization of unused resources or to prevent running code that
112 | * is not multi-process ready.
113 | *
114 | * @return true if the current process is a temporary Process
115 | */
116 | fun isTemporaryProcess(context: Context): Boolean {
117 | val currentPid = Process.myPid()
118 | val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
119 | val runningProcesses = manager.runningAppProcesses
120 |
121 | return runningProcesses?.any { processInfo ->
122 | processInfo.pid == currentPid && processInfo.processName.endsWith(":phoenix")
123 | } ?: false
124 | }
125 | }
126 |
127 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/mac/MacOSThemeDetector.kt:
--------------------------------------------------------------------------------
1 | // Inspired by the code from the jSystemThemeDetector project:
2 | // https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/src/main/java/com/jthemedetecor/MacOSThemeDetector.java
3 |
4 | package io.github.kdroidfilter.platformtools.darkmodedetector.mac
5 |
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.DisposableEffect
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import co.touchlab.kermit.Logger
11 | import co.touchlab.kermit.Logger.Companion.setMinSeverity
12 | import co.touchlab.kermit.Severity
13 | import de.jangassen.jfa.foundation.Foundation
14 | import de.jangassen.jfa.foundation.ID
15 | import java.util.concurrent.ConcurrentHashMap
16 | import java.util.concurrent.Executors
17 | import java.util.function.Consumer
18 | import java.util.regex.Pattern
19 |
20 | // Initialize logger using kotlin-logging
21 | private val macLogger = Logger.withTag("MacOSThemeDetector").apply { setMinSeverity(Severity.Warn) }
22 |
23 | /**
24 | * MacOSThemeDetector registers an observer with NSDistributedNotificationCenter
25 | * to detect theme changes in macOS. It reads the system preference "AppleInterfaceStyle"
26 | * (which is "Dark" when in dark mode) from NSUserDefaults.
27 | */
28 | internal object MacOSThemeDetector {
29 |
30 | // Set of listeners to notify when the theme changes (true = dark, false = light)
31 | private val listeners: MutableSet> = ConcurrentHashMap.newKeySet()
32 |
33 | // Pattern to match if the style string contains "dark" (case insensitive)
34 | private val darkPattern: Pattern = Pattern.compile(".*dark.*", Pattern.CASE_INSENSITIVE)
35 |
36 | // Executor to run callbacks in a dedicated thread
37 | private val callbackExecutor = Executors.newSingleThreadExecutor { r ->
38 | Thread(r, "MacOS Theme Detector Thread").apply { isDaemon = true }
39 | }
40 |
41 | /**
42 | * Callback invoked by the Objective-C runtime when the system posts
43 | * the "AppleInterfaceThemeChangedNotification" notification.
44 | * The expected Objective-C signature is "v@" (void return, no parameters).
45 | */
46 | @JvmStatic
47 | private val themeChangedCallback = object : com.sun.jna.Callback {
48 | fun callback() {
49 | callbackExecutor.execute {
50 | val isDark = isDark()
51 | macLogger.d { "Theme change detected. Dark mode: $isDark" }
52 | notifyListeners(isDark)
53 | }
54 | }
55 | }
56 |
57 | // Initialize the observer on startup.
58 | init {
59 | initObserver()
60 | }
61 |
62 | /**
63 | * Initializes the Objective-C observer.
64 | * This method creates a custom Objective-C class ("NSColorChangesObserver")
65 | * that extends NSObject, adds a method "handleAppleThemeChanged:" that calls our callback,
66 | * and registers the observer with NSDistributedNotificationCenter.
67 | */
68 | private fun initObserver() {
69 | macLogger.d { "Initializing macOS theme observer" }
70 | val pool = Foundation.NSAutoreleasePool()
71 | try {
72 | val delegateClass: ID = Foundation.allocateObjcClassPair(
73 | Foundation.getObjcClass("NSObject"),
74 | "NSColorChangesObserver"
75 | )
76 | if (!ID.NIL.equals(delegateClass)) {
77 | val selector = Foundation.createSelector("handleAppleThemeChanged:")
78 | val added = Foundation.addMethod(delegateClass, selector, themeChangedCallback, "v@")
79 | if (!added) {
80 | macLogger.e { "Failed to add observer method to NSColorChangesObserver" }
81 | }
82 | Foundation.registerObjcClassPair(delegateClass)
83 | }
84 | val delegateObj = Foundation.invoke("NSColorChangesObserver", "new")
85 | Foundation.invoke(
86 | Foundation.invoke("NSDistributedNotificationCenter", "defaultCenter"),
87 | "addObserver:selector:name:object:",
88 | delegateObj,
89 | Foundation.createSelector("handleAppleThemeChanged:"),
90 | Foundation.nsString("AppleInterfaceThemeChangedNotification"),
91 | ID.NIL
92 | )
93 | macLogger.d { "Observer successfully registered" }
94 | } finally {
95 | pool.drain()
96 | }
97 | }
98 |
99 | /**
100 | * Reads the system theme by checking the "AppleInterfaceStyle" preference.
101 | * Returns true if the system is in dark mode, false otherwise.
102 | */
103 | fun isDark(): Boolean {
104 | val pool = Foundation.NSAutoreleasePool()
105 | return try {
106 | val userDefaults = Foundation.invoke("NSUserDefaults", "standardUserDefaults")
107 | val styleKey = Foundation.nsString("AppleInterfaceStyle")
108 | val result = Foundation.invoke(userDefaults, "objectForKey:", styleKey)
109 | val styleString = Foundation.toStringViaUTF8(result)
110 | darkPattern.matcher(styleString ?: "").matches()
111 | } catch (e: Exception) {
112 | macLogger.e(e) { "Error reading system theme" }
113 | false
114 | } finally {
115 | pool.drain()
116 | }
117 | }
118 |
119 | fun registerListener(listener: Consumer) {
120 | listeners.add(listener)
121 | }
122 |
123 | fun removeListener(listener: Consumer) {
124 | listeners.remove(listener)
125 | }
126 |
127 | private fun notifyListeners(isDark: Boolean) {
128 | listeners.forEach { it.accept(isDark) }
129 | }
130 | }
131 |
132 |
133 | /**
134 | * A helper composable function that returns the current macOS dark mode state,
135 | * updating automatically when the system theme changes.
136 | */
137 | @Composable
138 | internal fun isMacOsInDarkMode(): Boolean {
139 | val darkModeState = remember { mutableStateOf(MacOSThemeDetector.isDark()) }
140 | DisposableEffect(Unit) {
141 | macLogger.d { "Registering macOS dark mode listener in Compose" }
142 | val listener = Consumer { newValue ->
143 | macLogger.d { "Compose macOS dark mode updated: $newValue" }
144 | darkModeState.value = newValue
145 | }
146 | MacOSThemeDetector.registerListener(listener)
147 | onDispose {
148 | macLogger.d { "Removing macOS dark mode listener in Compose" }
149 | MacOSThemeDetector.removeListener(listener)
150 | }
151 | }
152 | return darkModeState.value
153 | }
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/AwtOSClipboardMonitor.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | import java.awt.Toolkit
4 | import java.awt.datatransfer.DataFlavor
5 | import java.awt.datatransfer.Transferable
6 | import java.io.File
7 | import java.security.MessageDigest
8 | import java.util.concurrent.CountDownLatch
9 | import java.util.concurrent.Executors
10 | import java.util.concurrent.ScheduledExecutorService
11 | import java.util.concurrent.TimeUnit
12 | import java.util.concurrent.atomic.AtomicBoolean
13 |
14 |
15 | internal class AwtOSClipboardMonitor(
16 | private val listener: ClipboardListener,
17 | private val intervalMillis: Long = 200L
18 | ) : ClipboardMonitor {
19 |
20 | private val running = AtomicBoolean(false)
21 | private val started = CountDownLatch(1)
22 | private var scheduler: ScheduledExecutorService? = null
23 | private var lastSignature: String? = null
24 | private var shutdownHook: Thread? = null
25 |
26 | override fun start() {
27 | if (running.get()) return
28 | running.set(true)
29 |
30 | scheduler = Executors.newSingleThreadScheduledExecutor { r ->
31 | Thread(r, "awt-ClipboardMonitor").apply { isDaemon = true }
32 | }.also { exec ->
33 | // Fire a first read quickly, then repeat.
34 | exec.scheduleAtFixedRate(::tickSafe, 0L, intervalMillis.coerceAtLeast(50L), TimeUnit.MILLISECONDS)
35 | }
36 |
37 | // Register a shutdown hook to ensure background executor is stopped
38 | shutdownHook = Thread { runCatching { stop() } }.also {
39 | runCatching { Runtime.getRuntime().addShutdownHook(it) }
40 | }
41 |
42 | started.countDown()
43 | started.await()
44 | }
45 |
46 | override fun stop() {
47 | if (!running.get()) return
48 | running.set(false)
49 | // Try to remove shutdown hook if we're not already in shutdown
50 | shutdownHook?.let { hook ->
51 | runCatching { Runtime.getRuntime().removeShutdownHook(hook) }
52 | }
53 | shutdownHook = null
54 | scheduler?.shutdownNow()
55 | scheduler = null
56 | lastSignature = null
57 | }
58 |
59 | override fun isRunning(): Boolean = running.get()
60 |
61 | override fun getCurrentContent(): ClipboardContent = readClipboard()
62 |
63 | // === Internals ===
64 |
65 | private fun tickSafe() {
66 | if (!running.get()) return
67 | try {
68 | val content = readClipboard()
69 | val sig = signatureOf(content)
70 | if (sig != lastSignature) {
71 | lastSignature = sig
72 | try {
73 | listener.onClipboardChange(content)
74 | } catch (_: Throwable) {
75 | // Listener exceptions must not break the monitor.
76 | }
77 | }
78 | } catch (_: Throwable) {
79 | // Swallow all to keep the loop resilient.
80 | }
81 | }
82 |
83 | private fun readClipboard(): ClipboardContent {
84 | val clipboard = Toolkit.getDefaultToolkit().systemClipboard
85 | val contents: Transferable = clipboard.getContents(null)
86 | ?: return ClipboardContent(timestamp = System.currentTimeMillis())
87 |
88 | var text: String? = null
89 | var html: String? = null
90 | var rtf: String? = null
91 | var files: List? = null
92 | val imageAvailable: Boolean = contents.isDataFlavorSupported(DataFlavor.imageFlavor)
93 |
94 | // Plain text
95 | if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
96 | runCatching {
97 | text = contents.getTransferData(DataFlavor.stringFlavor) as? String
98 | }
99 | }
100 |
101 | // HTML (as String)
102 | runCatching {
103 | val htmlFlavor = DataFlavor("text/html;class=java.lang.String")
104 | if (contents.isDataFlavorSupported(htmlFlavor)) {
105 | html = contents.getTransferData(htmlFlavor) as? String
106 | }
107 | }
108 |
109 | // RTF (as String) – many apps provide RTF in this flavor on macOS
110 | runCatching {
111 | val rtfFlavor = DataFlavor("text/rtf;class=java.lang.String")
112 | if (contents.isDataFlavorSupported(rtfFlavor)) {
113 | rtf = contents.getTransferData(rtfFlavor) as? String
114 | }
115 | }
116 |
117 | // File list
118 | if (contents.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
119 | runCatching {
120 | @Suppress("UNCHECKED_CAST")
121 | val list = contents.getTransferData(DataFlavor.javaFileListFlavor) as List
122 | files = list.map { it.absolutePath }
123 | }
124 | }
125 |
126 | return ClipboardContent(
127 | text = text,
128 | html = html,
129 | rtf = rtf,
130 | files = files,
131 | imageAvailable = imageAvailable,
132 | timestamp = System.currentTimeMillis()
133 | )
134 | }
135 |
136 | /**
137 | * Build a robust signature to avoid emitting duplicate consecutive events.
138 | * We hash the shapes/lengths instead of full payloads to stay cheap.
139 | */
140 | private fun signatureOf(c: ClipboardContent): String {
141 | val sb = StringBuilder(128)
142 | fun add(name: String, v: String?) {
143 | if (v != null) {
144 | sb.append(name).append('#').append(v.length).append(';')
145 | // Include a small prefix to disambiguate same-length strings
146 | sb.append(v.take(64)).append('|')
147 | } else sb.append(name).append('#').append("0;|")
148 | }
149 | add("t", c.text)
150 | add("h", c.html)
151 | add("r", c.rtf)
152 | sb.append("i#").append(if (c.imageAvailable) 1 else 0).append('|')
153 | if (c.files != null) {
154 | sb.append("f#").append(c.files!!.size).append('|')
155 | c.files!!.take(8).forEach { sb.append(it).append('|') }
156 | } else sb.append("f#0|")
157 |
158 | // Cheap stable digest
159 | return sha1(sb.toString())
160 | }
161 |
162 | private fun sha1(s: String): String {
163 | val md = MessageDigest.getInstance("SHA-1")
164 | val bytes = md.digest(s.toByteArray(Charsets.UTF_8))
165 | val hex = CharArray(bytes.size * 2)
166 | val hexChars = "0123456789abcdef".toCharArray()
167 | var i = 0
168 | for (b in bytes) {
169 | val v = b.toInt() and 0xFF
170 | hex[i++] = hexChars[v ushr 4]
171 | hex[i++] = hexChars[v and 0x0F]
172 | }
173 | return String(hex)
174 | }
175 | }
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/GnomeThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.DisposableEffect
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.remember
7 | import co.touchlab.kermit.Logger
8 | import co.touchlab.kermit.Logger.Companion.setMinSeverity
9 | import co.touchlab.kermit.Severity
10 | import java.io.BufferedReader
11 | import java.io.InputStreamReader
12 | import java.util.concurrent.ConcurrentHashMap
13 | import java.util.function.Consumer
14 |
15 | // Logger for GNOME
16 | private val gnomeLogger = Logger.withTag("GnomeThemeDetector").apply { setMinSeverity(Severity.Warn) }
17 |
18 | /**
19 | * GNOME specific theme detector using gsettings and monitoring.
20 | */
21 | internal object GnomeThemeDetector {
22 | private const val MONITORING_CMD = "gsettings monitor org.gnome.desktop.interface"
23 | private val GET_CMD = arrayOf(
24 | "gsettings get org.gnome.desktop.interface gtk-theme",
25 | "gsettings get org.gnome.desktop.interface color-scheme"
26 | )
27 |
28 | val darkThemeRegex = ".*dark.*".toRegex(RegexOption.IGNORE_CASE)
29 |
30 | @Volatile
31 | private var detectorThread: Thread? = null
32 | private val listeners: MutableSet> = ConcurrentHashMap.newKeySet()
33 |
34 | fun isDark(): Boolean {
35 | return try {
36 | val runtime = Runtime.getRuntime()
37 | for (cmd in GET_CMD) {
38 | val process = runtime.exec(cmd)
39 | BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
40 | val line = reader.readLine()
41 | gnomeLogger.d { "Command '$cmd' output: $line" }
42 | if (line != null && isDarkTheme(line)) {
43 | return true
44 | }
45 | }
46 | }
47 | false
48 | } catch (e: Exception) {
49 | gnomeLogger.e(e) { "Couldn't detect GNOME theme" }
50 | false
51 | }
52 | }
53 |
54 | private fun startMonitoring() {
55 | if (detectorThread?.isAlive == true) return
56 | detectorThread = object : Thread("GTK Theme Detector Thread") {
57 | private var lastValue: Boolean = isDark()
58 | override fun run() {
59 | gnomeLogger.d { "Starting GTK theme monitoring thread" }
60 | val runtime = Runtime.getRuntime()
61 | val process = try {
62 | runtime.exec(MONITORING_CMD)
63 | } catch (e: Exception) {
64 | gnomeLogger.e(e) { "Couldn't start monitoring process" }
65 | return
66 | }
67 |
68 | BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
69 | while (!isInterrupted) {
70 | val line = reader.readLine() ?: break
71 | if (!line.contains("gtk-theme", ignoreCase = true) &&
72 | !line.contains("color-scheme", ignoreCase = true)
73 | ) continue
74 |
75 | gnomeLogger.d { "Monitoring output: $line" }
76 | val currentIsDark = isDarkThemeFromLine(line) ?: isDark()
77 | if (currentIsDark != lastValue) {
78 | lastValue = currentIsDark
79 | gnomeLogger.d { "Detected theme change => dark: $currentIsDark" }
80 | for (listener in listeners) {
81 | try { listener.accept(currentIsDark) } catch (ex: RuntimeException) {
82 | gnomeLogger.e(ex) { "Exception while notifying listener" }
83 | }
84 | }
85 | }
86 | }
87 | gnomeLogger.d { "GTK theme monitoring thread ending" }
88 | if (process.isAlive) {
89 | process.destroy()
90 | gnomeLogger.d { "Monitoring process destroyed" }
91 | }
92 | }
93 | }
94 | }.apply { isDaemon = true; start() }
95 | }
96 |
97 | private fun isDarkThemeFromLine(line: String): Boolean? {
98 | val tokens = line.split("\\s+".toRegex())
99 | if (tokens.size < 2) return null
100 | val value = tokens[1].lowercase().replace("'", "")
101 | return if (value.isNotBlank()) isDarkTheme(value) else null
102 | }
103 |
104 | private fun isDarkTheme(text: String): Boolean = darkThemeRegex.matches(text)
105 |
106 | fun registerListener(listener: Consumer) {
107 | val wasEmpty = listeners.isEmpty()
108 | listeners.add(listener)
109 | if (wasEmpty) {
110 | startMonitoring()
111 | }
112 | }
113 |
114 | fun removeListener(listener: Consumer) {
115 | listeners.remove(listener)
116 | if (listeners.isEmpty()) {
117 | detectorThread?.interrupt()
118 | detectorThread = null
119 | }
120 | }
121 | }
122 |
123 | internal fun detectGnomeDarkTheme(): Boolean? {
124 | return try {
125 | val p1 = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"))
126 | val theme = BufferedReader(InputStreamReader(p1.inputStream)).use { it.readLine()?.trim('\'', '"') }
127 | if (!theme.isNullOrBlank() && theme.contains("dark", ignoreCase = true)) return true
128 | val p2 = Runtime.getRuntime().exec(arrayOf("gsettings", "get", "org.gnome.desktop.interface", "color-scheme"))
129 | val scheme = BufferedReader(InputStreamReader(p2.inputStream)).use { it.readLine()?.trim('\'', '"') }
130 | when (scheme?.lowercase()) {
131 | "prefer-dark" -> true
132 | "default", "prefer-light" -> false
133 | else -> null
134 | }
135 | } catch (_: Exception) {
136 | null
137 | }
138 | }
139 |
140 | @Composable
141 | internal fun isGnomeInDarkMode(): Boolean {
142 | val darkModeState = remember { mutableStateOf(GnomeThemeDetector.isDark()) }
143 |
144 | DisposableEffect(Unit) {
145 | gnomeLogger.d { "Registering GNOME dark mode listener in Compose" }
146 | val listener = Consumer { newValue ->
147 | gnomeLogger.d { "GNOME dark mode updated: $newValue" }
148 | darkModeState.value = newValue
149 | }
150 | GnomeThemeDetector.registerListener(listener)
151 | onDispose {
152 | gnomeLogger.d { "Removing GNOME dark mode listener in Compose" }
153 | GnomeThemeDetector.removeListener(listener)
154 | }
155 | }
156 | return darkModeState.value
157 | }
158 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/WindowsPrivilegeHelper.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import com.sun.jna.platform.win32.*
4 | import com.sun.jna.ptr.IntByReference
5 | import java.io.File
6 | import java.io.InputStreamReader
7 |
8 | /**
9 | * Object containing configuration settings specific to Windows platform.
10 | *
11 | * @property requireAdmin Indicates whether administrator privileges are required
12 | * for certain operations on the Windows platform. Defaults to `true`.
13 | */
14 | object WindowsInstallerConfig {
15 | var requireAdmin: Boolean = true
16 | }
17 |
18 |
19 | /**
20 | * Utility object that offers functions to handle Windows-specific privilege and process management tasks.
21 | */
22 | object WindowsPrivilegeHelper {
23 |
24 | /**
25 | * Checks if the process is already running with elevated privileges (admin).
26 | */
27 | fun isProcessElevated(): Boolean {
28 | val hToken = WinNT.HANDLEByReference()
29 | try {
30 | // Open the token of the current process
31 | val success = Advapi32.INSTANCE.OpenProcessToken(
32 | Kernel32.INSTANCE.GetCurrentProcess(),
33 | WinNT.TOKEN_QUERY,
34 | hToken
35 | )
36 | if (!success) return false
37 |
38 | val elevation = WinNT.TOKEN_ELEVATION()
39 | val size = IntByReference()
40 | val result = Advapi32.INSTANCE.GetTokenInformation(
41 | hToken.value,
42 | WinNT.TOKEN_INFORMATION_CLASS.TokenElevation,
43 | elevation,
44 | elevation.size(),
45 | size
46 | )
47 | if (!result) return false
48 |
49 | // If TokenIsElevated != 0, then the process is in admin mode.
50 | return elevation.TokenIsElevated != 0
51 | } finally {
52 | Kernel32.INSTANCE.CloseHandle(hToken.value)
53 | }
54 | }
55 |
56 | /**
57 | * Requests elevation (UAC) to install an MSI via msiexec.
58 | * Waits for the msiexec process to finish and returns the result via the callback.
59 | */
60 | private fun requestAdminPrivilegesForMsi(msiPath: String, onResult: (Boolean, String?) -> Unit) {
61 | val shellExecuteInfo = ShellAPI.SHELLEXECUTEINFO().apply {
62 | cbSize = size()
63 | lpVerb = "runas" // Requests elevation (UAC)
64 | lpFile = "msiexec" // Executable in the PATH
65 | lpParameters = "/i \"$msiPath\" /quiet /l*v \"${File(msiPath).parentFile?.absolutePath}\\installation_log.txt\""
66 | nShow = WinUser.SW_SHOWNORMAL
67 | fMask = Shell32.SEE_MASK_NOCLOSEPROCESS
68 | }
69 |
70 | val success = Shell32.INSTANCE.ShellExecuteEx(shellExecuteInfo)
71 | if (!success) {
72 | val errorCode = Kernel32.INSTANCE.GetLastError()
73 | onResult(false, "ShellExecuteEx failed for the MSI. Error code: $errorCode")
74 | return
75 | }
76 |
77 | val hProcess = shellExecuteInfo.hProcess
78 | if (hProcess == null) {
79 | onResult(false, "Null process handle after ShellExecuteEx.")
80 | return
81 | }
82 |
83 | try {
84 | // Wait for the msiexec process to finish
85 | val waitResult = Kernel32.INSTANCE.WaitForSingleObject(hProcess, WinBase.INFINITE)
86 | if (waitResult != WinBase.WAIT_OBJECT_0) {
87 | onResult(false, "Failed to wait for the msiexec process.")
88 | return
89 | }
90 |
91 | // Get the exit code of msiexec
92 | val exitCode = IntByReference()
93 | val getExitSuccess = Kernel32.INSTANCE.GetExitCodeProcess(hProcess, exitCode)
94 | if (!getExitSuccess) {
95 | val errorCode = Kernel32.INSTANCE.GetLastError()
96 | onResult(false, "GetExitCodeProcess failed. Error code: $errorCode")
97 | return
98 | }
99 |
100 | if (exitCode.value == 0) {
101 | onResult(true, null)
102 | } else {
103 | onResult(false, "MSI installation failed with exit code: ${exitCode.value}")
104 | }
105 |
106 | } finally {
107 | Kernel32.INSTANCE.CloseHandle(hProcess)
108 | }
109 | }
110 |
111 | /**
112 | * Installs an MSI file on Windows.
113 | * @param installerFile The MSI file to install.
114 | * @param onResult Callback (success: Boolean, errorMessage: String?).
115 | * @param requireAdmin Indicates whether installation must be done in admin mode.
116 | */
117 | fun installOnWindows(
118 | installerFile: File,
119 | onResult: (Boolean, String?) -> Unit
120 | ) {
121 | val requireAdmin = WindowsInstallerConfig.requireAdmin
122 | // 1. Check if admin privileges are explicitly required
123 | if (requireAdmin && !isProcessElevated()) {
124 | // If admin rights are required and we are not in admin mode,
125 | // request elevation and wait for the result.
126 | requestAdminPrivilegesForMsi(installerFile.absolutePath, onResult)
127 | return
128 | }
129 |
130 | // 2. Validate the file
131 | if (!installerFile.exists() || !installerFile.extension.equals("msi", ignoreCase = true)) {
132 | onResult(false, "File not found or incorrect extension (MSI expected).")
133 | return
134 | }
135 |
136 | // 3. Prepare the msiexec command
137 | val command = listOf(
138 | "msiexec",
139 | "/i", installerFile.absolutePath,
140 | "/quiet",
141 | "/l*v", "${installerFile.parentFile?.absolutePath}\\installation_log.txt"
142 | )
143 |
144 | try {
145 | // 4. Build the ProcessBuilder
146 | val processBuilder = ProcessBuilder(command).apply {
147 | redirectErrorStream(true)
148 | }
149 |
150 | // 5. Start the msiexec process
151 | val process = processBuilder.start()
152 | val exitCode = process.waitFor()
153 |
154 | // 6. Read the output stream
155 | val output = InputStreamReader(process.inputStream).readText().trim()
156 |
157 | // 7. Check the exit code
158 | if (exitCode == 0) {
159 | onResult(true, null)
160 | } else {
161 | val errorMessage = """
162 | Installation failed (return code: $exitCode).
163 | Output:
164 | $output
165 | """.trimIndent()
166 | onResult(false, errorMessage)
167 | }
168 | } catch (e: Exception) {
169 | onResult(false, "Exception during installation: ${e.message}")
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/androidMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/AndroidClipboardMonitor.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager
2 |
3 | import android.content.ClipData
4 | import android.content.ClipDescription
5 | import android.content.ClipboardManager
6 | import android.content.ContentResolver
7 | import android.content.Context
8 | import android.database.Cursor
9 | import android.net.Uri
10 | import android.os.Handler
11 | import android.os.Looper
12 | import java.security.MessageDigest
13 | import java.util.concurrent.atomic.AtomicBoolean
14 |
15 | /**
16 | * Android implementation of ClipboardMonitor using ClipboardManager callbacks.
17 | * - Debounced via main-thread Handler to coalesce rapid events.
18 | * - Builds a lightweight signature to avoid emitting duplicate consecutive events.
19 | * - Extracts: text, html (if provided), files (as content URIs), imageAvailable boolean.
20 | *
21 | * NOTE: On Android there is no standard RTF flavor; we leave rtf = null.
22 | * Files are exposed as content URIs (string). Resolving real paths is discouraged.
23 | */
24 | internal class AndroidClipboardMonitor(
25 | private val context: Context,
26 | private val listener: ClipboardListener,
27 | private val debounceMillis: Long = 50L
28 | ) : ClipboardMonitor {
29 |
30 | private val running = AtomicBoolean(false)
31 | private val mainHandler = Handler(Looper.getMainLooper())
32 | private var lastSignature: String? = null
33 |
34 | private val cb by lazy {
35 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
36 | }
37 |
38 | private val callback = ClipboardManager.OnPrimaryClipChangedListener {
39 | // Debounce on main thread
40 | mainHandler.removeCallbacks(notifyRunnable)
41 | mainHandler.postDelayed(notifyRunnable, debounceMillis)
42 | }
43 |
44 | private val notifyRunnable = Runnable {
45 | if (!running.get()) return@Runnable
46 | val content = readClipboard()
47 | val sig = signatureOf(content)
48 | if (sig != lastSignature) {
49 | lastSignature = sig
50 | try {
51 | listener.onClipboardChange(content)
52 | } catch (_: Throwable) {
53 | // Listener exceptions must not stop monitoring
54 | }
55 | }
56 | }
57 |
58 | override fun start() {
59 | if (running.getAndSet(true)) return
60 | // Register listener and fire initial snapshot
61 | mainHandler.post {
62 | runCatching { cb.addPrimaryClipChangedListener(callback) }
63 | notifyRunnable.run()
64 | }
65 | }
66 |
67 | override fun stop() {
68 | if (!running.getAndSet(false)) return
69 | mainHandler.post {
70 | runCatching { cb.removePrimaryClipChangedListener(callback) }
71 | }
72 | mainHandler.removeCallbacksAndMessages(null)
73 | lastSignature = null
74 | }
75 |
76 | override fun isRunning(): Boolean = running.get()
77 |
78 | override fun getCurrentContent(): ClipboardContent = readClipboard()
79 |
80 | // ==== Internals ====
81 |
82 | private fun readClipboard(): ClipboardContent {
83 | val cm = cb
84 | val clip: ClipData? = runCatching { cm.primaryClip }.getOrNull()
85 | val desc: ClipDescription? = runCatching { cm.primaryClipDescription }.getOrNull()
86 |
87 | if (clip == null || clip.itemCount == 0) {
88 | return ClipboardContent(timestamp = System.currentTimeMillis())
89 | }
90 |
91 | var text: String? = null
92 | var html: String? = null
93 | var files: MutableList? = null
94 | var imageAvailable = false
95 |
96 | for (i in 0 until clip.itemCount) {
97 | val item = clip.getItemAt(i)
98 |
99 | // Text (coerceToText covers plain text and simple formats)
100 | val coerced = item.coerceToText(context)
101 | if (!coerced.isNullOrEmpty()) {
102 | text = (text ?: "") + (if (text == null) "" else "\n") + coerced.toString()
103 | }
104 |
105 | // HTML (explicit htmlText if provided)
106 | val h = item.htmlText
107 | if (!h.isNullOrEmpty()) {
108 | html = (html ?: "") + (if (html == null) "" else "\n") + h
109 | }
110 |
111 | // URIs: could be images or files (documents/photos/etc.)
112 | val uri: Uri? = item.uri
113 | if (uri != null) {
114 | val cr = context.contentResolver
115 | val type = runCatching { cr.getType(uri) }.getOrNull()
116 | if ((type ?: "").startsWith("image/")) {
117 | imageAvailable = true
118 | }
119 | // Treat any content URI as a "file-like" entry
120 | if (files == null) files = mutableListOf()
121 | files!!.add(uri.toString())
122 | }
123 |
124 | // Intents are ignored for now (non-file, non-text clipboard content)
125 | }
126 |
127 | // Also mark images via mime description if available (fallback)
128 | if (!imageAvailable && desc != null) {
129 | val mimes = (0 until desc.mimeTypeCount).map { desc.getMimeType(it) }
130 | imageAvailable = mimes.any { it.startsWith(ClipDescription.MIMETYPE_TEXT_URILIST) || it.startsWith("image/") }
131 | }
132 |
133 | return ClipboardContent(
134 | text = text,
135 | html = html,
136 | rtf = null, // Android has no canonical RTF clipboard flavor
137 | files = files,
138 | imageAvailable = imageAvailable,
139 | timestamp = System.currentTimeMillis()
140 | )
141 | }
142 |
143 | private fun signatureOf(c: ClipboardContent): String {
144 | val sb = StringBuilder(128)
145 | fun add(name: String, v: String?) {
146 | if (v != null) {
147 | sb.append(name).append('#').append(v.length).append(';')
148 | sb.append(v.take(64)).append('|')
149 | } else sb.append(name).append('#').append("0;|")
150 | }
151 | add("t", c.text)
152 | add("h", c.html)
153 | add("r", c.rtf)
154 | sb.append("i#").append(if (c.imageAvailable) 1 else 0).append('|')
155 | val f = c.files
156 | if (f != null) {
157 | sb.append("f#").append(f.size).append('|')
158 | f.take(8).forEach { sb.append(it).append('|') }
159 | } else sb.append("f#0|")
160 | return sha1(sb.toString())
161 | }
162 |
163 | private fun sha1(s: String): String {
164 | val md = MessageDigest.getInstance("SHA-1")
165 | val bytes = md.digest(s.toByteArray(Charsets.UTF_8))
166 | val hex = CharArray(bytes.size * 2)
167 | val hexChars = "0123456789abcdef".toCharArray()
168 | var i = 0
169 | for (b in bytes) {
170 | val v = b.toInt() and 0xFF
171 | hex[i++] = hexChars[v ushr 4]
172 | hex[i++] = hexChars[v and 0x0F]
173 | }
174 | return String(hex)
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/sample/composeApp/src/commonMain/kotlin/sample/app/GitHubRepoFetcherDemo.kt:
--------------------------------------------------------------------------------
1 | package sample.app
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.lazy.items
6 | import androidx.compose.material3.*
7 | import androidx.compose.material3.HorizontalDivider
8 | import androidx.compose.runtime.*
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.text.input.TextFieldValue
11 | import androidx.compose.ui.unit.dp
12 | import io.github.kdroidfilter.platformtools.releasefetcher.github.GitHubReleaseFetcher
13 | import io.github.kdroidfilter.platformtools.releasefetcher.github.model.Release
14 | import kotlinx.coroutines.launch
15 |
16 | @OptIn(ExperimentalMaterial3Api::class)
17 | @Composable
18 | fun GitHubRepoFetcherDemo() {
19 | val scope = rememberCoroutineScope()
20 |
21 | var url by remember { mutableStateOf(TextFieldValue("https://github.com/kdroidFilter/KmpRealTimeLogger")) }
22 | var owner by remember { mutableStateOf("kdroidFilter") }
23 | var repo by remember { mutableStateOf("KmpRealTimeLogger") }
24 |
25 | var error by remember { mutableStateOf(null) }
26 | var loading by remember { mutableStateOf(false) }
27 | var release by remember { mutableStateOf(null) }
28 |
29 | fun parseRepo(input: String) {
30 | error = null
31 | release = null
32 | val trimmed = input.trim()
33 | // Accept forms: https://github.com/owner/repo, http://github.com/owner/repo, github.com/owner/repo, owner/repo
34 | val cleaned = trimmed
35 | .removePrefix("https://")
36 | .removePrefix("http://")
37 | .removePrefix("www.")
38 | val withoutHost =
39 | if (cleaned.startsWith("github.com/", ignoreCase = true)) cleaned.substringAfter("github.com/")
40 | else cleaned
41 | val parts = withoutHost.split('/').filter { it.isNotBlank() }
42 |
43 | if (parts.size >= 2) {
44 | owner = parts[0]
45 | repo = parts[1]
46 | } else if (trimmed.contains('/')) {
47 | val sub = trimmed.split('/').filter { it.isNotBlank() }
48 | if (sub.size >= 2) {
49 | owner = sub[0]
50 | repo = sub[1]
51 | } else {
52 | owner = null
53 | repo = null
54 | error = "Invalid repository format. Use owner/repo or a GitHub URL."
55 | }
56 | } else {
57 | owner = null
58 | repo = null
59 | error = "Invalid repository format. Use owner/repo or a GitHub URL."
60 | }
61 | }
62 |
63 | fun canFetch(): Boolean = !loading && !owner.isNullOrBlank() && !repo.isNullOrBlank()
64 |
65 | Column(
66 | modifier = Modifier
67 | .fillMaxSize()
68 | .padding(16.dp),
69 | verticalArrangement = Arrangement.spacedBy(12.dp)
70 | ) {
71 | Text("GitHub Release Fetcher (by repository link)", style = MaterialTheme.typography.titleLarge)
72 |
73 | OutlinedTextField(
74 | value = url,
75 | onValueChange = {
76 | url = it
77 | parseRepo(it.text)
78 | },
79 | label = { Text("GitHub repository link (e.g., https://github.com/owner/repo)") },
80 | modifier = Modifier.fillMaxWidth(),
81 | isError = error != null,
82 | singleLine = true
83 | )
84 |
85 | if (owner != null && repo != null) {
86 | AssistChip(
87 | onClick = {},
88 | label = { Text("owner: $owner | repo: $repo") }
89 | )
90 | }
91 |
92 | if (error != null) {
93 | Text(
94 | error!!,
95 | color = MaterialTheme.colorScheme.error,
96 | style = MaterialTheme.typography.bodyMedium
97 | )
98 | }
99 |
100 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
101 | Button(
102 | onClick = {
103 | parseRepo(url.text)
104 | if (!canFetch()) return@Button
105 | loading = true
106 | error = null
107 | release = null
108 | val o = owner!!
109 | val r = repo!!
110 | scope.launch {
111 | try {
112 | val fetcher = GitHubReleaseFetcher(o, r)
113 | release = fetcher.getLatestRelease()
114 | if (release == null) {
115 | error = "No release found for $o/$r."
116 | }
117 | } catch (e: Exception) {
118 | error = "Fetch error: ${e.message}"
119 | } finally {
120 | loading = false
121 | }
122 | }
123 | },
124 | enabled = canFetch()
125 | ) {
126 | Text(if (loading) "Loading..." else "Fetch latest release")
127 | }
128 | }
129 |
130 | HorizontalDivider()
131 |
132 | release?.let { rel ->
133 | LazyColumn(
134 | verticalArrangement = Arrangement.spacedBy(8.dp),
135 | modifier = Modifier.fillMaxSize()
136 | ) {
137 | item {
138 | Text(
139 | "${rel.name.ifBlank { rel.tag_name }} (${rel.tag_name})",
140 | style = MaterialTheme.typography.titleMedium
141 | )
142 | }
143 | item { Text("Published at: ${rel.published_at}", style = MaterialTheme.typography.bodyMedium) }
144 | item { Text("Author: ${rel.author.login}", style = MaterialTheme.typography.bodyMedium) }
145 | item { Text("Link: ${rel.html_url}", style = MaterialTheme.typography.bodyMedium) }
146 | item { Text("Tag : ${rel.tag_name}", style = MaterialTheme.typography.bodyMedium) }
147 |
148 |
149 | if (rel.body.isNotBlank()) {
150 | item { Text("Release notes:") }
151 | item { Text(rel.body, style = MaterialTheme.typography.bodySmall) }
152 | }
153 |
154 | if (rel.assets.isNotEmpty()) {
155 | item { Text("Assets:", style = MaterialTheme.typography.titleSmall) }
156 | items(rel.assets) { asset ->
157 | ElevatedCard(modifier = Modifier.fillMaxWidth()) {
158 | Column(Modifier.padding(12.dp)) {
159 | Text(asset.name, style = MaterialTheme.typography.bodyLarge)
160 | Text("Size: ${asset.size} bytes", style = MaterialTheme.typography.bodySmall)
161 | Text("Download: ${asset.browser_download_url}", style = MaterialTheme.typography.bodySmall)
162 | }
163 | }
164 | }
165 | }
166 | }
167 | }
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/platformtools/appmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/appmanager/AppInstaller.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.appmanager
2 |
3 | import co.touchlab.kermit.Logger
4 | import co.touchlab.kermit.Logger.Companion.setMinSeverity
5 | import co.touchlab.kermit.Severity
6 | import io.github.kdroidfilter.platformtools.OperatingSystem
7 | import io.github.kdroidfilter.platformtools.appmanager.WindowsPrivilegeHelper.installOnWindows
8 | import io.github.kdroidfilter.platformtools.getOperatingSystem
9 | import java.io.File
10 |
11 | val logger = Logger.withTag("AppInstaller").apply { setMinSeverity(Severity.Warn) }
12 |
13 |
14 | fun getAppInstaller(): AppInstaller = DesktopInstaller()
15 |
16 | /**
17 | * The `DesktopInstaller` class is responsible for handling the installation of applications
18 | * on desktop operating systems such as Windows, Linux, and macOS. This class implements the
19 | * `AppInstaller` interface and provides platform-specific installation logic for each supported
20 | * operating system.
21 | */
22 | class DesktopInstaller : AppInstaller {
23 |
24 | /**
25 | * Installs a given application file based on the detected operating system.
26 | * The method supports multiple platforms (Windows, Linux, Mac) and uses platform-specific
27 | * installation mechanisms. If the operating system is unsupported, the installation fails with
28 | * an appropriate message.
29 | *
30 | * @param appFile The application file to be installed.
31 | * @param onResult A callback that provides the result of the installation.
32 | * The first parameter indicates success or failure (Boolean).
33 | * The second parameter contains an optional error message (String?).
34 | */
35 | override suspend fun installApp(appFile: File, onResult: (Boolean, String?) -> Unit) {
36 | logger.d { "Starting installation for file: ${appFile.absolutePath}" }
37 | val osDetected = getOperatingSystem()
38 | logger.d { "Detected OS: $osDetected" }
39 |
40 | when (osDetected) {
41 | OperatingSystem.WINDOWS -> installOnWindows(appFile, onResult)
42 | OperatingSystem.LINUX -> installOnLinux(appFile, onResult)
43 | OperatingSystem.MACOS -> installOnMac(appFile, onResult)
44 | else -> {
45 | val message = "Installation not supported for: ${getOperatingSystem()}"
46 | logger.d { message }
47 | onResult(false, message)
48 | }
49 | }
50 | }
51 |
52 | /**
53 | * Installs a .deb package on a Linux system using `pkexec` and `dpkg`.
54 | * This method attempts to elevate privileges if required and executes the installation command.
55 | *
56 | * @param installerFile The .deb file to be installed.
57 | * @param onResult A callback to handle the result of the installation.
58 | * The first parameter is a Boolean indicating success or failure.
59 | * The second parameter is an optional String containing an error message or output.
60 | */
61 | private fun installOnLinux(installerFile: File, onResult: (Boolean, String?) -> Unit) {
62 | logger.d { "Starting installation for .deb package." }
63 |
64 | if (!installerFile.exists()) {
65 | val msg = "DEB file not found: ${installerFile.absolutePath}"
66 | logger.d { msg }
67 | onResult(false, msg)
68 | return
69 | }
70 |
71 | logger.d { "Executing dpkg via pkexec, which will prompt for a password if needed." }
72 |
73 | val command = listOf("pkexec", "dpkg", "-i", installerFile.absolutePath)
74 | logger.d { "pkexec command: $command" }
75 |
76 | runCommand(command) { success, output ->
77 | logger.d { "pkexec + dpkg result: success=$success, output=$output" }
78 |
79 | if (!success) {
80 | logger.d { "dpkg via pkexec failed." }
81 | onResult(false, output)
82 | } else {
83 | logger.d { "DEB package installation succeeded!" }
84 | onResult(true, output)
85 | }
86 | }
87 | }
88 |
89 | /**
90 | * Executes a system command using the provided list of command arguments
91 | * and returns the result asynchronously through a callback function.
92 | *
93 | * @param command A list of strings representing the command to execute and its arguments.
94 | * @param onResult A callback function to handle the result of the command execution.
95 | * The first parameter is a Boolean indicating success (true) or failure (false).
96 | * The second parameter is an optional String containing the output of the command
97 | * or an error message in case of failure.
98 | */
99 | private fun runCommand(command: List, onResult: (Boolean, String?) -> Unit) {
100 | logger.d { "Executing command: $command" }
101 | try {
102 | val process = ProcessBuilder(command)
103 | .redirectErrorStream(true)
104 | .start()
105 |
106 | val output = process.inputStream.bufferedReader().readText()
107 | val exitCode = process.waitFor()
108 |
109 | logger.d { "Command completed (exitCode=$exitCode). Output: $output" }
110 |
111 | if (exitCode == 0) {
112 | onResult(true, "Success. Output: $output")
113 | } else {
114 | onResult(false, "Failure (code=$exitCode). Output: $output")
115 | }
116 |
117 | } catch (e: Exception) {
118 | logger.d { "Exception in runCommand(): ${e.message}" }
119 | e.printStackTrace()
120 | onResult(false, "Exception during execution: ${e.message}")
121 | }
122 | }
123 |
124 | /**
125 | * Installs a package on macOS using an installer script with administrator privileges.
126 | *
127 | * @param installerFile The installer file to be executed, typically a `.pkg` file.
128 | * @param onResult A callback to handle the result of the operation. The first parameter
129 | * indicates success or failure as a Boolean. The second parameter provides
130 | * an optional error message as a String.
131 | */
132 | private fun installOnMac(installerFile: File, onResult: (Boolean, String?) -> Unit) {
133 | val script = """
134 | do shell script "installer -pkg ${installerFile.absolutePath} -target /" with administrator privileges
135 | """.trimIndent()
136 |
137 | try {
138 | val process = ProcessBuilder("osascript", "-e", script).start()
139 | val exitCode = process.waitFor()
140 |
141 | if (exitCode == 0) {
142 | onResult(true, null)
143 | } else {
144 | val errorMessage = process.errorStream.bufferedReader().readText()
145 | onResult(false, errorMessage)
146 | }
147 | } catch (e: Exception) {
148 | onResult(false, e.message)
149 | }
150 | }
151 |
152 |
153 | override suspend fun uninstallApp(packageName: String, onResult: (success: Boolean, message: String?) -> Unit) {
154 | TODO("Not yet implemented")
155 | }
156 |
157 | override suspend fun uninstallApp(onResult: (success: Boolean, message: String?) -> Unit) {
158 | TODO("Not yet implemented")
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
88 |
89 | # Use the maximum available, or set MAX_FD != -1 to use that value.
90 | MAX_FD=maximum
91 |
92 | warn () {
93 | echo "$*"
94 | } >&2
95 |
96 | die () {
97 | echo
98 | echo "$*"
99 | echo
100 | exit 1
101 | } >&2
102 |
103 | # OS specific support (must be 'true' or 'false').
104 | cygwin=false
105 | msys=false
106 | darwin=false
107 | nonstop=false
108 | case "$( uname )" in #(
109 | CYGWIN* ) cygwin=true ;; #(
110 | Darwin* ) darwin=true ;; #(
111 | MSYS* | MINGW* ) msys=true ;; #(
112 | NONSTOP* ) nonstop=true ;;
113 | esac
114 |
115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
116 |
117 |
118 | # Determine the Java command to use to start the JVM.
119 | if [ -n "$JAVA_HOME" ] ; then
120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
121 | # IBM's JDK on AIX uses strange locations for the executables
122 | JAVACMD=$JAVA_HOME/jre/sh/java
123 | else
124 | JAVACMD=$JAVA_HOME/bin/java
125 | fi
126 | if [ ! -x "$JAVACMD" ] ; then
127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
128 |
129 | Please set the JAVA_HOME variable in your environment to match the
130 | location of your Java installation."
131 | fi
132 | else
133 | JAVACMD=java
134 | if ! command -v java >/dev/null 2>&1
135 | then
136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 | fi
142 |
143 | # Increase the maximum file descriptors if we can.
144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
145 | case $MAX_FD in #(
146 | max*)
147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
148 | # shellcheck disable=SC2039,SC3045
149 | MAX_FD=$( ulimit -H -n ) ||
150 | warn "Could not query maximum file descriptor limit"
151 | esac
152 | case $MAX_FD in #(
153 | '' | soft) :;; #(
154 | *)
155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
156 | # shellcheck disable=SC2039,SC3045
157 | ulimit -n "$MAX_FD" ||
158 | warn "Could not set maximum file descriptor limit to $MAX_FD"
159 | esac
160 | fi
161 |
162 | # Collect all arguments for the java command, stacking in reverse order:
163 | # * args from the command line
164 | # * the main class name
165 | # * -classpath
166 | # * -D...appname settings
167 | # * --module-path (only if needed)
168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
169 |
170 | # For Cygwin or MSYS, switch paths to Windows format before running java
171 | if "$cygwin" || "$msys" ; then
172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -classpath "$CLASSPATH" \
214 | org.gradle.wrapper.GradleWrapperMain \
215 | "$@"
216 |
217 | # Stop when "xargs" is not available.
218 | if ! command -v xargs >/dev/null 2>&1
219 | then
220 | die "xargs is not available"
221 | fi
222 |
223 | # Use "xargs" to parse quoted args.
224 | #
225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
226 | #
227 | # In Bash we could simply go:
228 | #
229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
230 | # set -- "${ARGS[@]}" "$@"
231 | #
232 | # but POSIX shell has neither arrays nor command substitution, so instead we
233 | # post-process each arg (as a line of input to sed) to backslash-escape any
234 | # character that might be a shell metacharacter, then use eval to reverse
235 | # that process (while maintaining the separation between arguments), and wrap
236 | # the whole thing up as a single "set" statement.
237 | #
238 | # This will of course break if any of these variables contains a newline or
239 | # an unmatched quote.
240 | #
241 |
242 | eval "set -- $(
243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
244 | xargs -n1 |
245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
246 | tr '\n' ' '
247 | )" '"$@"'
248 |
249 | exec "$JAVACMD" "$@"
250 |
--------------------------------------------------------------------------------
/platformtools/clipboardmanager/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/clipboardmanager/windows/WindowsClipboardMonitor.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.clipboardmanager.windows
2 |
3 | import com.sun.jna.Pointer
4 | import io.github.kdroidfilter.platformtools.clipboardmanager.*
5 | import java.awt.Toolkit
6 | import java.awt.datatransfer.DataFlavor
7 | import java.util.concurrent.CountDownLatch
8 | import java.util.concurrent.atomic.AtomicBoolean
9 | import java.util.concurrent.atomic.AtomicReference
10 | import com.sun.jna.platform.win32.*
11 | import com.sun.jna.platform.win32.WinUser.*
12 | import com.sun.jna.platform.win32.WinDef.*
13 | import com.sun.jna.ptr.PointerByReference
14 |
15 | internal class WindowsClipboardMonitor(private val listener: ClipboardListener) : ClipboardMonitor {
16 | private val WM_CLIPBOARDUPDATE = 0x031D
17 | private val WM_QUIT = 0x0012
18 | private val WM_DESTROY = 0x0002
19 |
20 | // Keep a strong reference to the WindowProc to prevent GC.
21 | private val wndProc: WindowProc = WindowProc { h, msg, w, l -> handleMessage(h, msg, w, l) }
22 |
23 | private var hwnd: HWND? = null
24 | private var classRegistered = false
25 | private var thread: Thread? = null
26 | private val running = AtomicBoolean(false)
27 | private val started = CountDownLatch(1)
28 | private val startError = AtomicReference(null)
29 | private var shutdownHook: Thread? = null
30 |
31 | override fun start() {
32 | if (running.get()) return
33 | running.set(true)
34 |
35 | thread = Thread({
36 | try {
37 | createMessageWindow()
38 | } catch (e: Throwable) {
39 | startError.set(e)
40 | } finally {
41 | // Unblock the starter regardless of success/failure
42 | started.countDown()
43 | }
44 |
45 | try {
46 | if (startError.get() == null) {
47 | loop()
48 | }
49 | } finally {
50 | cleanup()
51 | }
52 | }, "Windows-ClipboardMonitor").apply {
53 | isDaemon = true
54 | start()
55 | }
56 |
57 | // Register a shutdown hook to ensure background thread is stopped
58 | shutdownHook = Thread { runCatching { stop() } }.also {
59 | runCatching { Runtime.getRuntime().addShutdownHook(it) }
60 | }
61 |
62 | // Wait until window creation succeeded or failed
63 | started.await()
64 |
65 | // Surface any startup failure to caller
66 | startError.get()?.let { err ->
67 | running.set(false)
68 | throw err
69 | }
70 | }
71 |
72 | override fun stop() {
73 | if (!running.get()) return
74 | running.set(false)
75 | // Try to remove shutdown hook if we're not already in shutdown
76 | shutdownHook?.let { hook ->
77 | runCatching { Runtime.getRuntime().removeShutdownHook(hook) }
78 | }
79 | shutdownHook = null
80 | hwnd?.let { User32.INSTANCE.PostMessage(it, WM_QUIT, WPARAM(0), LPARAM(0)) }
81 | thread?.join(5000)
82 | }
83 |
84 | override fun isRunning() = running.get()
85 |
86 | override fun getCurrentContent(): ClipboardContent = readClipboard()
87 |
88 | private fun lastErrorMsg(prefix: String): String {
89 | val code = Kernel32.INSTANCE.GetLastError()
90 | if (code == 0) return "$prefix (GetLastError=0)"
91 |
92 | val flags = WinBase.FORMAT_MESSAGE_FROM_SYSTEM or
93 | WinBase.FORMAT_MESSAGE_IGNORE_INSERTS or
94 | WinBase.FORMAT_MESSAGE_ALLOCATE_BUFFER
95 |
96 | val out = PointerByReference()
97 | val len = Kernel32.INSTANCE.FormatMessage(
98 | flags,
99 | null,
100 | code,
101 | 0,
102 | out,
103 | 0,
104 | null
105 | )
106 | if (len == 0) {
107 | return "$prefix (error $code: )"
108 | }
109 |
110 | val ptr: Pointer = out.value
111 | val msg = try {
112 | ptr.getWideString(0).trim()
113 | } finally {
114 | Kernel32.INSTANCE.LocalFree(ptr)
115 | }
116 | return "$prefix (error $code: $msg)"
117 | }
118 |
119 | private fun createMessageWindow() {
120 | val hInstance = Kernel32.INSTANCE.GetModuleHandle(null)
121 | val className = "ClipboardMonitorWindow_${Integer.toHexString(System.identityHashCode(this))}"
122 |
123 | // Register once per instance
124 | if (!classRegistered) {
125 | val wndClass = WNDCLASSEX().apply {
126 | cbSize = size()
127 | lpfnWndProc = wndProc
128 | this.hInstance = hInstance
129 | lpszClassName = className
130 | }
131 | if (User32.INSTANCE.RegisterClassEx(wndClass).toInt() == 0) {
132 | throw IllegalStateException(lastErrorMsg("RegisterClassEx failed"))
133 | }
134 | classRegistered = true
135 | }
136 |
137 | // -------- Attempt #1: message-only parent (HWND_MESSAGE) --------
138 | hwnd = User32.INSTANCE.CreateWindowEx(
139 | 0, className, "Clipboard Monitor", 0,
140 | 0, 0, 0, 0,
141 | HWND_MESSAGE, null, hInstance, null
142 | )
143 |
144 | if (hwnd == null || !User32.INSTANCE.IsWindow(hwnd)) {
145 | val firstErr = lastErrorMsg("CreateWindowEx (HWND_MESSAGE) failed")
146 |
147 | // -------- Attempt #2: hidden top-level WS_POPUP, no parent --------
148 | val WS_POPUP = 0x80000000.toInt()
149 | hwnd = User32.INSTANCE.CreateWindowEx(
150 | 0, className, "Clipboard Monitor", WS_POPUP,
151 | 0, 0, 1, 1,
152 | null, null, hInstance, null
153 | )
154 |
155 | if (hwnd == null || !User32.INSTANCE.IsWindow(hwnd)) {
156 | throw IllegalStateException("$firstErr; then fallback also failed: ${lastErrorMsg("CreateWindowEx (WS_POPUP) failed")}")
157 | }
158 | // Keep it hidden; no ShowWindow call
159 | }
160 |
161 | // Register for clipboard notifications
162 | hwnd!!.let {
163 | if (!User32Extended.INSTANCE.AddClipboardFormatListener(it)) {
164 | throw IllegalStateException(lastErrorMsg("AddClipboardFormatListener failed"))
165 | }
166 | }
167 | }
168 |
169 | private fun handleMessage(hwnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
170 | return when (uMsg) {
171 | WM_CLIPBOARDUPDATE -> {
172 | try { listener.onClipboardChange(readClipboard()) } catch (_: Throwable) {}
173 | LRESULT(0)
174 | }
175 | WM_DESTROY -> {
176 | User32.INSTANCE.PostQuitMessage(0)
177 | LRESULT(0)
178 | }
179 | else -> User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam)
180 | }
181 | }
182 |
183 | private fun loop() {
184 | val msg = MSG()
185 | while (running.get()) {
186 | val r = User32.INSTANCE.GetMessage(msg, null, 0, 0)
187 | when {
188 | r > 0 -> {
189 | User32.INSTANCE.TranslateMessage(msg)
190 | User32.INSTANCE.DispatchMessage(msg)
191 | }
192 | r == 0 -> break // WM_QUIT
193 | else -> break // error
194 | }
195 | }
196 | }
197 |
198 | private fun cleanup() {
199 | hwnd?.let {
200 | runCatching { User32Extended.INSTANCE.RemoveClipboardFormatListener(it) }
201 | runCatching { User32.INSTANCE.DestroyWindow(it) }
202 | }
203 | hwnd = null
204 | running.set(false)
205 | // Unregistering the class is optional; OS will clean up at process exit.
206 | }
207 |
208 | private fun readClipboard(): ClipboardContent {
209 | val clipboard = Toolkit.getDefaultToolkit().systemClipboard
210 | val contents = clipboard.getContents(null) ?: return ClipboardContent(timestamp = System.currentTimeMillis())
211 |
212 | var text: String? = null
213 | var html: String? = null
214 | var rtf: String? = null
215 | var files: List? = null
216 | val imageAvailable: Boolean = contents.isDataFlavorSupported(DataFlavor.imageFlavor)
217 |
218 | if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
219 | text = contents.getTransferData(DataFlavor.stringFlavor) as? String
220 | }
221 |
222 | runCatching {
223 | val htmlFlavor = DataFlavor("text/html;class=java.lang.String")
224 | if (contents.isDataFlavorSupported(htmlFlavor)) {
225 | html = contents.getTransferData(htmlFlavor) as? String
226 | }
227 | }
228 |
229 | runCatching {
230 | val rtfFlavor = DataFlavor("text/rtf;class=java.lang.String")
231 | if (contents.isDataFlavorSupported(rtfFlavor)) {
232 | rtf = contents.getTransferData(rtfFlavor) as? String
233 | }
234 | }
235 |
236 | if (contents.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
237 | @Suppress("UNCHECKED_CAST")
238 | val list = contents.getTransferData(DataFlavor.javaFileListFlavor) as List
239 | files = list.map { it.absolutePath }
240 | }
241 |
242 | return ClipboardContent(text, html, rtf, files, imageAvailable, System.currentTimeMillis())
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/windows/WindowsThemeDetector.kt:
--------------------------------------------------------------------------------
1 | // Inspired by the code from the jSystemThemeDetector project:
2 | // https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/src/main/java/com/jthemedetecor/WindowsThemeDetector.java
3 |
4 | package io.github.kdroidfilter.platformtools.darkmodedetector.windows
5 |
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.DisposableEffect
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import co.touchlab.kermit.Logger
11 | import co.touchlab.kermit.Logger.Companion.setMinSeverity
12 | import co.touchlab.kermit.Severity
13 | import com.sun.jna.Native
14 | import com.sun.jna.platform.win32.*
15 | import com.sun.jna.platform.win32.WinNT.KEY_READ
16 | import com.sun.jna.platform.win32.WinReg.HKEY
17 | import com.sun.jna.ptr.IntByReference
18 | import io.github.kdroidfilter.platformtools.OperatingSystem
19 | import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode
20 | import io.github.kdroidfilter.platformtools.getOperatingSystem
21 | import java.awt.Window
22 | import java.util.concurrent.ConcurrentHashMap
23 | import java.util.function.Consumer
24 |
25 | // Initialize logger using kotlin-logging
26 | internal val windowsLogger = Logger.withTag("WindowsThemeDetector").apply { setMinSeverity(Severity.Warn) }
27 |
28 | /**
29 | * WindowsThemeDetector uses JNA to read the Windows registry value:
30 | * HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme
31 | *
32 | * If this value = 0 => Dark mode. If this value = 1 => Light mode.
33 | *
34 | * The detector also monitors the registry for changes in real-time by
35 | * calling RegNotifyChangeKeyValue on a background thread.
36 | */
37 | internal object WindowsThemeDetector {
38 | private const val REGISTRY_PATH = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
39 | private const val REGISTRY_VALUE = "AppsUseLightTheme"
40 |
41 | // A set of listeners to notify when the theme changes (true = dark, false = light).
42 | private val listeners: MutableSet> = ConcurrentHashMap.newKeySet()
43 |
44 | @Volatile
45 | private var detectorThread: Thread? = null
46 |
47 | /**
48 | * Returns true if the system is in dark mode (i.e. registry value is 0),
49 | * or false if the system is in light mode (registry value is 1 or doesn't
50 | * exist).
51 | */
52 | fun isDark(): Boolean {
53 | // Check if the registry value is 0 for "AppsUseLightTheme"
54 | return Advapi32Util.registryValueExists(WinReg.HKEY_CURRENT_USER, REGISTRY_PATH, REGISTRY_VALUE) &&
55 | Advapi32Util.registryGetIntValue(WinReg.HKEY_CURRENT_USER, REGISTRY_PATH, REGISTRY_VALUE) == 0
56 | }
57 |
58 | /**
59 | * Registers a listener. If it's the first listener, we start a
60 | * background thread to listen for changes in the registry key via
61 | * RegNotifyChangeKeyValue.
62 | */
63 | fun registerListener(listener: Consumer) {
64 | synchronized(this) {
65 | listeners.add(listener)
66 |
67 | // If this is the first listener, or if a previous thread was interrupted,
68 | // start a new monitoring thread
69 | if (listeners.size == 1 || detectorThread?.isInterrupted == true) {
70 | startMonitoringThread()
71 | }
72 | }
73 | }
74 |
75 | /**
76 | * Removes a listener. If no listeners remain, we interrupt the monitoring
77 | * thread.
78 | */
79 | fun removeListener(listener: Consumer) {
80 | synchronized(this) {
81 | listeners.remove(listener)
82 | if (listeners.isEmpty()) {
83 | detectorThread?.interrupt()
84 | detectorThread = null
85 | }
86 | }
87 | }
88 |
89 | /**
90 | * Creates and starts a background thread that monitors the registry for
91 | * changes to the theme key. When a change is detected, it reads the new
92 | * theme and notifies the listeners if there's a difference from the
93 | * previous state.
94 | */
95 | private fun startMonitoringThread() {
96 | val thread = object : Thread("Windows Theme Detector Thread") {
97 | private var lastValue = isDark()
98 |
99 | override fun run() {
100 | windowsLogger.d { "Windows theme monitor thread started" }
101 |
102 | // Open the registry key for reading
103 | val hKeyRef = WinReg.HKEYByReference()
104 | val openErr = Advapi32.INSTANCE.RegOpenKeyEx(
105 | WinReg.HKEY_CURRENT_USER,
106 | REGISTRY_PATH,
107 | 0,
108 | KEY_READ,
109 | hKeyRef
110 | )
111 | if (openErr != WinError.ERROR_SUCCESS) {
112 | windowsLogger.e { "RegOpenKeyEx failed with code $openErr" }
113 | return
114 | }
115 | val hKey: HKEY = hKeyRef.value
116 |
117 | try {
118 | // Loop until the thread is interrupted
119 | while (!isInterrupted) {
120 | // Wait for registry changes
121 | val notifyErr = Advapi32.INSTANCE.RegNotifyChangeKeyValue(
122 | hKey,
123 | false,
124 | WinNT.REG_NOTIFY_CHANGE_LAST_SET,
125 | null,
126 | false
127 | )
128 | if (notifyErr != WinError.ERROR_SUCCESS) {
129 | windowsLogger.e { "RegNotifyChangeKeyValue failed with code $notifyErr" }
130 | return
131 | }
132 |
133 | val currentValue = isDark()
134 | if (currentValue != lastValue) {
135 | lastValue = currentValue
136 | windowsLogger.d { "Windows theme changed => dark: $currentValue" }
137 | // Notify all listeners
138 | val snapshot = listeners.toList()
139 | for (l in snapshot) {
140 | try {
141 | l.accept(currentValue)
142 | } catch (e: RuntimeException) {
143 | windowsLogger.e(e) { "Error while notifying listener" }
144 | }
145 | }
146 | }
147 | }
148 | } finally {
149 | // Close the registry key
150 | windowsLogger.d { "Detector thread closing registry key" }
151 | Advapi32Util.registryCloseKey(hKey)
152 | }
153 | }
154 | }
155 | thread.isDaemon = true
156 | detectorThread = thread
157 | thread.start()
158 | }
159 | }
160 |
161 | /**
162 | * Composable function that returns whether Windows is currently in dark
163 | * mode.
164 | *
165 | * It uses [WindowsThemeDetector] to read the registry
166 | * value for AppsUseLightTheme. It registers a listener to
167 | * automatically update the Compose state if the registry changes.
168 | */
169 | @Composable
170 | internal fun isWindowsInDarkMode(): Boolean {
171 | // Compose state with initial value
172 | val darkModeState = remember { mutableStateOf(WindowsThemeDetector.isDark()) }
173 |
174 | DisposableEffect(Unit) {
175 | windowsLogger.d { "Registering Windows dark mode listener in Compose" }
176 | val listener = Consumer { newValue ->
177 | windowsLogger.d { "Windows dark mode updated: $newValue" }
178 | darkModeState.value = newValue
179 | }
180 |
181 | WindowsThemeDetector.registerListener(listener)
182 |
183 | onDispose {
184 | windowsLogger.d { "Removing Windows dark mode listener in Compose" }
185 | WindowsThemeDetector.removeListener(listener)
186 | }
187 | }
188 |
189 | return darkModeState.value
190 | }
191 |
192 | /**
193 | * Sets the dark mode title bar appearance for a Windows application
194 | * window.
195 | *
196 | * This function attempts to modify the immersive dark mode attribute
197 | * for the specified window's title bar using the Windows Desktop Window
198 | * Manager API (DWM).
199 | *
200 | * @param dark Boolean value indicating whether the title bar should use
201 | * dark mode. Defaults to the result of [isWindowsInDarkMode], which
202 | * determines the current system theme preference.
203 | */
204 | @Composable
205 | fun Window.setWindowsAdaptiveTitleBar(dark: Boolean = isSystemInDarkMode()) {
206 | try {
207 | if (getOperatingSystem() == OperatingSystem.WINDOWS) {
208 | // Get HWND from the AWT Window
209 | val hwnd = WinDef.HWND(Native.getComponentPointer(this))
210 |
211 | // Create a pointer to hold the boolean value
212 | val darkModeEnabled = IntByReference(if (dark) 1 else 0)
213 |
214 | // Set the window attribute
215 | DwmApi.INSTANCE.DwmSetWindowAttribute(
216 | hwnd,
217 | DwmApi.DWMWA_USE_IMMERSIVE_DARK_MODE,
218 | darkModeEnabled.pointer,
219 | 4 // size of Int
220 | )
221 | }
222 | } catch (e: Exception) {
223 | windowsLogger.d { "Failed to set dark mode: ${e.message}" }
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/platformtools/darkmodedetector/src/jvmMain/kotlin/io/github/kdroidfilter/platformtools/darkmodedetector/linux/KdeThemeDetector.kt:
--------------------------------------------------------------------------------
1 | package io.github.kdroidfilter.platformtools.darkmodedetector.linux
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.runtime.DisposableEffect
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.setValue
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.runtime.collectAsState
11 | import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment
12 | import io.github.kdroidfilter.platformtools.detectLinuxDesktopEnvironment
13 | import kotlinx.coroutines.CancellationException
14 | import kotlinx.coroutines.CoroutineScope
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.Job
17 | import kotlinx.coroutines.delay
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.flow.StateFlow
20 | import kotlinx.coroutines.flow.asStateFlow
21 | import kotlinx.coroutines.launch
22 | import kotlinx.coroutines.sync.Mutex
23 | import kotlinx.coroutines.sync.withLock
24 | import java.io.BufferedReader
25 | import java.io.File
26 | import java.io.InputStreamReader
27 | import java.util.concurrent.TimeUnit
28 | import java.util.concurrent.atomic.AtomicBoolean
29 |
30 | /* ============================================================================
31 | * Public data & API
32 | * ========================================================================== */
33 |
34 | data class KdeThemeState(
35 | val windowTheme: Boolean?, // Global/window color scheme dark?
36 | val panelTheme: Boolean? // Plasma panel (Style) dark?
37 | ) {
38 | val isMixed: Boolean get() = windowTheme != null && panelTheme != null && windowTheme != panelTheme
39 | val isFullDark: Boolean get() = windowTheme == true && panelTheme == true
40 | val isFullLight: Boolean get() = windowTheme == false && panelTheme == false
41 | }
42 |
43 | /** Singleton detector: one monitor, shared state for the whole app. */
44 | object KdeThemeDetector {
45 | private val scope = CoroutineScope(Dispatchers.IO)
46 | private val started = AtomicBoolean(false)
47 | private val state = MutableStateFlow(null)
48 | private val startStopMutex = Mutex()
49 |
50 | // Process-based monitor
51 | @Volatile private var dbusProcess: Process? = null
52 | @Volatile private var dbusReaderJob: Job? = null
53 | @Volatile private var pollerJob: Job? = null
54 |
55 | fun themeState(): StateFlow = state.asStateFlow()
56 |
57 | suspend fun startIfNeeded() {
58 | startStopMutex.withLock {
59 | if (started.get()) return
60 | started.set(true)
61 |
62 | // Compute initial snapshot
63 | state.value = computeSnapshot()
64 |
65 | // Try dbus-monitor first; if not found, fall back to polling
66 | if (isCommandAvailable("dbus-monitor")) {
67 | startDbusMonitor()
68 | } else {
69 | startPollingFallback()
70 | }
71 | }
72 | }
73 |
74 | suspend fun stop() {
75 | startStopMutex.withLock {
76 | started.set(false)
77 | dbusReaderJob?.cancel()
78 | dbusReaderJob = null
79 | dbusProcess?.let { p ->
80 | try { p.destroy() } catch (_: Throwable) {}
81 | dbusProcess = null
82 | }
83 | pollerJob?.cancel()
84 | pollerJob = null
85 | }
86 | }
87 |
88 | private fun startDbusMonitor() {
89 | // Ensure polling is not running
90 | pollerJob?.cancel()
91 | pollerJob = null
92 |
93 | // Launch (and relaunch) dbus-monitor loop
94 | dbusReaderJob = scope.launch {
95 | while (started.get()) {
96 | dbusProcess = try {
97 | ProcessBuilder(
98 | "dbus-monitor",
99 | "--session",
100 | "type='signal',interface='org.kde.KGlobalSettings',member='notifyChange'",
101 | "type='signal',interface='org.kde.PlasmaShell',member='themeChanged'",
102 | "type='signal',interface='org.kde.kdeglobals',member='configChanged'"
103 | )
104 | .redirectErrorStream(true)
105 | .start()
106 | } catch (_: Exception) {
107 | // Fallback to polling if dbus-monitor fails to spawn
108 | startPollingFallback()
109 | return@launch
110 | }
111 |
112 | try {
113 | BufferedReader(InputStreamReader(dbusProcess!!.inputStream)).use { reader ->
114 | var debounceJob: Job? = null
115 |
116 | // Emit current state once on start (already computed but emit again in case consumers started late)
117 | state.emitSafely(computeSnapshot())
118 |
119 | while (started.get()) {
120 | val line = reader.readLine() ?: break
121 | if (line.contains("notifyChange") ||
122 | line.contains("themeChanged") ||
123 | line.contains("configChanged")
124 | ) {
125 | // Debounce rapid bursts from DBus
126 | debounceJob?.cancel()
127 | debounceJob = launch {
128 | delay(200)
129 | state.emitIfChanged(computeSnapshot())
130 | }
131 | }
132 | }
133 |
134 | debounceJob?.join()
135 | }
136 | } catch (_: CancellationException) {
137 | // Normal shutdown
138 | } catch (_: Exception) {
139 | // Reader crashed; will restart below
140 | } finally {
141 | try { dbusProcess?.destroy() } catch (_: Throwable) {}
142 | dbusProcess = null
143 | }
144 |
145 | // If we’re still supposed to run, restart dbus-monitor after a short pause
146 | if (started.get()) delay(500)
147 | }
148 | }
149 | }
150 |
151 | private fun startPollingFallback() {
152 | // Ensure dbus is not running
153 | dbusReaderJob?.cancel()
154 | dbusReaderJob = null
155 | dbusProcess?.let { try { it.destroy() } catch (_: Throwable) {} }
156 | dbusProcess = null
157 |
158 | // Poll a small set of files and kreadconfig outputs
159 | pollerJob = scope.launch {
160 | var last: KdeThemeState? = state.value
161 | while (started.get()) {
162 | val snap = computeSnapshot()
163 | if (snap != last) {
164 | last = snap
165 | state.emitSafely(snap)
166 | }
167 | delay(750) // small, low-cost poll
168 | }
169 | }
170 | }
171 |
172 | /* ----------------------------- Snapshot logic ----------------------------- */
173 |
174 | private fun computeSnapshot(): KdeThemeState =
175 | KdeThemeState(
176 | windowTheme = detectKdeDarkTheme(),
177 | panelTheme = detectKdePanelDark()
178 | )
179 |
180 | /* ------------------------------ Helpers ---------------------------------- */
181 |
182 | private fun isCommandAvailable(cmd: String): Boolean {
183 | // Try `which` without spawning a shell
184 | return try {
185 | val p = ProcessBuilder("which", cmd)
186 | .redirectErrorStream(true)
187 | .start()
188 | p.waitFor(300, TimeUnit.MILLISECONDS) && p.exitValue() == 0
189 | } catch (_: Exception) { false }
190 | }
191 |
192 | private suspend fun MutableStateFlow.emitSafely(v: KdeThemeState?) {
193 | try {
194 | emit(v)
195 | } catch (e: CancellationException) {
196 | throw e
197 | } catch (t: Throwable) {
198 | // Swallow any other exception
199 | }
200 | }
201 |
202 |
203 | private suspend fun MutableStateFlow.emitIfChanged(v: KdeThemeState?) {
204 | if (value != v) emitSafely(v)
205 | }
206 | }
207 |
208 | /* ============================================================================
209 | * Compose-facing APIs
210 | * ========================================================================== */
211 |
212 | /**
213 | * Starts the singleton monitor (idempotent) and returns the latest KDE theme state
214 | * as a Compose state value. Updates are debounced and robust.
215 | */
216 | @Composable
217 | fun rememberKdeDarkModeState(): KdeThemeState? {
218 | // Keep a small local state that mirrors the flow
219 | var current by remember { mutableStateOf(null) }
220 |
221 | // Start monitor once when needed; stop when this composable leaves the tree
222 | DisposableEffect(Unit) {
223 | var disposed = false
224 | val job = Job()
225 | val scope = CoroutineScope(Dispatchers.IO + job)
226 | scope.launch {
227 | KdeThemeDetector.startIfNeeded()
228 | }
229 | onDispose {
230 | disposed = true
231 | job.cancel()
232 | // Do NOT stop the singleton monitor here; other composables/app parts may still use it.
233 | }
234 | }
235 |
236 | // Collect the shared flow as state
237 | val flow = remember { KdeThemeDetector.themeState() }
238 | val collected by flow.collectAsState(initial = null)
239 | LaunchedEffect(collected) { current = collected }
240 |
241 | return current
242 | }
243 |
244 | /** Convenience boolean: prefer window theme, then panel, else false. */
245 | @Composable
246 | fun isKdeInDarkMode(): Boolean {
247 | val st = rememberKdeDarkModeState()
248 | return st?.windowTheme ?: st?.panelTheme ?: false
249 | }
250 |
251 | /* ============================================================================
252 | * KDE-specific detection (pure functions below)
253 | * ========================================================================== */
254 |
255 | private fun isTwilightLaf(lookAndFeel: String?): Boolean {
256 | if (lookAndFeel.isNullOrBlank()) return false
257 | val s = lookAndFeel.lowercase()
258 | return s.contains("breezetwilight") || (s.contains("breeze") && s.contains("twilight"))
259 | }
260 |
261 | private fun runKReadConfig(file: String, group: String, key: String): String? {
262 | val cmds = listOf(
263 | arrayOf("kreadconfig6", "--file", file, "--group", group, "--key", key),
264 | arrayOf("kreadconfig5", "--file", file, "--group", group, "--key", key)
265 | )
266 | for (cmd in cmds) {
267 | try {
268 | val p = ProcessBuilder(*cmd).redirectErrorStream(true).start()
269 | val out = BufferedReader(InputStreamReader(p.inputStream)).use { it.readLine()?.trim() }
270 | p.waitFor(300, TimeUnit.MILLISECONDS)
271 | if (!out.isNullOrBlank()) return out
272 | } catch (_: Exception) { }
273 | }
274 | return null
275 | }
276 |
277 | private fun normalizePlasmaThemeName(rawTheme: String?, lookAndFeel: String?): String? {
278 | if (isTwilightLaf(lookAndFeel)) return "breeze-dark"
279 | if (rawTheme.isNullOrBlank() || rawTheme.equals("default", true)) return "breeze"
280 | return when (rawTheme.lowercase()) {
281 | "org.kde.breezedark.desktop", "breeze-dark", "breezedark" -> "breeze-dark"
282 | "org.kde.breeze.desktop", "breeze" -> "breeze"
283 | else -> rawTheme
284 | }
285 | }
286 |
287 | private fun firstReadable(vararg paths: String): File? =
288 | paths.asSequence().map(::File).firstOrNull { it.isFile && it.canRead() }
289 |
290 | private fun plasmaThemeDirCandidates(themeName: String): List {
291 | val home = System.getenv("HOME") ?: System.getProperty("user.home")
292 | val variants = listOf(
293 | themeName,
294 | themeName.replace(' ', '_'),
295 | themeName.replace(' ', '-'),
296 | themeName.lowercase(),
297 | themeName.lowercase().replace(' ', '_'),
298 | themeName.lowercase().replace(' ', '-')
299 | ).distinct()
300 |
301 | val roots = listOf(
302 | "$home/.local/share/plasma/desktoptheme",
303 | "/usr/share/plasma/desktoptheme"
304 | )
305 |
306 | val dirs = mutableListOf()
307 | for (v in variants) for (r in roots) dirs += File("$r/$v")
308 | return dirs
309 | }
310 |
311 | private fun parseIni(file: File): Map> {
312 | val result = mutableMapOf>()
313 | var section = ""
314 | file.forEachLine { raw ->
315 | val line = raw.trim()
316 | if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) return@forEachLine
317 | if (line.startsWith("[") && line.endsWith("]")) {
318 | section = line.substring(1, line.length - 1)
319 | result.putIfAbsent(section, mutableMapOf())
320 | } else {
321 | val idx = line.indexOf('=')
322 | if (idx > 0) {
323 | val k = line.substring(0, idx).trim()
324 | val v = line.substring(idx + 1).trim()
325 | result.getOrPut(section) { mutableMapOf() }[k] = v
326 | }
327 | }
328 | }
329 | return result
330 | }
331 |
332 | private fun parseKdeRgb(value: String): Triple? {
333 | val nums = value.split(',', ' ')
334 | .mapNotNull { it.trim().toIntOrNull() }
335 | .take(3)
336 | return if (nums.size == 3)
337 | Triple(nums[0].coerceIn(0,255), nums[1].coerceIn(0,255), nums[2].coerceIn(0,255))
338 | else null
339 | }
340 |
341 | private fun isRgbDark(r: Int, g: Int, b: Int): Boolean {
342 | val lum = 0.299 * r + 0.587 * g + 0.114 * b
343 | return lum < 140.0
344 | }
345 |
346 | private fun deriveDarkFromColorsMap(colors: Map>): Boolean? {
347 | val sectionsInPriority = listOf("Colors:Panel", "Colors:Window", "Colors:View", "Colors:Button")
348 | for (sec in sectionsInPriority) {
349 | val bg = colors[sec]?.get("BackgroundNormal") ?: continue
350 | val rgb = parseKdeRgb(bg) ?: continue
351 | return isRgbDark(rgb.first, rgb.second, rgb.third)
352 | }
353 | // Generic fallback
354 | colors.values.forEach { section ->
355 | section["BackgroundNormal"]?.let { v ->
356 | parseKdeRgb(v)?.let { (r, g, b) -> return isRgbDark(r, g, b) }
357 | }
358 | }
359 | return null
360 | }
361 |
362 | private fun resolvePlasmaColorsFile(themeName: String, visited: MutableSet = mutableSetOf()): File? {
363 | val key = themeName.lowercase()
364 | if (!visited.add(key)) return null // prevent cycles
365 |
366 | val dir = plasmaThemeDirCandidates(themeName).firstOrNull { it.isDirectory && it.canRead() }
367 | val directColors = dir?.let { firstReadable("${it.path}/colors") }
368 | if (directColors != null) return directColors
369 |
370 | val meta = dir?.let { firstReadable("${it.path}/metadata.desktop") }
371 | if (meta != null) {
372 | val ini = parseIni(meta)
373 | val inherits = ini.values.firstNotNullOfOrNull { map ->
374 | map.entries.firstOrNull { (k, _) -> k.equals("Inherits", true) || k.equals("inherits", true) }?.value
375 | }
376 | if (!inherits.isNullOrBlank()) {
377 | inherits.split(',', ';')
378 | .map { it.trim() }
379 | .filter { it.isNotEmpty() }
380 | .forEach { parent ->
381 | resolvePlasmaColorsFile(parent, visited)?.let { return it }
382 | }
383 | }
384 | }
385 | return null
386 | }
387 |
388 | /* ---------- KDE dark detection, with safe fallbacks ---------- */
389 |
390 | private fun detectKdeDarkTheme(): Boolean? {
391 | if (detectLinuxDesktopEnvironment() != LinuxDesktopEnvironment.KDE) return null
392 | return try {
393 | val scheme = runKReadConfig(file = "kdeglobals", group = "General", key = "ColorScheme") ?: return null
394 | val home = System.getenv("HOME") ?: System.getProperty("user.home")
395 | val variants = listOf(
396 | scheme, scheme.replace(' ', '_'), scheme.replace(' ', '-'),
397 | scheme.lowercase(), scheme.lowercase().replace(' ', '_'), scheme.lowercase().replace(' ', '-')
398 | ).distinct()
399 | val schemeFile = variants
400 | .flatMap { v -> listOf("$home/.local/share/color-schemes/$v.colors", "/usr/share/color-schemes/$v.colors") }
401 | .map(::File).firstOrNull { it.isFile && it.canRead() } ?: return null
402 | val colors = parseIni(schemeFile)
403 | deriveDarkFromColorsMap(colors)
404 | } catch (_: Exception) {
405 | try {
406 | val laf = runKReadConfig(file = "kdeglobals", group = "KDE", key = "LookAndFeelPackage")
407 | laf?.contains("dark", ignoreCase = true)
408 | } catch (_: Exception) {
409 | null
410 | }
411 | }
412 | }
413 |
414 | private fun detectKdePanelDark(): Boolean? {
415 | if (detectLinuxDesktopEnvironment() != LinuxDesktopEnvironment.KDE) return null
416 | return try {
417 | val rawTheme = runKReadConfig("plasmarc", "Theme", "name")
418 | ?: runKReadConfig("plasmashellrc", "Theme", "name")
419 | val lookAndFeel = runKReadConfig("kdeglobals", "KDE", "LookAndFeelPackage")
420 | val theme = normalizePlasmaThemeName(rawTheme, lookAndFeel) ?: return null
421 |
422 | val colorsFile = resolvePlasmaColorsFile(theme)
423 | if (colorsFile != null) {
424 | val colors = parseIni(colorsFile)
425 | val derived = deriveDarkFromColorsMap(colors)
426 | if (derived != null) return derived
427 | }
428 |
429 | // Last resort: global hints
430 | val scheme = runKReadConfig("kdeglobals", "General", "ColorScheme")
431 | when {
432 | scheme?.contains("dark", true) == true -> true
433 | scheme?.contains("light", true) == true -> false
434 | lookAndFeel?.contains("dark", true) == true -> true
435 | lookAndFeel?.contains("light", true) == true -> false
436 | else -> null
437 | }
438 | } catch (_: Exception) {
439 | null
440 | }
441 | }
442 |
--------------------------------------------------------------------------------