feat(tor): add kmp-tor desktop daemon + preferences

Phase 2: Desktop Tor runtime implementation.

DesktopTorManager:
- Implements ITorManager using kmp-tor (Apache 2.0)
- Reactive: auto-starts/stops Tor based on TorType flow
- Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user SOCKS port)
- NEWNYM/Dormant/Active signal support via TorCmd
- OS-specific data dirs (~/Library/Application Support, ~/.local/share, %APPDATA%)
- Directory permissions set to 700
- stopSync() for shutdown hook integration

DesktopTorPreferences:
- Implements ITorSettingsPersistence using java.util.prefs.Preferences
- Underscore-separated keys matching project convention
- Default TorType.OFF (user must opt-in)
- No explicit flush() (matches existing DesktopPreferences pattern)

Gradle:
- kmp-tor runtime 2.6.0 + resource-exec-tor 409.5.0 in version catalog
- java.management module added to nativeDistributions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-31 17:35:34 +03:00
parent c564d4532b
commit 7cad30b899
4 changed files with 298 additions and 0 deletions
@@ -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<TorType>,
private val externalPortFlow: StateFlow<Int>,
private val scope: CoroutineScope,
) : ITorManager {
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val activePortOrNull: StateFlow<Int?> =
_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")
}
}
}
}
}
@@ -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)
}
}