Files
amethyst/docs/plans/2026-03-30-feat-desktop-tor-support-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:35:33 +03:00

50 KiB

title, type, status, date, deepened, origin
title type status date deepened origin
feat: Desktop Tor Support feat active 2026-03-30 2026-03-30 docs/brainstorms/2026-03-30-desktop-tor-support-brainstorm.md

feat: Desktop Tor Support

Enhancement Summary

Deepened on: 2026-03-30 Agents used: KMP patterns, Coroutines, Desktop expert, Gradle, Security sentinel, Architecture strategist, Performance oracle, Code simplicity, Pattern recognition

Key Improvements from Deepening

  1. Security: 4 must-fix findings (DNS leak in ProxiedSocketFactory, port 9050 fallback, NEWNYM missing, logging leaks)
  2. Architecture: Split TorRelayState into evaluator (commonMain) + client provider (jvmAndroid); make ITorManager reactive not imperative; add IOkHttpClientFactory interface
  3. Source set corrections: ProxiedSocketFactory → jvmAndroid (uses java.net); IRoleBasedHttpClientBuilder → jvmAndroid (returns OkHttpClient); TorRelayState → jvmAndroid
  4. Performance: Stagger relay reconnections (200ms + jitter); share ConnectionPool across OkHttpClient rebuilds; desktop timeout 2x not 3x
  5. Desktop: Use OS-specific data dirs (not ~/.amethyst/); add Tor shutdown to existing shutdown hook; sidebar indicator in both layout modes
  6. Pattern fixes: Remove "expect" from interfaces (plain interfaces); preference keys use underscores; data object for stateless sealed variants

Simplicity Reviewer Note

The simplicity reviewer recommended presets-only for v1 (~400 LOC vs ~1200), deferring granular toggles. This conflicts with the brainstorm decision for full granular routing. Decision: keep full granular as chosen, but the presets-only fallback is documented if scope needs cutting.


Overview

Add embedded Tor support to Amethyst Desktop with full granular routing parity with Android. Extract Android's platform-agnostic Tor logic to commons/, add kmp-tor (Apache 2.0) as desktop Tor runtime, build desktop settings UI with quick toggle + "Advanced..." dialog.

Problem Statement / Motivation

Desktop has zero Tor/proxy awareness. DesktopHttpClient returns the same OkHttpClient for all URLs. Users connecting to .onion relays or wanting privacy routing have no options. Android has a production-grade Tor implementation that's mostly platform-agnostic but trapped in the amethyst/ module.

Proposed Solution

Extract-first approach (see brainstorm: docs/brainstorms/2026-03-30-desktop-tor-support-brainstorm.md):

  1. Extract platform-agnostic Tor logic from amethyst/commons/commonMain/ and commons/jvmAndroid/
  2. Create interfaces for Tor daemon management and settings persistence (NOT expect/actual — implementations have fundamentally different constructors)
  3. Android impl: keep tor-android + jtorctl
  4. Desktop impl: kmp-tor + kmp-tor-resource (Apache 2.0, resource-exec-tor)
  5. Build desktop settings UI (quick toggle + full dialog)
  6. Wire desktop relay connections through proxy-aware client manager

Technical Approach

Architecture

commons/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/
├── TorType.kt                    # Enum: OFF, INTERNAL, EXTERNAL (no R.string)
├── TorSettings.kt                # Data class + presets
├── TorSettingsFlow.kt            # MutableStateFlow per setting
├── TorPresetType.kt              # Preset enum + definitions
├── TorServiceStatus.kt           # Sealed: Off, Connecting, Active(port), Error(msg)
├── TorRelaySettings.kt           # Relay-specific subset
├── TorRelayEvaluation.kt         # Per-relay routing logic (pure function)
├── TorRelayEvaluator.kt          # Flow: combines settings + relay lists → evaluation
├── ITorManager.kt                # Interface: reactive status flow
└── ITorSettingsPersistence.kt    # Interface: load/save/observe

commons/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/
├── okhttp/
│   ├── IOkHttpClientFactory.kt   # Interface: buildHttpClient(proxy, timeout)
│   ├── DualHttpClientManager.kt  # Proxy + clear clients (takes IOkHttpClientFactory)
│   ├── IHttpClientManager.kt     # Interface
│   └── OkHttpWebSocket.kt        # Proxy-aware WebSocket
├── privacyOptions/
│   ├── IRoleBasedHttpClientBuilder.kt  # Interface (returns OkHttpClient)
│   ├── RoleBasedHttpClientBuilder.kt   # Per-content-type routing
│   ├── ProxiedSocketFactory.kt         # SOCKS socket factory (java.net)
│   └── TorRelayHttpClientProvider.kt   # Composes evaluator + IHttpClientManager
└── tor/
    └── TorRelayState.kt          # Combines evaluator + client manager

amethyst/src/main/java/.../
├── tor/
│   ├── AndroidTorManager.kt      # Impl: tor-android + jtorctl (reactive, settings-driven)
│   └── TorSharedPreferences.kt   # Impl: Android DataStore persistence
└── okhttp/
    ├── AndroidOkHttpClientFactory.kt   # Impl: IOkHttpClientFactory + EncryptedBlobInterceptor
    └── OkHttpClientFactoryForRelays.kt # Android-specific (Build.FINGERPRINT check)

desktopApp/src/jvmMain/.../
├── tor/
│   ├── DesktopTorManager.kt      # Impl: kmp-tor TorRuntime (reactive, settings-driven)
│   └── DesktopTorPreferences.kt  # Impl: java.util.prefs.Preferences
├── network/
│   ├── DesktopOkHttpClientFactory.kt   # Impl: IOkHttpClientFactory (no encrypted blob)
│   ├── DesktopHttpClient.kt            # REFACTORED: DualHttpClientManager + TorRelayState
│   └── DesktopRelayConnectionManager.kt # Wire torStatusFlow → staggered relay reconnection
└── ui/tor/
    ├── TorSettingsSection.kt      # Inline quick toggle in settings screen
    └── TorSettingsDialog.kt       # Full dialog: presets + per-content-type toggles

Research Insights: Source Set Placement

Corrections from KMP skill review:

