diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0ea2cde8f..f413928a3 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -62,6 +62,10 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.collection) + // Tor daemon (desktop embedded via kmp-tor) + implementation(libs.kmp.tor.runtime) + implementation(libs.kmp.tor.resource.exec.tor) + // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps implementation(libs.slf4j.nop) @@ -85,6 +89,7 @@ compose.desktop { nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + modules("java.management") // Required by kmp-tor TorRuntime packageName = "Amethyst" packageVersion = "1.0.0" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt new file mode 100644 index 000000000..90dd4fd39 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.tor + +import com.vitorpamplona.amethyst.commons.tor.ITorManager +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.quartz.utils.Log +import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec +import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync +import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonAsync +import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonSync +import io.matthewnelson.kmp.tor.runtime.RuntimeEvent +import io.matthewnelson.kmp.tor.runtime.TorRuntime +import io.matthewnelson.kmp.tor.runtime.TorState +import io.matthewnelson.kmp.tor.runtime.core.OnEvent +import io.matthewnelson.kmp.tor.runtime.core.OnFailure +import io.matthewnelson.kmp.tor.runtime.core.OnSuccess +import io.matthewnelson.kmp.tor.runtime.core.TorEvent +import io.matthewnelson.kmp.tor.runtime.core.config.TorOption +import io.matthewnelson.kmp.tor.runtime.core.ctrl.TorCmd +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.io.File + +/** + * Desktop Tor daemon manager using kmp-tor. + * + * Reactive: automatically starts/stops Tor daemon based on [torTypeFlow] changes. + * Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user-provided SOCKS port) modes. + */ +class DesktopTorManager( + private val torTypeFlow: StateFlow, + private val externalPortFlow: StateFlow, + private val scope: CoroutineScope, +) : ITorManager { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + override val activePortOrNull: StateFlow = + _status + .map { (it as? TorServiceStatus.Active)?.port } + .stateIn(scope, SharingStarted.Eagerly, null) + + private val runtime: TorRuntime by lazy { + TorRuntime.Builder(desktopEnvironment()) { + observerStatic( + RuntimeEvent.LISTENERS, + OnEvent.Executor.Immediate, + OnEvent { listeners -> + val port = + listeners.socks + .firstOrNull() + ?.port + ?.value + if (port != null) { + _status.value = TorServiceStatus.Active(port) + } + }, + ) + + observerStatic( + RuntimeEvent.STATE, + OnEvent.Executor.Immediate, + OnEvent { state -> + val daemon = state.daemon + when { + daemon is TorState.Daemon.Off -> { + _status.value = TorServiceStatus.Off + } + + daemon is TorState.Daemon.Starting -> { + _status.value = TorServiceStatus.Connecting + } + + daemon is TorState.Daemon.Stopping -> { + _status.value = TorServiceStatus.Connecting + } + } + }, + ) + + // Suppress verbose logging to prevent relay hostname leaks + required(TorEvent.ERR) + required(TorEvent.WARN) + + config { _ -> + TorOption.__SocksPort.configure { auto() } + } + } + } + + init { + // Reactive: auto-derive Tor lifecycle from settings changes + scope.launch { + torTypeFlow.collect { mode -> + when (mode) { + TorType.INTERNAL -> { + _status.value = TorServiceStatus.Connecting + try { + runtime.startDaemonAsync() + // LISTENERS event will set Active(port) + } catch (e: Exception) { + Log.e("DesktopTorManager", "Failed to start Tor", e) + _status.value = TorServiceStatus.Error(e.message ?: "Unknown error") + } + } + + TorType.EXTERNAL -> { + _status.value = TorServiceStatus.Active(externalPortFlow.value) + } + + TorType.OFF -> { + try { + runtime.stopDaemonAsync() + } catch (_: Exception) { + // May not be running + } + _status.value = TorServiceStatus.Off + } + } + } + } + } + + override suspend fun dormant() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.Dormant, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + override suspend fun active() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.Active, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + override suspend fun newIdentity() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.NewNym, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + /** Call from shutdown hook to stop Tor synchronously. */ + fun stopSync() { + try { + runtime.stopDaemonSync() + } catch (_: Exception) { + // Best-effort + } + } + + companion object { + private fun desktopEnvironment(): TorRuntime.Environment { + val appDir = torDataDirectory() + appDir.mkdirs() + // Restrict permissions to owner only (700) + appDir.setReadable(false, false) + appDir.setReadable(true, true) + appDir.setWritable(false, false) + appDir.setWritable(true, true) + appDir.setExecutable(false, false) + appDir.setExecutable(true, true) + + return TorRuntime.Environment.Builder( + workDirectory = appDir.resolve("work"), + cacheDirectory = appDir.resolve("cache"), + loader = ResourceLoaderTorExec::getOrCreate, + ) {} + } + + /** OS-specific data directory for Tor. */ + internal fun torDataDirectory(): File { + val osName = System.getProperty("os.name", "").lowercase() + val home = System.getProperty("user.home") ?: "." + return when { + osName.contains("mac") -> { + File(home, "Library/Application Support/Amethyst/tor") + } + + osName.contains("win") -> { + File(System.getenv("APPDATA") ?: "$home/AppData/Roaming", "Amethyst/tor") + } + + else -> { + val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" + File(xdgData, "Amethyst/tor") + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt new file mode 100644 index 000000000..b08499bd1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.tor + +import com.vitorpamplona.amethyst.commons.tor.ITorSettingsPersistence +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import java.util.prefs.Preferences + +/** + * Desktop persistence for Tor settings using java.util.prefs.Preferences. + * + * Per-device settings (not synced across accounts). + * Default TorType is OFF — user must opt-in. + * No explicit flush() — matches existing DesktopPreferences pattern. + */ +object DesktopTorPreferences : ITorSettingsPersistence { + private val prefs = Preferences.userNodeForPackage(DesktopTorPreferences::class.java) + + override fun load(): TorSettings = + TorSettings( + torType = TorType.entries.firstOrNull { it.name == prefs.get("tor_type", TorType.OFF.name) } ?: TorType.OFF, + externalSocksPort = prefs.getInt("tor_external_port", 9050), + onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true), + dmRelaysViaTor = prefs.getBoolean("tor_dm_relays", false), + newRelaysViaTor = prefs.getBoolean("tor_new_relays", false), + trustedRelaysViaTor = prefs.getBoolean("tor_trusted_relays", false), + urlPreviewsViaTor = prefs.getBoolean("tor_url_previews", false), + profilePicsViaTor = prefs.getBoolean("tor_profile_pics", false), + imagesViaTor = prefs.getBoolean("tor_images", false), + videosViaTor = prefs.getBoolean("tor_videos", false), + moneyOperationsViaTor = prefs.getBoolean("tor_money", false), + nip05VerificationsViaTor = prefs.getBoolean("tor_nip05", false), + mediaUploadsViaTor = prefs.getBoolean("tor_media_uploads", false), + ) + + override fun save(settings: TorSettings) { + prefs.put("tor_type", settings.torType.name) + prefs.putInt("tor_external_port", settings.externalSocksPort) + prefs.putBoolean("tor_onion_relays", settings.onionRelaysViaTor) + prefs.putBoolean("tor_dm_relays", settings.dmRelaysViaTor) + prefs.putBoolean("tor_new_relays", settings.newRelaysViaTor) + prefs.putBoolean("tor_trusted_relays", settings.trustedRelaysViaTor) + prefs.putBoolean("tor_url_previews", settings.urlPreviewsViaTor) + prefs.putBoolean("tor_profile_pics", settings.profilePicsViaTor) + prefs.putBoolean("tor_images", settings.imagesViaTor) + prefs.putBoolean("tor_videos", settings.videosViaTor) + prefs.putBoolean("tor_money", settings.moneyOperationsViaTor) + prefs.putBoolean("tor_nip05", settings.nip05VerificationsViaTor) + prefs.putBoolean("tor_media_uploads", settings.mediaUploadsViaTor) + // No explicit flush() — JVM auto-flushes on shutdown (matches DesktopPreferences pattern) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a08ed679..f736855d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,9 @@ fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.2" javaKeyring = "1.0.4" +jtorctl = "0.4.5.7" +kmpTorRuntime = "2.6.0" +kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.3.20" @@ -144,6 +147,9 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } +jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } +kmp-tor-runtime = { group = "io.matthewnelson.kmp-tor", name = "runtime", version.ref = "kmpTorRuntime" } +kmp-tor-resource-exec-tor = { group = "io.matthewnelson.kmp-tor", name = "resource-exec-tor", version.ref = "kmpTorResource" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }