├── 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 | --------------------------------------------------------------------------------