Component Original Plan Corrected Why
ProxiedSocketFactory commonMain jvmAndroid Uses java.net.Socket, javax.net.SocketFactory — JVM-only
IRoleBasedHttpClientBuilder commonMain jvmAndroid Returns OkHttpClient — JVM-only type
TorRelayState commonMain jvmAndroid Depends on DualHttpClientManager/OkHttp
ITorManager expect interface plain interface Different constructors per platform; inject, not expect/actual
ITorSettingsPersistence expect interface plain interface Same reason

New: Split TorRelayState (architecture strategist recommendation):

  • TorRelayEvaluatorcommonMain — pure flow combination: settings + relay lists → TorRelayEvaluation. Fully testable without OkHttp.
  • TorRelayHttpClientProviderjvmAndroid — thin wrapper composing evaluator + IHttpClientManager to return correct OkHttpClient per relay.

Implementation Phases

Phase 1: Extract Shared Models & Logic + Security Fixes

Move platform-agnostic code from amethyst/commons/commonMain/:

File (source) Destination Changes Needed
ui/tor/TorSettings.kt commons/.../tor/TorSettings.kt Strip R.string from TorType + TorPresetType. Use string keys, map to resources via when at platform UI layer
ui/tor/TorSettingsFlow.kt commons/.../tor/TorSettingsFlow.kt @Stable is fine (Compose MP in commons). Add distinctUntilChanged() after combine
ui/tor/TorServiceStatus.kt commons/.../tor/TorServiceStatus.kt Remove TorControlConnection from Active. Add data class Error(val message: String). Use data object Off, data object Connecting
model/torState/TorRelaySettings.kt commons/.../tor/TorRelaySettings.kt No changes
model/torState/TorRelayEvaluation.kt commons/.../tor/TorRelayEvaluation.kt No changes — pure logic using NormalizedRelayUrl from quartz
model/torState/TorRelayState.kt SplitTorRelayEvaluator (commonMain) + TorRelayHttpClientProvider (jvmAndroid) Separate pure routing logic from OkHttpClient selection

Move JVM-shared code from amethyst/commons/jvmAndroid/:

File (source) Destination Changes Needed
service/okhttp/DualHttpClientManager.kt commons/.../okhttp/DualHttpClientManager.kt Take IOkHttpClientFactory interface instead of concrete factory. Remove isMobileDataProvider (optional param, desktop passes null). Share a single ConnectionPool across rebuilt clients; evict old pool on rebuild
service/okhttp/IHttpClientManager.kt commons/.../okhttp/IHttpClientManager.kt No changes
service/okhttp/OkHttpWebSocket.kt commons/.../okhttp/OkHttpWebSocket.kt No changes
model/privacyOptions/IRoleBasedHttpClientBuilder.kt commons/.../privacyOptions/IRoleBasedHttpClientBuilder.kt No changes
model/privacyOptions/RoleBasedHttpClientBuilder.kt commons/.../privacyOptions/RoleBasedHttpClientBuilder.kt Use IHttpClientManager interface, not concrete DualHttpClientManager
model/privacyOptions/ProxiedSocketFactory.kt commons/.../privacyOptions/ProxiedSocketFactory.kt SECURITY FIX: Replace InetSocketAddress(host, port) with InetSocketAddress.createUnresolved(host, port) to prevent DNS leak

Create new interfaces in commons/commonMain/:

// commons/.../tor/ITorManager.kt
// REACTIVE pattern — matches Android's TorManager which derives status from settings
interface ITorManager {
    val status: StateFlow<TorServiceStatus>
    val activePortOrNull: StateFlow<Int?>
    suspend fun dormant()       // SIGNAL_DORMANT — reduce Tor activity
    suspend fun active()        // SIGNAL_ACTIVE — resume Tor activity
    suspend fun newIdentity()   // SIGNAL_NEWNYM — new Tor circuit
}

// commons/.../tor/ITorSettingsPersistence.kt
interface ITorSettingsPersistence {
    fun load(): TorSettings
    fun save(settings: TorSettings)
}

Create new interface in commons/jvmAndroid/:

// commons/.../okhttp/IOkHttpClientFactory.kt
interface IOkHttpClientFactory {
    fun buildHttpClient(proxy: Proxy?, timeoutSeconds: Long): OkHttpClient
    fun buildLocalSocksProxy(port: Int?): Proxy?  // Returns NULL when port is null (not 9050 fallback!)
}

SECURITY FIX: buildLocalSocksProxy(null) must return null — not fall back to port 9050. The current Android code falls back to DEFAULT_SOCKS_PORT when port is null, which can route through an uncontrolled system Tor daemon during bootstrap.

Gradle: Add OkHttp to commons/jvmAndroid deps:

// commons/build.gradle.kts — jvmAndroid source set
dependencies {
    implementation(libs.okhttp)
    implementation(libs.okhttpCoroutines)
}

Unaddressed gap: AccountsTorStateConnector — aggregates DM/trusted relay lists from all accounts for TorRelayEvaluator. Desktop needs equivalent wiring. Either extract (if AccountCacheState is in commons) or create desktop-specific connector.

Success criteria: ./gradlew :commons:compileKotlinJvm and ./gradlew :amethyst:compileDebugKotlin both pass. All existing Tor tests green. ProxiedSocketFactory DNS leak fixed.


Phase 2: Desktop Tor Daemon (kmp-tor)

Gradle dependencies:

# gradle/libs.versions.toml — [versions]
kmpTorRuntime = "2.6.0"
kmpTorResource = "409.5.0"

# gradle/libs.versions.toml — [libraries]
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" }
// desktopApp/build.gradle.kts
dependencies {
    implementation(libs.kmp.tor.runtime)
    implementation(libs.kmp.tor.resource.exec.tor)
}

nativeDistributions {
    modules("java.management")  // required by kmp-tor
}

DesktopTorManager — reactive, settings-driven (matches Android pattern):

