diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000000..6c91c3144d --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,17 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { `kotlin-dsl` } + +group = "com.github.kr328.clash.buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } } + +dependencies { + implementation("com.android.tools.build:gradle:${libs.versions.agp.get()}") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") +} diff --git a/build-logic/convention/src/main/kotlin/tabby.kmp.library.gradle.kts b/build-logic/convention/src/main/kotlin/tabby.kmp.library.gradle.kts new file mode 100644 index 0000000000..558c0d6025 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/tabby.kmp.library.gradle.kts @@ -0,0 +1,40 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + applyDefaultHierarchyTemplate() + + android { + namespace = "com.github.kr328.clash.${project.path.removePrefix(":").replace(':', '.')}" + compileSdk = 37 + minSdk = 28 + withHostTest {} + compilerOptions { jvmTarget = JvmTarget.JVM_21 } + } + + jvm { compilerOptions { jvmTarget = JvmTarget.JVM_21 } } + + js { + browser() + nodejs() + } + + iosX64() + iosArm64() + iosSimulatorArm64() + macosArm64() + linuxX64() + mingwX64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + sourceSets { commonTest.dependencies { implementation(kotlin("test")) } } +} + +tasks.register("testClasses") { dependsOn("allTests") } diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000000..06cc056700 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + google() + mavenCentral() + } + versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } +} + +rootProject.name = "tabby-build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts index d2738bdf18..4f4a58a473 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,8 +8,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 0000000000..195d1cbcd4 --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.tabby.kmp.library) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + sourceSets { + commonMain.dependencies { implementation(libs.kotlin.serialization.json) } + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/AccessControlMode.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/AccessControlMode.kt new file mode 100644 index 0000000000..57838e1953 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/AccessControlMode.kt @@ -0,0 +1,7 @@ +package com.github.kr328.clash.core.model + +enum class AccessControlMode { + AcceptAll, + AcceptSelected, + DenySelected, +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ConfigurationOverride.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ConfigurationOverride.kt new file mode 100644 index 0000000000..3310257821 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ConfigurationOverride.kt @@ -0,0 +1,122 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +data class ConfigurationOverride( + @SerialName("port") val httpPort: Int? = null, + @SerialName("socks-port") val socksPort: Int? = null, + @SerialName("redir-port") val redirectPort: Int? = null, + @SerialName("tproxy-port") val tproxyPort: Int? = null, + @SerialName("mixed-port") val mixedPort: Int? = null, + val authentication: List? = null, + @SerialName("allow-lan") val allowLan: Boolean? = null, + @SerialName("bind-address") val bindAddress: String? = null, + val mode: TunnelState.Mode? = null, + @SerialName("log-level") val logLevel: LogMessage.Level? = null, + val ipv6: Boolean? = null, + @SerialName("external-controller") val externalController: String? = null, + @SerialName("external-controller-tls") val externalControllerTLS: String? = null, + @SerialName("external-controller-cors") + val externalControllerCors: ExternalControllerCors = ExternalControllerCors(), + val secret: String? = null, + val hosts: Map? = null, + @SerialName("unified-delay") val unifiedDelay: Boolean? = null, + @SerialName("geodata-mode") val geodataMode: Boolean? = null, + @SerialName("tcp-concurrent") val tcpConcurrent: Boolean? = null, + @SerialName("find-process-mode") val findProcessMode: FindProcessMode? = null, + val dns: Dns = Dns(), + @SerialName("clash-for-android") val app: App = App(), + val sniffer: Sniffer = Sniffer(), + @SerialName("geox-url") val geoxurl: GeoXUrl = GeoXUrl(), + @Transient val revision: Int = 0, +) { + @Serializable + data class Dns( + val enable: Boolean? = null, + @SerialName("prefer-h3") val preferH3: Boolean? = null, + val listen: String? = null, + val ipv6: Boolean? = null, + @SerialName("use-hosts") val useHosts: Boolean? = null, + @SerialName("enhanced-mode") val enhancedMode: DnsEnhancedMode? = null, + @SerialName("nameserver") val nameServer: List? = null, + val fallback: List? = null, + @SerialName("default-nameserver") val defaultServer: List? = null, + @SerialName("fake-ip-filter") val fakeIpFilter: List? = null, + @SerialName("fake-ip-filter-mode") val fakeIPFilterMode: FilterMode? = null, + @SerialName("fallback-filter") val fallbackFilter: DnsFallbackFilter = DnsFallbackFilter(), + @SerialName("nameserver-policy") val nameserverPolicy: Map? = null, + ) + + @Serializable + data class DnsFallbackFilter( + @SerialName("geoip") val geoIp: Boolean? = null, + @SerialName("geoip-code") val geoIpCode: String? = null, + val ipcidr: List? = null, + val domain: List? = null, + ) + + @Serializable + data class App(@SerialName("append-system-dns") val appendSystemDns: Boolean? = null) + + @Serializable + enum class FindProcessMode { + @SerialName("off") Off, + @SerialName("strict") Strict, + @SerialName("always") Always, + } + + @Serializable + enum class DnsEnhancedMode { + @SerialName("normal") None, + @SerialName("redir-host") Mapping, + @SerialName("fake-ip") FakeIp, + } + + @Serializable + enum class FilterMode { + @SerialName("blacklist") BlackList, + @SerialName("whitelist") WhiteList, + } + + @Serializable + data class Sniffer( + val enable: Boolean? = null, + val sniff: Sniff = Sniff(), + @SerialName("force-dns-mapping") val forceDnsMapping: Boolean? = null, + @SerialName("parse-pure-ip") val parsePureIp: Boolean? = null, + @SerialName("override-destination") val overrideDestination: Boolean? = null, + @SerialName("force-domain") val forceDomain: List? = null, + @SerialName("skip-domain") val skipDomain: List? = null, + @SerialName("skip-src-address") val skipSrcAddress: List? = null, + @SerialName("skip-dst-address") val skipDstAddress: List? = null, + ) + + @Serializable + data class GeoXUrl( + val geoip: String? = null, + val mmdb: String? = null, + val geosite: String? = null, + ) + + @Serializable + data class ExternalControllerCors( + @SerialName("allow-origins") val allowOrigins: List? = null, + @SerialName("allow-private-network") val allowPrivateNetwork: Boolean? = null, + ) + + @Serializable + data class Sniff( + @SerialName("HTTP") val http: ProtocolConfig = ProtocolConfig(), + @SerialName("TLS") val tls: ProtocolConfig = ProtocolConfig(), + @SerialName("QUIC") val quic: ProtocolConfig = ProtocolConfig(), + ) + + @Serializable + data class ProtocolConfig( + val ports: List? = null, + @SerialName("override-destination") val overrideDestination: Boolean? = null, + ) +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/DarkMode.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/DarkMode.kt new file mode 100644 index 0000000000..4a976c8cdb --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/DarkMode.kt @@ -0,0 +1,7 @@ +package com.github.kr328.clash.core.model + +enum class DarkMode { + Auto, + ForceLight, + ForceDark, +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/FetchStatus.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/FetchStatus.kt new file mode 100644 index 0000000000..4d96a07d89 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/FetchStatus.kt @@ -0,0 +1,17 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class FetchStatus( + val action: Action, + val args: List, + val progress: Int, + val max: Int, +) { + enum class Action { + FetchConfiguration, + FetchProviders, + Verifying, + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/LogMessage.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/LogMessage.kt new file mode 100644 index 0000000000..492a583695 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/LogMessage.kt @@ -0,0 +1,17 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LogMessage(val level: Level, val message: String, val timeMillis: Long) { + @Serializable + enum class Level { + @SerialName("debug") Debug, + @SerialName("info") Info, + @SerialName("warning") Warning, + @SerialName("error") Error, + @SerialName("silent") Silent, + @SerialName("unknown") Unknown, + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Profile.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Profile.kt new file mode 100644 index 0000000000..a52bbf8f28 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Profile.kt @@ -0,0 +1,30 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package com.github.kr328.clash.core.model + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.serialization.Serializable + +@Serializable +data class Profile( + val uuid: Uuid, + val name: String, + val type: Type, + val source: String, + val active: Boolean, + val interval: Long, + val upload: Long, + val download: Long, + val total: Long, + val expire: Long, + val updatedAt: Long, + val imported: Boolean, + val pending: Boolean, +) { + enum class Type { + File, + Url, + External, + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Provider.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Provider.kt new file mode 100644 index 0000000000..c2fa3aef69 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Provider.kt @@ -0,0 +1,27 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Provider( + val name: String, + val type: Type, + val vehicleType: VehicleType, + val updatedAt: Long, +) : Comparable { + enum class Type { + Proxy, + Rule, + } + + enum class VehicleType { + HTTP, + File, + Inline, + Compatible, + } + + override fun compareTo(other: Provider): Int { + return compareValuesBy(this, other, Provider::type, Provider::name) + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Proxy.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Proxy.kt new file mode 100644 index 0000000000..259f354307 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Proxy.kt @@ -0,0 +1,45 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Proxy( + val name: String, + val title: String, + val subtitle: String, + val type: Type, + val delay: Int, +) { + enum class Type(val group: Boolean) { + Direct(false), + Reject(false), + RejectDrop(false), + Compatible(false), + Pass(false), + Shadowsocks(false), + ShadowsocksR(false), + Snell(false), + Socks5(false), + Http(false), + Vmess(false), + Vless(false), + Trojan(false), + Hysteria(false), + Hysteria2(false), + Tuic(false), + WireGuard(false), + Dns(false), + Ssh(false), + Mieru(false), + AnyTLS(false), + Sudoku(false), + Masque(false), + TrustTunnel(false), + Relay(true), + Selector(true), + Fallback(true), + URLTest(true), + LoadBalance(true), + Unknown(false), + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxyGroup.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxyGroup.kt new file mode 100644 index 0000000000..e6e49e5880 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxyGroup.kt @@ -0,0 +1,5 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.Serializable + +@Serializable data class ProxyGroup(val type: Proxy.Type, val proxies: List, val now: String) diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxySort.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxySort.kt new file mode 100644 index 0000000000..28a7158e16 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/ProxySort.kt @@ -0,0 +1,7 @@ +package com.github.kr328.clash.core.model + +enum class ProxySort { + Default, + Title, + Delay, +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Traffic.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Traffic.kt new file mode 100644 index 0000000000..dcf8054de3 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/Traffic.kt @@ -0,0 +1,22 @@ +package com.github.kr328.clash.core.model + +data class Traffic(val packed: Long) { + val uploadScaled: Long + get() = unpackTrafficScaled(packed ushr 32) + + val downloadScaled: Long + get() = unpackTrafficScaled(packed and 0xFFFFFFFF) +} + +private fun unpackTrafficScaled(value: Long): Long { + val type = (value ushr 30) and 0x3 + val data = value and 0x3FFFFFFF + + return when (type) { + 0L -> data + 1L -> data * 1024 + 2L -> data * 1024 * 1024 + 3L -> data * 1024 * 1024 * 1024 + else -> throw IllegalArgumentException("invalid value type") + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/TunnelState.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/TunnelState.kt new file mode 100644 index 0000000000..cf73ff9f6d --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/TunnelState.kt @@ -0,0 +1,14 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TunnelState(val mode: Mode) { + @Serializable + enum class Mode { + @SerialName("direct") Direct, + @SerialName("global") Global, + @SerialName("rule") Rule, + } +} diff --git a/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/UiConfiguration.kt b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/UiConfiguration.kt new file mode 100644 index 0000000000..c015eccc22 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/github/kr328/clash/core/model/UiConfiguration.kt @@ -0,0 +1,5 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.Serializable + +@Serializable class UiConfiguration diff --git a/core/model/src/commonTest/kotlin/com/github/kr328/clash/core/model/CoreModelSerializationTest.kt b/core/model/src/commonTest/kotlin/com/github/kr328/clash/core/model/CoreModelSerializationTest.kt new file mode 100644 index 0000000000..961c412bf2 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/github/kr328/clash/core/model/CoreModelSerializationTest.kt @@ -0,0 +1,62 @@ +package com.github.kr328.clash.core.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.serialization.json.Json + +class CoreModelSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun proxyGroupSerializesProductStateWithoutAndroidTypes() { + val group = + ProxyGroup( + type = Proxy.Type.Selector, + proxies = + listOf( + Proxy( + name = "proxy-a", + title = "Proxy A", + subtitle = "100 ms", + type = Proxy.Type.Shadowsocks, + delay = 100, + ) + ), + now = "proxy-a", + ) + + val encoded = json.encodeToString(group) + val decoded = json.decodeFromString(encoded) + + assertEquals(group, decoded) + } + + @Test + fun configurationOverrideSerializesMihomoYamlShape() { + val override = + ConfigurationOverride( + httpPort = 7890, + mode = TunnelState.Mode.Rule, + dns = ConfigurationOverride.Dns(enable = true), + ) + + val encoded = json.encodeToString(override) + + assertEquals( + ConfigurationOverride( + httpPort = 7890, + mode = TunnelState.Mode.Rule, + dns = ConfigurationOverride.Dns(enable = true), + ), + json.decodeFromString(encoded), + ) + } + + @Test + fun trafficUnpacksNativePackedValueInCommonCode() { + val traffic = Traffic((1L shl 32) or 2L) + + assertEquals(1L, traffic.uploadScaled) + assertEquals(2L, traffic.downloadScaled) + } +} diff --git a/gradle.properties b/gradle.properties index dc0b39caae..7a71cd849e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,4 @@ org.gradle.configuration-cache.parallel=true org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx4g org.gradle.kotlin.dsl.allWarningsAsErrors=true org.gradle.tooling.parallel=true +kotlin.native.ignoreDisabledTargets=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 312260b5c9..9e2c30fd98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,11 +51,14 @@ ktfmt = "com.facebook:ktfmt:0.63" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = "com.google.devtools.ksp:2.3.9" golang = "io.github.goooler.golang:0.2.2" download = "de.undercouch.download:5.7.0" spotless = "com.diffplug.spotless:8.6.0" +tabby-kmp-library = { id = "tabby.kmp.library" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 089646de97..e0e212aeda 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,6 @@ pluginManagement { + includeBuild("build-logic") + repositories { maven("https://central.sonatype.com/repository/maven-snapshots/") { mavenContent { includeGroupAndSubgroups("io.github.goooler.golang") } @@ -49,6 +51,7 @@ enableFeaturePreview("STABLE_CONFIGURATION_CACHE") include( ":app", ":core", + ":core:model", ":service", ":common", ":glue",