class DesktopTorManager(
    private val settingsFlow: TorSettingsFlow,
    private val scope: CoroutineScope,  // App-level scope
) : ITorManager {
    private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
    override val status: StateFlow<TorServiceStatus> = _status
    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()) {
            // Suppress relay/circuit details in logs — SECURITY: prevent hostname leaks
            observerStatic(RuntimeEvent.LISTENERS, OnEvent { listeners ->
                val port = listeners.socks.firstOrNull()?.port
                _status.value = if (port != null) TorServiceStatus.Active(port)
                               else TorServiceStatus.Off
            })
            config { TorOption.__SocksPort.configure { auto() } }
        }
    }

    init {
        // REACTIVE: auto-derive lifecycle from settings (matches Android TorManager pattern)
        scope.launch {
            settingsFlow.torType.distinctUntilChanged().transformLatest { mode ->
                when (mode) {
                    TorType.INTERNAL -> {
                        emit(TorServiceStatus.Connecting)
                        try {
                            runtime.startDaemonAsync()
                            emitAll(runtime.status)  // Keep observing
                        } catch (e: CancellationException) {
                            withContext(NonCancellable) { runtime.stopDaemonAsync() }
                            throw e
                        } catch (e: Exception) {
                            emit(TorServiceStatus.Error(e.message ?: "Unknown"))
                        }
                    }
                    TorType.EXTERNAL -> {
                        emit(TorServiceStatus.Active(settingsFlow.externalSocksPort.value))
                    }
                    TorType.OFF -> {
                        withContext(NonCancellable) { runtime.stopDaemonAsync() }
                        emit(TorServiceStatus.Off)
                    }
                }
            }.collect { _status.value = it }
        }
    }

    override suspend fun newIdentity() {
        runtime.executeAsync(TorCmd.Signal.NewNym)
    }
    override suspend fun dormant() { runtime.executeAsync(TorCmd.Signal.Dormant) }
    override suspend fun active() { runtime.executeAsync(TorCmd.Signal.Active) }
}

Desktop environment — OS-specific data dirs (not ~/.amethyst/):

private fun desktopEnvironment(): TorRuntime.Environment {
    val appDir = when {
        System.getProperty("os.name").contains("Mac", true) ->
            File(System.getProperty("user.home"), "Library/Application Support/Amethyst/tor")
        System.getProperty("os.name").contains("Win", true) ->
            File(System.getenv("APPDATA"), "Amethyst/tor")
        else -> // Linux
            File(System.getenv("XDG_DATA_HOME")
                ?: "${System.getProperty("user.home")}/.local/share", "Amethyst/tor")
    }
    // SECURITY: Set directory permissions to 700
    appDir.mkdirs()
    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,
    ) {}
}

Shutdown hook — add to existing hook in Main.kt:

Runtime.getRuntime().addShutdownHook(Thread {
    GlobalMediaPlayer.shutdown()
    VlcjPlayerPool.shutdown()
    desktopTorManager.stopSync()  // Must stop before process exits
})

Success criteria: ./gradlew :desktopApp:run, enable Internal Tor, see Active(port) in logs. Also test ./gradlew :desktopApp:packageDmg early to catch notarization issues.


Phase 3: Desktop Network Plumbing + Relay Reconnection

Create DesktopOkHttpClientFactory implementing IOkHttpClientFactory:

class DesktopOkHttpClientFactory : IOkHttpClientFactory {
    private val sharedConnectionPool = ConnectionPool()  // Shared across rebuilds

    override fun buildHttpClient(proxy: Proxy?, timeoutSeconds: Long): OkHttpClient {
        val multiplier = if (proxy != null) 2 else 1  // Desktop: 2x not 3x
        return OkHttpClient.Builder()
            .proxy(proxy)
            .connectionPool(sharedConnectionPool)
            .connectTimeout(timeoutSeconds * multiplier, TimeUnit.SECONDS)
            .readTimeout(timeoutSeconds * multiplier, TimeUnit.SECONDS)
            .writeTimeout(timeoutSeconds * multiplier, TimeUnit.SECONDS)
            .build()
    }

    override fun buildLocalSocksProxy(port: Int?): Proxy? {
        if (port == null) return null  // NO fallback to 9050!
        return Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))
    }
}

Refactor DesktopHttpClient from static singleton:

class DesktopTorAwareHttpClient(
    private val evaluator: TorRelayEvaluator,       // Pure routing decisions
    private val clientManager: DualHttpClientManager, // Single manager (no separate relay one)
    private val settingsFlow: TorSettingsFlow,
) : IRoleBasedHttpClientBuilder {
    // Desktop uses ONE DualHttpClientManager for everything (no emulator check needed)
    // Delegates per-content-type decisions to RoleBasedHttpClientBuilder
}

Staggered relay reconnection (prevents thundering herd):

// In DesktopRelayConnectionManager
torManager.status
    .distinctUntilChanged()
    .debounce(100)  // Match Android RelayProxyClientConnector pattern
    .flatMapLatest { status ->
        when (status) {
            is TorServiceStatus.Active -> flow {
                // Stagger reconnections: 200ms between each relay + jitter
                relays.forEachIndexed { index, relay ->
                    delay(index * 200L + Random.nextLong(0, 100))
                    relay.reconnectIfProxyChanged()
                }
                emit(Unit)
            }
            else -> emptyFlow()
        }
    }

Behavior during Tor bootstrap (Connecting state):

  • Clearnet relays: connect immediately (no proxy needed)
  • .onion relays: queue via torManager.status.first { it is Active } before connecting
  • Tor-routed clearnet relays (per settings): queue until Active
  • No clearnet fallback — connections that should use Tor must wait, never leak to clearnet

Success criteria: Enable Tor → relays reconnect through SOCKS (staggered) → disable Tor → relays reconnect direct.


Phase 4: Settings Persistence (Desktop)

DesktopTorPreferences — matches existing DesktopPreferences pattern:

object DesktopTorPreferences : ITorSettingsPersistence {
    private val prefs = Preferences.userNodeForPackage(DesktopTorPreferences::class.java)

    override fun load(): TorSettings = TorSettings(
        torType = TorType.valueOf(prefs.get("tor_type", TorType.OFF.name)),
        externalSocksPort = prefs.getInt("tor_external_port", 9050),
        onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true),
        // ... all fields with underscore-separated keys
    )

    override fun save(settings: TorSettings) {
        prefs.put("tor_type", settings.torType.name)
        prefs.putInt("tor_external_port", settings.externalSocksPort)
        // ... all fields
        // No explicit flush() — match existing DesktopPreferences pattern (JVM auto-flushes on shutdown)
    }
}

Default for desktop: TorType.OFF (user must opt-in).

Per-device settings (not synced across accounts or devices).

Success criteria: Change Tor settings → restart app → settings restored.


Phase 5: Desktop Settings UI

Quick toggle in existing settings screen (insert between Media Server and Dev Settings sections):

// TorSettingsSection.kt — inline in settings, matching existing section pattern
@Composable
fun TorSettingsSection(torManager: ITorManager, settingsFlow: TorSettingsFlow) {
    Row {
        TorStatusIcon(torManager.status.collectAsState().value)
        Text("Tor")
        Switch(checked = settingsFlow.torType.value != TorType.OFF, onCheckedChange = { ... })
        TextButton("Advanced...") { showDialog = true }
    }
    if (showDialog) {
        // Use androidx.compose.ui.window.Dialog (Desktop window-based, NOT AlertDialog)
        TorSettingsDialog(settingsFlow, onDismiss = { showDialog = false })
    }
}

Full dialog (TorSettingsDialog.kt) — same mockup as original plan.

Status indicator in sidebar footer — add in both DeckSidebar and SinglePaneLayout:

@Composable
fun TorStatusIndicator(status: TorServiceStatus) {
    val (icon, color, tooltip) = when (status) {
        is TorServiceStatus.Off -> Triple(Icons.Default.ShieldOutlined, Color.Gray, "Tor: Off")
        is TorServiceStatus.Connecting -> Triple(Icons.Default.Shield, Color.Yellow, "Tor: Connecting...")
        is TorServiceStatus.Active -> Triple(Icons.Default.Shield, Color.Green, "Tor: Connected")
        is TorServiceStatus.Error -> Triple(Icons.Default.ShieldOutlined, Color.Red, "Tor: ${status.message}")
    }
    // SECURITY: Don't show port number in tooltip (aids attacker with local access)
    TooltipArea(tooltip) { Icon(icon, tooltip, tint = color, modifier = Modifier.size(20.dp)) }
}

Place between BunkerHeartbeatIndicator and Settings button in sidebar footer.

Success criteria: Toggle Tor → see status change in sidebar → open dialog → change presets → save → relays reconnect.


Phase 6: Polish & Edge Cases (Deferrable)

  1. .onion relay warning — Show "Requires Tor" badge on .onion relays when Tor is OFF
  2. External proxy validation — Port range check (1-65535) in dialog
  3. External proxy warning — "Cannot verify this is a Tor proxy" UI text
  4. Tor bootstrap progress — If kmp-tor exposes it, show in status tooltip
  5. Auto-restartDesktopTorAutoRestart with exponential backoff (defer until crash telemetry exists)
  6. filterjar plugin — Strip non-host architectures from distribution (optimization, defer)
  7. .onion TLS — Custom HostnameVerifier for .onion addresses; disable SSL redirects for proxied connections

Security Findings (from Security Sentinel)

# Finding Severity Status
1 DNS leak in ProxiedSocketFactoryInetSocketAddress(host, port) resolves locally HIGH Fix in Phase 1: use createUnresolved()
2 No NEWNYM on desktop — same Tor circuits reused indefinitely MED-HIGH Fix in Phase 2: implement dormant()/active()/newIdentity() via kmp-tor TorCmd.Signal
3 Port 9050 fallbackbuildLocalSocksProxy(null) falls back to 9050 during bootstrap MEDIUM Fix in Phase 1: return null when port is null
4 Clearnet fallback during bootstrap — connections may leak before Tor is ready MEDIUM Fix in Phase 3: queue Tor-routed connections, never clearnet fallback
5 Logging leaks — relay hostnames, ports in logs/tooltips MEDIUM Fix in Phase 2/5: suppress kmp-tor verbose output, don't show port in UI
6 Tor binary file permissions~/.amethyst/tor/ may be world-readable LOW-MED Fix in Phase 2: chmod 700 on creation
7 SOCKS proxy open to local processes MEDIUM Accepted risk (matches Android). Document. Consider IsolateSOCKSAuth flag
8 Settings readable by other processes LOW-MED Accepted risk (same as Android DataStore)
9 External proxy trust — no health check LOW-MED UI warning in Phase 6
10 .onion TLS/redirect edge cases MEDIUM Phase 6: custom HostnameVerifier

Performance Considerations

Concern Mitigation
Thundering herd on Tor toggle (20-40 relays reconnecting) Stagger reconnections: 200ms between each relay + random jitter
OkHttpClient allocation on every state change Share single ConnectionPool across rebuilds; evict old pool explicitly
TorSettingsFlow combine overhead (13 flows, preset switch emits many) Add distinctUntilChanged() after combine; .debounce(100) downstream
Desktop timeout multiplier Use 2x (not Android's 3x) — desktop has stable connections
java.util.prefs.flush() on main thread Don't call flush() — match existing DesktopPreferences pattern
BasicOkHttpWebSocket.needsReconnect() doesn't detect proxy changes Use explicit disconnect+reconnect triggered by TorRelayState flow, not needsReconnect()
Channel(Channel.UNLIMITED) in BasicOkHttpWebSocket Pre-existing issue exacerbated by Tor's bursty delivery. Consider Channel(BUFFERED) later

System-Wide Impact

Interaction Graph

Settings change (UI)
  → TorSettingsFlow emits
  → DesktopTorPreferences.save() (no flush, JVM auto-flushes)
  → ITorManager reactive flow derives new TorServiceStatus
    → transformLatest: cancel previous mode, start new
    → NonCancellable cleanup for stop() during cancellation
  → DualHttpClientManager rebuilds OkHttpClients (shared ConnectionPool)
  → TorRelayEvaluator emits new TorRelayEvaluation
  → TorRelayHttpClientProvider selects correct client per relay
  → DesktopRelayConnectionManager staggered reconnect (200ms + jitter)
  → Sidebar TorStatusIndicator updates (both DeckSidebar + SinglePane)

Error Propagation

  • kmp-tor start failure → catch in transformLatestTorServiceStatus.Error(msg) → UI red shield
  • External port unreachable → connections timeout (no health check, matches Android)
  • Tor crash → status emits Off unexpectedly → auto-restart (Phase 6) or user clicks to retry
  • Orphaned process → shutdown hook calls stopSync(); NonCancellable cleanup in transformLatest

State Lifecycle Risks

  • Race: INTERNAL → EXTERNAL quicklytransformLatest cancels; withContext(NonCancellable) { runtime.stopDaemonAsync() } ensures cleanup
  • Orphaned Tor process → shutdown hook + NonCancellable stop in all cancellation paths
  • Settings save during bootstrapdistinctUntilChanged() + debounce(100) prevent redundant propagation

API Surface Parity

Interface Android impl Desktop impl
ITorManager AndroidTorManager (tor-android, reactive) DesktopTorManager (kmp-tor, reactive)
ITorSettingsPersistence TorSharedPreferences (DataStore) DesktopTorPreferences (java.util.prefs, object)
IOkHttpClientFactory AndroidOkHttpClientFactory (+EncryptedBlob) DesktopOkHttpClientFactory (plain)
IHttpClientManager DualHttpClientManager (shared) Same
IRoleBasedHttpClientBuilder RoleBasedHttpClientBuilder (shared) Same

Acceptance Criteria

Functional Requirements

  • Internal Tor: start/stop embedded Tor daemon via kmp-tor
  • External Tor: connect to user-provided SOCKS port
  • All 4 presets work (ONLY_WHEN_NEEDED, DEFAULT, SMALL_PAYLOADS, FULL_PRIVACY)
  • All per-content-type toggles work (images, video, NIP-05, money, uploads, previews, profile pics)
  • Per-relay routing: .onion always via Tor, DM/trusted/new configurable
  • Settings persist across app restarts (java.util.prefs)
  • Relay reconnection on Tor state changes (staggered, not thundering herd)
  • Status indicator in sidebar (Off/Connecting/Active/Error) — both layout modes
  • Quick toggle + "Advanced..." dialog in settings
  • .onion relays show "Requires Tor" when Tor is OFF
  • Android continues working with tor-android (no regressions)
  • NEWNYM / dormant / active signals work on desktop

Non-Functional Requirements

  • No DNS leaks — ProxiedSocketFactory uses createUnresolved(), OkHttp SOCKS5 safe by default
  • No clearnet fallback — Tor-routed connections queue during bootstrap, never leak
  • No port 9050 fallback — buildLocalSocksProxy(null) returns null
  • Tor data directory permissions 700
  • No relay hostnames or ports in logs/tooltips
  • Tor bootstrap < 30s typical
  • No orphaned Tor processes on app exit (shutdown hook)

Testing Strategy

Framework: kotlin.test (JUnit 4 runner) + mockk + kotlinx-coroutines-test. Add com.squareup.okhttp3:mockwebserver:5.3.2 to test deps.

Gradle Test Dependency Additions

# gradle/libs.versions.toml — [libraries]
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
# commons/build.gradle.kts  jvmAndroid test deps
val jvmAndroidTest = create("jvmAndroidTest") {
    dependsOn(commonTest.get())
    dependencies {
        implementation(libs.okhttp.mockwebserver)
    }
}

# desktopApp/build.gradle.kts
testImplementation(libs.okhttp.mockwebserver)

Unit Tests (commons/commonTest) — Pure Logic

File: commons/src/commonTest/.../tor/TorRelayEvaluationTest.kt

class TorRelayEvaluationTest {
    // --- .onion routing ---
    @Test fun onionRelay_torOff_returnsFalse()
    @Test fun onionRelay_torInternal_onionEnabled_returnsTrue()
    @Test fun onionRelay_torInternal_onionDisabled_returnsFalse()
    @Test fun onionRelay_torExternal_onionEnabled_returnsTrue()

    // --- localhost bypass ---
    @Test fun localhost_alwaysReturnsFalse()
    @Test fun localIp127_alwaysReturnsFalse()
    @Test fun localIp192168_alwaysReturnsFalse()

    // --- DM relay routing ---
    @Test fun dmRelay_dmViaTorEnabled_returnsTrue()
    @Test fun dmRelay_dmViaTorDisabled_returnsFalse()
    @Test fun dmRelay_torOff_returnsFalse()

    // --- Trusted relay routing ---
    @Test fun trustedRelay_trustedViaTorEnabled_returnsTrue()
    @Test fun trustedRelay_trustedViaTorDisabled_returnsFalse()

    // --- New/unknown relay routing ---
    @Test fun unknownRelay_newViaTorEnabled_returnsTrue()
    @Test fun unknownRelay_newViaTorDisabled_returnsFalse()

    // --- Edge cases ---
    @Test fun relayInBothDmAndTrusted_dmTakesPrecedence()
    @Test fun emptyRelayLists_allRelaysAreNew()
}

File: commons/src/commonTest/.../tor/TorSettingsTest.kt

class TorSettingsTest {
    // --- Presets ---
    @Test fun onlyWhenNeeded_onlyOnionEnabled()
    @Test fun default_onionAndDmAndNewEnabled()
    @Test fun smallPayloads_addsPreviewsNip05Money()
    @Test fun fullPrivacy_allEnabled()
    @Test fun custom_detectedWhenNoPresetMatches()

    // --- Preset detection ---
    @Test fun whichPreset_matchesOnlyWhenNeeded()
    @Test fun whichPreset_matchesDefault()
    @Test fun whichPreset_matchesFullPrivacy()
    @Test fun whichPreset_returnsCustomForMixedSettings()

    // --- TorType ---
    @Test fun torType_parsesOff()
    @Test fun torType_parsesInternal()
    @Test fun torType_parsesExternal()
    @Test fun torType_unknownDefaultsToOff()  // Desktop default

    // --- Data class equality ---
    @Test fun settings_equalsWorksForDistinctUntilChanged()
    @Test fun settings_copyPreservesAllFields()
}

File: commons/src/commonTest/.../tor/TorServiceStatusTest.kt

class TorServiceStatusTest {
    @Test fun active_hasPort()
    @Test fun error_hasMessage()
    @Test fun off_isSingleton()
    @Test fun connecting_isSingleton()
    @Test fun sealed_exhaustiveWhen()  // All variants covered
}

Unit Tests (commons/jvmAndroidTest) — OkHttp Logic

File: commons/src/jvmAndroidTest/.../okhttp/IOkHttpClientFactoryTest.kt

class IOkHttpClientFactoryTest {
    // --- SOCKS proxy creation ---
    @Test fun buildLocalSocksProxy_withPort_returnsSocks5Proxy() {
        val proxy = factory.buildLocalSocksProxy(9050)
        assertNotNull(proxy)
        assertEquals(Proxy.Type.SOCKS, proxy!!.type())
        val addr = proxy.address() as InetSocketAddress
        assertEquals("127.0.0.1", addr.hostString)
        assertEquals(9050, addr.port)
    }

    @Test fun buildLocalSocksProxy_nullPort_returnsNull() {
        // SECURITY: must NOT fall back to 9050
        val proxy = factory.buildLocalSocksProxy(null)
        assertNull(proxy)
    }

    // --- Client configuration ---
    @Test fun buildHttpClient_withProxy_hasDoubledTimeouts()
    @Test fun buildHttpClient_withoutProxy_hasBaseTimeouts()
    @Test fun buildHttpClient_sharesConnectionPool()
}

File: commons/src/jvmAndroidTest/.../okhttp/DualHttpClientManagerTest.kt

class DualHttpClientManagerTest {
    @Test fun proxyPort_null_returnsClientWithoutProxy()
    @Test fun proxyPort_9050_returnsClientWithSocksProxy()
    @Test fun proxyPort_changes_rebuildsClient()
    @Test fun getHttpClient_useProxyTrue_returnsProxiedClient()
    @Test fun getHttpClient_useProxyFalse_returnsDirectClient()
    @Test fun oldConnectionPool_evictedOnRebuild()
}

File: commons/src/jvmAndroidTest/.../privacyOptions/ProxiedSocketFactoryTest.kt

class ProxiedSocketFactoryTest {
    // SECURITY: DNS leak prevention
    @Test fun createSocket_usesUnresolvedAddress() {
        // Verify InetSocketAddress.createUnresolved is used
        // by checking the address is NOT resolved
        val factory = ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS,
            InetSocketAddress("127.0.0.1", 9050)))
        // Can't easily test without a SOCKS proxy, but verify the
        // code path uses createUnresolved via code inspection or
        // by mocking InetSocketAddress creation
    }
}

File: commons/src/jvmAndroidTest/.../privacyOptions/RoleBasedHttpClientBuilderTest.kt

class RoleBasedHttpClientBuilderTest {
    // --- Content-type routing ---
    @Test fun image_torOff_returnsDirect()
    @Test fun image_imagesViaTorTrue_returnsProxied()
    @Test fun image_imagesViaTorFalse_returnsDirect()
    @Test fun video_videosViaTorTrue_returnsProxied()
    @Test fun nip05_nip05ViaTorTrue_returnsProxied()
    @Test fun money_moneyViaTorTrue_returnsProxied()
    @Test fun upload_uploadsViaTorTrue_returnsProxied()
    @Test fun preview_previewsViaTorTrue_returnsProxied()

    // --- URL-based overrides ---
    @Test fun onionUrl_alwaysReturnsProxied()
    @Test fun localhostUrl_alwaysReturnsDirect()
    @Test fun onionUrl_torOff_returnsDirect()  // Can't proxy without Tor
}

Unit Tests (desktopApp/jvmTest) — Desktop-Specific

File: desktopApp/src/jvmTest/.../tor/DesktopTorPreferencesTest.kt

class DesktopTorPreferencesTest {
    @BeforeTest fun setup() { /* clear test prefs */ }
    @AfterTest fun teardown() { /* clear test prefs */ }

    @Test fun save_andLoad_roundTrips()
    @Test fun load_noSavedData_returnsDefaults()
    @Test fun load_defaultTorType_isOFF()  // Desktop must default to OFF
    @Test fun save_allFields_persisted()
    @Test fun save_presetSettings_matchesExpected()
}

File: desktopApp/src/jvmTest/.../network/DesktopOkHttpClientFactoryTest.kt

class DesktopOkHttpClientFactoryTest {
    @Test fun timeoutMultiplier_withProxy_is2x()  // Desktop: 2x, not 3x
    @Test fun timeoutMultiplier_withoutProxy_is1x()
    @Test fun sharedConnectionPool_acrossRebuilds()
    @Test fun buildLocalSocksProxy_null_returnsNull()
}

File: desktopApp/src/jvmTest/.../tor/DesktopTorManagerTest.kt

class DesktopTorManagerTest {
    // Uses FakeTorRuntime or mockk

    @Test fun initialStatus_isOff()
    @Test fun settingsChangeToInternal_statusGoesToConnecting()
    @Test fun settingsChangeToOff_stopsRuntime()
    @Test fun settingsChangeToExternal_emitsActiveWithPort()
    @Test fun runtimeStartFails_emitsError()
    @Test fun quickToggle_internalToExternal_cancelsAndCleanup() {
        // Verify NonCancellable stop is called during cancellation
    }
    @Test fun quickToggle_internalToOff_stopsCleanly()
    @Test fun newIdentity_callsTorCmdSignalNewNym()
    @Test fun dormant_callsTorCmdSignalDormant()
    @Test fun active_callsTorCmdSignalActive()
}

Integration Tests (desktopApp/jvmTest) — Real Tor

Requires: System Tor running (brew install tor && brew services start tor) or kmp-tor embedded bootstrap.

File: desktopApp/src/jvmTest/.../tor/TorRoutingVerificationTest.kt

Tag with @Tag("integration") to exclude from CI unless Tor is available.

@Tag("integration")
class TorRoutingVerificationTest {
    companion object {
        private const val SOCKS_PORT = 9050  // System Tor default
        private const val TIMEOUT = 60L      // Tor is slow
    }

    private val torClient = OkHttpClient.Builder()
        .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", SOCKS_PORT)))
        .connectTimeout(TIMEOUT, TimeUnit.SECONDS)
        .readTimeout(TIMEOUT, TimeUnit.SECONDS)
        .dns(object : Dns {
            override fun lookup(hostname: String): List<InetAddress> {
                throw IllegalStateException("DNS LEAK: local resolution of $hostname")
            }
        })
        .build()

    @Test fun torProjectApi_confirmsTorRouting() {
        val response = torClient.newCall(
            Request.Builder().url("https://check.torproject.org/api/ip").build()
        ).execute()
        val body = response.body?.string() ?: fail("Empty response")
        assertTrue(body.contains("\"IsTor\":true"), "Not routing through Tor: $body")
    }

    @Test fun ipAddress_differsFromRealIp() {
        val directClient = OkHttpClient()
        val realIp = directClient.newCall(
            Request.Builder().url("https://icanhazip.com").build()
        ).execute().body?.string()?.trim()

        val torIp = torClient.newCall(
            Request.Builder().url("https://icanhazip.com").build()
        ).execute().body?.string()?.trim()

        assertNotEquals(realIp, torIp, "IPs match — not using Tor")
    }

    @Test fun dnsGuard_throwsOnLocalResolution() {
        // Verify the custom Dns override catches leaks
        assertFailsWith<IllegalStateException> {
            // Force a connection through a non-existent proxy to trigger DNS path
            val leakyClient = OkHttpClient.Builder()
                .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 1)))
                .dns(object : Dns {
                    override fun lookup(hostname: String): List<InetAddress> {
                        throw IllegalStateException("DNS LEAK: $hostname")
                    }
                })
                .build()
            leakyClient.newCall(
                Request.Builder().url("http://example.com").build()
            ).execute()
        }
    }

    @Test fun webSocketConnect_throughTor() {
        val connected = CountDownLatch(1)
        torClient.newWebSocket(
            Request.Builder().url("wss://relay.damus.io").build(),
            object : WebSocketListener() {
                override fun onOpen(webSocket: WebSocket, response: Response) {
                    connected.countDown()
                    webSocket.close(1000, "test")
                }
                override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                    connected.countDown()  // Relay may be down; connection attempt is what matters
                }
            }
        )
        assertTrue(connected.await(TIMEOUT, TimeUnit.SECONDS), "WebSocket timed out")
    }

    @Test fun onionAddress_resolvesViaTor() {
        // Test that .onion addresses work through SOCKS (DNS goes through Tor)
        // Use a known stable .onion service
        val response = torClient.newCall(
            Request.Builder()
                .url("http://2gzyxa5ihm7nsber2ezqbaga4dawcxirrj3hp2z5i2czsvierqdgrsyd.onion/")  // Tor Project .onion
                .build()
        ).execute()
        assertTrue(response.isSuccessful || response.code == 301, "Failed to reach .onion: ${response.code}")
    }
}

File: desktopApp/src/jvmTest/.../tor/TorSettingsIntegrationTest.kt

@Tag("integration")
class TorSettingsIntegrationTest {
    @Test fun presetChange_rebuildsClientWithCorrectProxy() {
        // Start with OFF → verify no proxy
        // Switch to DEFAULT → verify SOCKS proxy on port
        // Switch back to OFF → verify no proxy
    }

    @Test fun relayReconnection_staggeredOnTorToggle() {
        // Enable Tor → measure delay between relay reconnections
        // Verify ~200ms gap between each
    }

    @Test fun settingsPersistence_surviveRestart() {
        // Save FULL_PRIVACY preset
        // Create new DesktopTorPreferences instance
        // Load and verify all fields match
    }
}

Security Tests (desktopApp/jvmTest)

File: desktopApp/src/jvmTest/.../tor/TorSecurityTest.kt

class TorSecurityTest {
    @Test fun proxiedSocketFactory_usesUnresolvedAddress() {
        // Verify ProxiedSocketFactory uses InetSocketAddress.createUnresolved
        // This is the DNS leak fix verification
    }

    @Test fun buildLocalSocksProxy_nullPort_noFallbackTo9050() {
        val factory = DesktopOkHttpClientFactory()
        val proxy = factory.buildLocalSocksProxy(null)
        assertNull(proxy, "Must return null, not fall back to port 9050")
    }

    @Test fun torDataDirectory_hasRestrictedPermissions() {
        val torDir = File(/* OS-specific path */)
        if (torDir.exists()) {
            // Unix: verify 700 permissions
            assertTrue(torDir.canRead())
            assertTrue(torDir.canWrite())
            assertTrue(torDir.canExecute())
            // Note: Java File API can't check other-user permissions directly
            // Use ProcessBuilder("stat", "-f", "%Lp", torDir.path) on macOS/Linux
        }
    }

    @Test fun statusTooltip_doesNotContainPort() {
        val status = TorServiceStatus.Active(9050)
        // Verify UI text generation doesn't include port
        val tooltip = when (status) {
            is TorServiceStatus.Active -> "Tor: Connected"
            else -> ""
        }
        assertFalse(tooltip.contains("9050"), "Port number leaked in tooltip")
    }

    @Test fun torType_defaultsToOff_onDesktop() {
        val settings = DesktopTorPreferences.load()
        assertEquals(TorType.OFF, settings.torType)
    }
}

Manual Testing Plan

Pre-requisites

# Install monitoring tools
brew install wireshark  # macOS GUI
brew install tor        # System Tor for external mode testing

# Or on Linux:
sudo apt install wireshark-qt tor tcpdump

Test 1: Verify Internal Tor Routing

1. Start app: ./gradlew :desktopApp:run
2. Open terminal: sudo tcpdump -i any port 53 -n  (monitor DNS)
3. Open terminal: sudo tcpdump -i en0 '!host 127.0.0.1' -n  (monitor non-local)
4. In app: Settings → Tor → Enable Internal
5. Wait for sidebar shield to turn green
6. Verify in tcpdump:
   - DNS monitor: NO queries after Tor enabled ✓
   - Non-local monitor: Only Tor guard node traffic (port 9001/443) ✓
7. Open relay list — verify relays show "connected" status
8. Send a test note
9. Verify note appears on other clients

Test 2: Verify No DNS Leaks

1. Start Wireshark, filter: dns
2. Start app with Internal Tor enabled
3. Connect to 5+ relays
4. Browse profiles (triggers NIP-05 lookups)
5. Load images (triggers image URL fetches)
6. Verify Wireshark shows ZERO DNS queries from the Java process
   - Some DNS may come from other processes — filter by port/PID
   - On macOS: use `nettop -p <PID> -m tcp` to correlate

Test 3: Verify Relay Reconnection on Toggle

1. Start app, connect to relays (Tor OFF)
2. Note relay connection count in relay list
3. Enable Internal Tor
4. Watch relay list:
   - Relays should disconnect and reconnect (staggered, ~200ms apart)
   - .onion relays should now show connected
5. Disable Tor
6. Watch relay list:
   - Relays reconnect directly
   - .onion relays show "Requires Tor" or disconnect

Test 4: Verify External Tor Mode

1. Start system Tor: brew services start tor  (listens on 9050)
2. In app: Settings → Tor → External → Port: 9050
3. Sidebar shield turns green immediately
4. Verify routing: curl --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip
5. Verify app traffic flows through same Tor instance

Test 5: Verify Preset Behavior

For each preset (ONLY_WHEN_NEEDED, DEFAULT, SMALL_PAYLOADS, FULL_PRIVACY):
1. Select preset in Advanced dialog
2. Verify toggles match expected state
3. Test traffic routing:
   - ONLY_WHEN_NEEDED: only .onion relays via Tor
   - DEFAULT: .onion + DM + new relays via Tor
   - SMALL_PAYLOADS: + previews, NIP-05, money
   - FULL_PRIVACY: everything via Tor
4. Verify with tcpdump that non-Tor traffic goes direct

Test 6: Nuclear Leak Test (macOS pf / Linux iptables)

# Block ALL non-Tor traffic from Java process
# macOS (pf.conf):
echo '
block drop out on en0 proto tcp from any to any
pass out on en0 proto tcp from any to any port {9001, 443}  # Tor guard
pass out on lo0
' | sudo pfctl -ef -

# Start app with Tor enabled
# If ANY feature fails (images, relays, notes) that should go through Tor,
# it means traffic was leaking to clearnet

# CLEANUP:
sudo pfctl -d

Test 7: Packaging Verification

# Build packaged app
./gradlew :desktopApp:packageDmg

# Install from DMG
# Verify kmp-tor extracts and runs Tor binary
# Verify macOS Gatekeeper doesn't block
# Run Test 1-3 from the packaged app (not Gradle run)

Test Execution Commands

# All unit tests (fast, no Tor needed)
./gradlew :commons:jvmTest :desktopApp:jvmTest

# Specific test suites
./gradlew :commons:jvmTest --tests "*.tor.*"
./gradlew :commons:jvmTest --tests "*.okhttp.*"
./gradlew :commons:jvmTest --tests "*.privacyOptions.*"
./gradlew :desktopApp:jvmTest --tests "*.tor.*"
./gradlew :desktopApp:jvmTest --tests "*.network.*"

# Integration tests (requires Tor running)
./gradlew :desktopApp:jvmTest --tests "*.TorRoutingVerificationTest" -Pinclude-integration

# Android regression tests
./gradlew :amethyst:test
./gradlew :amethyst:compileDebugKotlin

# Full quality gate
./gradlew spotlessApply && ./gradlew :commons:jvmTest :desktopApp:jvmTest :amethyst:test

Quality Gates

  • ./gradlew :commons:jvmTest — all Tor logic unit tests green
  • ./gradlew :desktopApp:jvmTest — all desktop Tor unit tests green
  • ./gradlew :amethyst:test — no Android regressions
  • ./gradlew :amethyst:compileDebugKotlin — Android builds clean
  • ./gradlew :desktopApp:compileKotlin — desktop builds clean
  • ./gradlew :desktopApp:packageDmg — packaging works (Phase 2)
  • ./gradlew spotlessApply — formatting clean
  • Integration: TorRoutingVerificationTest passes with system Tor
  • Manual Test 1: Internal Tor routing verified via tcpdump
  • Manual Test 2: Zero DNS leaks in Wireshark
  • Manual Test 3: Relay reconnection staggered correctly
  • Manual Test 6: Nuclear leak test passes (pf/iptables block)

Dependencies & Risks

Risk Mitigation
kmp-tor doesn't work in packaged DMG Test early in Phase 2 with ./gradlew :desktopApp:packageDmg
macOS Hardened Runtime blocks Tor subprocess Test with com.apple.security.cs.disable-library-validation entitlement
kmp-tor + tor-android Gradle conflict Separate modules (desktopApp vs amethyst), no transitive bleed
Extraction breaks Android Run Android compile + tests after each extraction step
R.string removal from enums Use string keys in commons, when mapping at platform UI layer
OkHttpClientFactory has Android deps (EncryptionKeyCache) Create IOkHttpClientFactory interface; Android adds interceptors, desktop doesn't
AccountsTorStateConnector not in plan Desktop needs relay list aggregation — create desktop equivalent or extract if AccountCacheState is shared
EncryptedBlobInterceptor/DefaultContentTypeInterceptor Stay in Android impl of IOkHttpClientFactory; desktop factory skips them

Resolved Open Questions

Question Resolution
License conflict? No — Apache 2.0 variant of kmp-tor-resource (non-GPL Tor binary)
Per-device or per-account? Per-device (java.util.prefs, object singleton)
Android library change? No — Android keeps tor-android/jtorctl
DNS leaks? Safe for OkHttp SOCKS5. FIX NEEDED for ProxiedSocketFactory
Default TorType on desktop? OFF — user must opt-in
Behavior during bootstrap? Clearnet=connect, .onion=queue, Tor-routed=queue. No clearnet fallback
Port 9050 fallback? RemovedbuildLocalSocksProxy(null) returns null
Interface vs expect/actual? Interfaces — different constructors, injected per platform
Reactive vs imperative ITorManager? Reactive — settings-driven via transformLatest, matching Android
OS data directory? OS-specific~/Library/Application Support/Amethyst/tor/ (macOS), ~/.local/share/Amethyst/tor/ (Linux), %APPDATA%/Amethyst/tor/ (Windows)

Sources & References

Origin

Internal References

  • Android Tor implementation: amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/
  • OkHttp factories: amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/
  • Privacy options: amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/
  • Desktop network: desktopApp/src/jvmMain/.../network/
  • Desktop preferences pattern: desktopApp/src/jvmMain/.../DesktopPreferences.kt
  • Existing expect/actual: commons/src/commonMain/.../SecureKeyStorage.kt
  • Sidebar footer: desktopApp/src/jvmMain/.../ui/deck/DeckSidebar.kt (BunkerHeartbeatIndicator pattern)
  • Shutdown hook: desktopApp/src/jvmMain/.../Main.kt:165-171

External References