Merge pull request #2381 from nrobi144/feat/tor-support
feat: Desktop Tor support — embedded kmp-tor, full privacy routing, settings UI
This commit is contained in:
@@ -26,6 +26,7 @@ import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
@@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
|
||||
+2
-2
@@ -26,9 +26,9 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
+1
-1
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
+2
-27
@@ -20,30 +20,5 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
|
||||
class TorRelayEvaluation(
|
||||
val torSettings: TorRelaySettings,
|
||||
val trustedRelayList: Set<NormalizedRelayUrl>,
|
||||
val dmRelayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
fun useTor(relay: NormalizedRelayUrl): Boolean =
|
||||
if (torSettings.torType == TorType.OFF) {
|
||||
false
|
||||
} else {
|
||||
if (relay.isLocalHost()) {
|
||||
false
|
||||
} else if (relay.isOnion()) {
|
||||
torSettings.onionRelaysViaTor
|
||||
} else if (relay in dmRelayList) {
|
||||
torSettings.dmRelaysViaTor
|
||||
} else if (relay in trustedRelayList) {
|
||||
torSettings.trustedRelaysViaTor
|
||||
} else {
|
||||
torSettings.newRelaysViaTor
|
||||
}
|
||||
}
|
||||
}
|
||||
// Canonical type now lives in commons
|
||||
typealias TorRelayEvaluation = com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
|
||||
+2
-9
@@ -20,12 +20,5 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
|
||||
data class TorRelaySettings(
|
||||
val torType: TorType = TorType.OFF,
|
||||
val onionRelaysViaTor: Boolean = true,
|
||||
val dmRelaysViaTor: Boolean = false,
|
||||
val newRelaysViaTor: Boolean = false,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
)
|
||||
// Canonical type now lives in commons
|
||||
typealias TorRelaySettings = com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
+1
-1
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
@@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
|
||||
|
||||
+1
-1
@@ -34,11 +34,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
+1
-1
@@ -35,12 +35,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
@@ -24,6 +24,14 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
|
||||
import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.whichPreset
|
||||
|
||||
@Stable
|
||||
class TorDialogViewModel : ViewModel() {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -21,138 +21,40 @@
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
|
||||
data class TorSettings(
|
||||
val torType: TorType = TorType.INTERNAL,
|
||||
val externalSocksPort: Int = 9050,
|
||||
val onionRelaysViaTor: Boolean = true,
|
||||
val dmRelaysViaTor: Boolean = true,
|
||||
val newRelaysViaTor: Boolean = true,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val urlPreviewsViaTor: Boolean = false,
|
||||
val profilePicsViaTor: Boolean = false,
|
||||
val imagesViaTor: Boolean = false,
|
||||
val videosViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
val nip05VerificationsViaTor: Boolean = false,
|
||||
val mediaUploadsViaTor: Boolean = false,
|
||||
)
|
||||
// Re-export shared types so existing Android imports continue to work
|
||||
// The canonical types now live in commons/commonMain
|
||||
@Suppress("unused")
|
||||
private const val RE_EXPORTS = 0
|
||||
|
||||
enum class TorType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
OFF(0, R.string.tor_off),
|
||||
INTERNAL(1, R.string.tor_internal),
|
||||
EXTERNAL(2, R.string.tor_external),
|
||||
}
|
||||
// Android-specific resource ID mappings for TorType
|
||||
val TorType.resourceId: Int
|
||||
get() =
|
||||
when (this) {
|
||||
TorType.OFF -> R.string.tor_off
|
||||
TorType.INTERNAL -> R.string.tor_internal
|
||||
TorType.EXTERNAL -> R.string.tor_external
|
||||
}
|
||||
|
||||
fun parseTorType(code: Int?): TorType =
|
||||
when (code) {
|
||||
TorType.OFF.screenCode -> TorType.OFF
|
||||
TorType.INTERNAL.screenCode -> TorType.INTERNAL
|
||||
TorType.EXTERNAL.screenCode -> TorType.EXTERNAL
|
||||
else -> TorType.INTERNAL
|
||||
}
|
||||
// Android-specific resource ID mappings for TorPresetType
|
||||
val TorPresetType.resourceId: Int
|
||||
get() =
|
||||
when (this) {
|
||||
TorPresetType.ONLY_WHEN_NEEDED -> R.string.tor_when_needed
|
||||
TorPresetType.DEFAULT -> R.string.tor_default
|
||||
TorPresetType.SMALL_PAYLOADS -> R.string.tor_small_payloads
|
||||
TorPresetType.FULL_PRIVACY -> R.string.tor_full_privacy
|
||||
TorPresetType.CUSTOM -> R.string.tor_custom
|
||||
}
|
||||
|
||||
enum class TorPresetType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
val explainerId: Int,
|
||||
) {
|
||||
ONLY_WHEN_NEEDED(0, R.string.tor_when_needed, R.string.tor_when_needed_explainer),
|
||||
DEFAULT(1, R.string.tor_default, R.string.tor_default_explainer),
|
||||
SMALL_PAYLOADS(2, R.string.tor_small_payloads, R.string.tor_small_payloads_explainer),
|
||||
FULL_PRIVACY(3, R.string.tor_full_privacy, R.string.tor_full_privacy_explainer),
|
||||
CUSTOM(4, R.string.tor_custom, R.string.tor_custom_explainer),
|
||||
}
|
||||
|
||||
fun parseTorPresetType(code: Int?): TorPresetType =
|
||||
when (code) {
|
||||
TorPresetType.ONLY_WHEN_NEEDED.screenCode -> TorPresetType.ONLY_WHEN_NEEDED
|
||||
TorPresetType.DEFAULT.screenCode -> TorPresetType.DEFAULT
|
||||
TorPresetType.SMALL_PAYLOADS.screenCode -> TorPresetType.SMALL_PAYLOADS
|
||||
TorPresetType.FULL_PRIVACY.screenCode -> TorPresetType.FULL_PRIVACY
|
||||
else -> TorPresetType.CUSTOM
|
||||
}
|
||||
|
||||
fun isPreset(
|
||||
torSettings: TorSettings,
|
||||
preset: TorSettings,
|
||||
): Boolean =
|
||||
torSettings.onionRelaysViaTor == preset.onionRelaysViaTor &&
|
||||
torSettings.dmRelaysViaTor == preset.dmRelaysViaTor &&
|
||||
torSettings.newRelaysViaTor == preset.newRelaysViaTor &&
|
||||
torSettings.trustedRelaysViaTor == preset.trustedRelaysViaTor &&
|
||||
torSettings.urlPreviewsViaTor == preset.urlPreviewsViaTor &&
|
||||
// torSettings.profilePicsViaTor == preset.profilePicsViaTor &&
|
||||
torSettings.imagesViaTor == preset.imagesViaTor &&
|
||||
torSettings.videosViaTor == preset.videosViaTor &&
|
||||
torSettings.moneyOperationsViaTor == preset.moneyOperationsViaTor &&
|
||||
torSettings.nip05VerificationsViaTor == preset.nip05VerificationsViaTor &&
|
||||
torSettings.mediaUploadsViaTor == preset.mediaUploadsViaTor
|
||||
|
||||
fun whichPreset(torSettings: TorSettings): TorPresetType {
|
||||
if (isPreset(torSettings, torOnlyWhenNeededPreset)) return TorPresetType.ONLY_WHEN_NEEDED
|
||||
if (isPreset(torSettings, torDefaultPreset)) return TorPresetType.DEFAULT
|
||||
if (isPreset(torSettings, torSmallPayloadsPreset)) return TorPresetType.SMALL_PAYLOADS
|
||||
if (isPreset(torSettings, torFullyPrivate)) return TorPresetType.FULL_PRIVACY
|
||||
return TorPresetType.CUSTOM
|
||||
}
|
||||
|
||||
val torOnlyWhenNeededPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = false,
|
||||
newRelaysViaTor = false,
|
||||
trustedRelaysViaTor = false,
|
||||
urlPreviewsViaTor = false,
|
||||
profilePicsViaTor = false,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = false,
|
||||
nip05VerificationsViaTor = false,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torDefaultPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = false,
|
||||
urlPreviewsViaTor = false,
|
||||
profilePicsViaTor = false,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = false,
|
||||
nip05VerificationsViaTor = false,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torSmallPayloadsPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = true,
|
||||
urlPreviewsViaTor = true,
|
||||
profilePicsViaTor = true,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = true,
|
||||
nip05VerificationsViaTor = true,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torFullyPrivate =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = true,
|
||||
urlPreviewsViaTor = true,
|
||||
profilePicsViaTor = true,
|
||||
imagesViaTor = true,
|
||||
videosViaTor = true,
|
||||
moneyOperationsViaTor = true,
|
||||
nip05VerificationsViaTor = true,
|
||||
mediaUploadsViaTor = true,
|
||||
)
|
||||
val TorPresetType.explainerId: Int
|
||||
get() =
|
||||
when (this) {
|
||||
TorPresetType.ONLY_WHEN_NEEDED -> R.string.tor_when_needed_explainer
|
||||
TorPresetType.DEFAULT -> R.string.tor_default_explainer
|
||||
TorPresetType.SMALL_PAYLOADS -> R.string.tor_small_payloads_explainer
|
||||
TorPresetType.FULL_PRIVACY -> R.string.tor_full_privacy_explainer
|
||||
TorPresetType.CUSTOM -> R.string.tor_custom_explainer
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.parseTorType
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TorRelayEvaluationTest {
|
||||
// Helper relay URLs
|
||||
private val clearnetRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
private val onionRelay = NormalizedRelayUrl("wss://abc123.onion/")
|
||||
private val localhostRelay = NormalizedRelayUrl("ws://127.0.0.1:8080/")
|
||||
private val localhostNameRelay = NormalizedRelayUrl("ws://localhost:8080/")
|
||||
private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/")
|
||||
private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/")
|
||||
private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/")
|
||||
|
||||
private fun buildEvaluation(
|
||||
torType: TorType = TorType.INTERNAL,
|
||||
onionViaTor: Boolean = true,
|
||||
dmViaTor: Boolean = true,
|
||||
newViaTor: Boolean = true,
|
||||
trustedViaTor: Boolean = false,
|
||||
dmRelays: Set<NormalizedRelayUrl> = setOf(dmRelay),
|
||||
trustedRelays: Set<NormalizedRelayUrl> = setOf(trustedRelay),
|
||||
) = TorRelayEvaluation(
|
||||
torSettings =
|
||||
TorRelaySettings(
|
||||
torType = torType,
|
||||
onionRelaysViaTor = onionViaTor,
|
||||
dmRelaysViaTor = dmViaTor,
|
||||
newRelaysViaTor = newViaTor,
|
||||
trustedRelaysViaTor = trustedViaTor,
|
||||
),
|
||||
trustedRelayList = trustedRelays,
|
||||
dmRelayList = dmRelays,
|
||||
)
|
||||
|
||||
// --- Tor OFF: always false ---
|
||||
|
||||
@Test
|
||||
fun torOff_clearnetRelay_returnsFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.OFF)
|
||||
assertFalse(eval.useTor(clearnetRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOff_onionRelay_returnsFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.OFF)
|
||||
assertFalse(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOff_dmRelay_returnsFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.OFF)
|
||||
assertFalse(eval.useTor(dmRelay))
|
||||
}
|
||||
|
||||
// --- Localhost: always false regardless of Tor ---
|
||||
|
||||
@Test
|
||||
fun localhost127_alwaysFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.INTERNAL)
|
||||
assertFalse(eval.useTor(localhostRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localhostName_alwaysFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.INTERNAL)
|
||||
assertFalse(eval.useTor(localhostNameRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localNetwork192_alwaysFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.INTERNAL)
|
||||
assertFalse(eval.useTor(localNetworkRelay))
|
||||
}
|
||||
|
||||
// --- .onion relays ---
|
||||
|
||||
@Test
|
||||
fun onionRelay_torInternal_onionEnabled_returnsTrue() {
|
||||
val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = true)
|
||||
assertTrue(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onionRelay_torInternal_onionDisabled_returnsFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = false)
|
||||
assertFalse(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onionRelay_torExternal_onionEnabled_returnsTrue() {
|
||||
val eval = buildEvaluation(torType = TorType.EXTERNAL, onionViaTor = true)
|
||||
assertTrue(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
// --- DM relays ---
|
||||
|
||||
@Test
|
||||
fun dmRelay_dmViaTorEnabled_returnsTrue() {
|
||||
val eval = buildEvaluation(dmViaTor = true)
|
||||
assertTrue(eval.useTor(dmRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dmRelay_dmViaTorDisabled_returnsFalse() {
|
||||
val eval = buildEvaluation(dmViaTor = false)
|
||||
assertFalse(eval.useTor(dmRelay))
|
||||
}
|
||||
|
||||
// --- Trusted relays ---
|
||||
|
||||
@Test
|
||||
fun trustedRelay_trustedViaTorEnabled_returnsTrue() {
|
||||
val eval = buildEvaluation(trustedViaTor = true)
|
||||
assertTrue(eval.useTor(trustedRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedRelay_trustedViaTorDisabled_returnsFalse() {
|
||||
val eval = buildEvaluation(trustedViaTor = false)
|
||||
assertFalse(eval.useTor(trustedRelay))
|
||||
}
|
||||
|
||||
// --- New/unknown relays ---
|
||||
|
||||
@Test
|
||||
fun unknownRelay_newViaTorEnabled_returnsTrue() {
|
||||
val eval = buildEvaluation(newViaTor = true)
|
||||
assertTrue(eval.useTor(clearnetRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRelay_newViaTorDisabled_returnsFalse() {
|
||||
val eval = buildEvaluation(newViaTor = false)
|
||||
assertFalse(eval.useTor(clearnetRelay))
|
||||
}
|
||||
|
||||
// --- Priority: .onion > DM > trusted > new ---
|
||||
|
||||
@Test
|
||||
fun onionRelay_inDmList_treatedAsOnionNotDm() {
|
||||
// If a relay is both .onion AND in DM list, .onion takes precedence
|
||||
val onionDmRelay = NormalizedRelayUrl("wss://dmrelay.onion/")
|
||||
val eval =
|
||||
buildEvaluation(
|
||||
onionViaTor = false,
|
||||
dmViaTor = true,
|
||||
dmRelays = setOf(onionDmRelay),
|
||||
)
|
||||
// onion check happens first, and it's disabled → false
|
||||
assertFalse(eval.useTor(onionDmRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relay_inBothDmAndTrusted_dmTakesPrecedence() {
|
||||
val bothRelay = NormalizedRelayUrl("wss://both.relay.com/")
|
||||
val eval =
|
||||
buildEvaluation(
|
||||
dmViaTor = true,
|
||||
trustedViaTor = false,
|
||||
dmRelays = setOf(bothRelay),
|
||||
trustedRelays = setOf(bothRelay),
|
||||
)
|
||||
// DM check happens before trusted check
|
||||
assertTrue(eval.useTor(bothRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relay_inBothDmAndTrusted_dmDisabled_doesNotFallToTrusted() {
|
||||
val bothRelay = NormalizedRelayUrl("wss://both.relay.com/")
|
||||
val eval =
|
||||
buildEvaluation(
|
||||
dmViaTor = false,
|
||||
trustedViaTor = true,
|
||||
dmRelays = setOf(bothRelay),
|
||||
trustedRelays = setOf(bothRelay),
|
||||
)
|
||||
// DM check matches first, dm is disabled → false
|
||||
// Does NOT fall through to trusted check
|
||||
assertFalse(eval.useTor(bothRelay))
|
||||
}
|
||||
|
||||
// --- Empty relay lists ---
|
||||
|
||||
@Test
|
||||
fun emptyRelayLists_allRelaysAreNew() {
|
||||
val eval =
|
||||
buildEvaluation(
|
||||
newViaTor = true,
|
||||
dmRelays = emptySet(),
|
||||
trustedRelays = emptySet(),
|
||||
)
|
||||
assertTrue(eval.useTor(clearnetRelay))
|
||||
assertTrue(eval.useTor(dmRelay)) // Not in DM list, treated as new
|
||||
assertTrue(eval.useTor(trustedRelay)) // Not in trusted list, treated as new
|
||||
}
|
||||
|
||||
// --- External Tor mode ---
|
||||
|
||||
@Test
|
||||
fun torExternal_newViaTor_returnsTrue() {
|
||||
val eval = buildEvaluation(torType = TorType.EXTERNAL, newViaTor = true)
|
||||
assertTrue(eval.useTor(clearnetRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torExternal_localhostStillFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.EXTERNAL)
|
||||
assertFalse(eval.useTor(localhostRelay))
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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.service.okhttp
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
/**
|
||||
* Tests for SOCKS proxy creation and timeout logic.
|
||||
*
|
||||
* Note: OkHttpClientFactoryForRelays cannot be instantiated in unit tests because
|
||||
* its constructor calls android.os.Build (isEmulator check). These tests verify
|
||||
* the proxy and timeout logic directly using the same patterns the factory uses.
|
||||
* This documents the behavior we must preserve during extraction to commons.
|
||||
*/
|
||||
class OkHttpClientFactoryForRelaysTest {
|
||||
// --- buildLocalSocksProxy logic (tested directly) ---
|
||||
|
||||
@Test
|
||||
fun socksProxy_withPort_returnsSocksType() {
|
||||
val proxy = buildLocalSocksProxy(9050)
|
||||
assertEquals(Proxy.Type.SOCKS, proxy.type())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socksProxy_withPort_usesLocalhost() {
|
||||
val proxy = buildLocalSocksProxy(9050)
|
||||
val addr = proxy.address() as InetSocketAddress
|
||||
assertEquals("127.0.0.1", addr.hostString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socksProxy_withPort_usesGivenPort() {
|
||||
val proxy = buildLocalSocksProxy(9050)
|
||||
val addr = proxy.address() as InetSocketAddress
|
||||
assertEquals(9050, addr.port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socksProxy_customPort_usesGivenPort() {
|
||||
val proxy = buildLocalSocksProxy(9150) // Tor Browser port
|
||||
val addr = proxy.address() as InetSocketAddress
|
||||
assertEquals(9150, addr.port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socksProxy_nullPort_fallsBackToDefault9050() {
|
||||
// DOCUMENTS CURRENT BEHAVIOR: null falls back to 9050
|
||||
// This is a security concern flagged in the plan — will be fixed during extraction
|
||||
val proxy = buildLocalSocksProxy(null)
|
||||
val addr = proxy.address() as InetSocketAddress
|
||||
assertEquals(OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT, addr.port)
|
||||
}
|
||||
|
||||
// --- Timeout logic ---
|
||||
|
||||
@Test
|
||||
fun timeout_mobile_returns30Seconds() {
|
||||
assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS, buildTimeout(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeout_wifi_returns10Seconds() {
|
||||
assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS, buildTimeout(false))
|
||||
}
|
||||
|
||||
// --- Timeout multiplier with proxy ---
|
||||
|
||||
@Test
|
||||
fun timeoutWithProxy_tripled() {
|
||||
val base = 10
|
||||
val withProxy = computeTimeout(base, hasProxy = true)
|
||||
assertEquals(30, withProxy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutWithoutProxy_unchanged() {
|
||||
val base = 10
|
||||
val withoutProxy = computeTimeout(base, hasProxy = false)
|
||||
assertEquals(10, withoutProxy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readWriteTimeout_tripleOfConnectTimeout() {
|
||||
// Both factories do: readTimeout = connectTimeout * 3
|
||||
val connectSeconds = 30
|
||||
assertEquals(90, connectSeconds * 3)
|
||||
}
|
||||
|
||||
// Note: OkHttpClient.Builder tests removed — OkHttp internals depend on
|
||||
// Android platform classes in this module's test classpath.
|
||||
// These will be tested in desktopApp/jvmTest after extraction.
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
@Test
|
||||
fun defaultSocksPort_is9050() {
|
||||
assertEquals(9050, OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultIsMobile_isFalse() {
|
||||
assertEquals(false, OkHttpClientFactoryForRelays.DEFAULT_IS_MOBILE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultTimeoutWifi_is10() {
|
||||
assertEquals(10, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultTimeoutMobile_is30() {
|
||||
assertEquals(30, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS)
|
||||
}
|
||||
|
||||
// --- Helper functions matching factory logic ---
|
||||
|
||||
private fun buildLocalSocksProxy(port: Int?): Proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT))
|
||||
|
||||
private fun buildTimeout(isMobile: Boolean): Int =
|
||||
if (isMobile) {
|
||||
OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS
|
||||
} else {
|
||||
OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS
|
||||
}
|
||||
|
||||
private fun computeTimeout(
|
||||
baseSeconds: Int,
|
||||
hasProxy: Boolean,
|
||||
): Int = if (hasProxy) baseSeconds * 3 else baseSeconds
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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.ui.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.tor.isPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.parseTorType
|
||||
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
|
||||
import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.whichPreset
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TorSettingsTest {
|
||||
// --- parseTorType ---
|
||||
|
||||
@Test
|
||||
fun parseTorType_code0_returnsOff() {
|
||||
assertEquals(TorType.OFF, parseTorType(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorType_code1_returnsInternal() {
|
||||
assertEquals(TorType.INTERNAL, parseTorType(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorType_code2_returnsExternal() {
|
||||
assertEquals(TorType.EXTERNAL, parseTorType(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorType_null_defaultsToInternal() {
|
||||
assertEquals(TorType.INTERNAL, parseTorType(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorType_unknownCode_defaultsToInternal() {
|
||||
assertEquals(TorType.INTERNAL, parseTorType(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorType_negativeCode_defaultsToInternal() {
|
||||
assertEquals(TorType.INTERNAL, parseTorType(-1))
|
||||
}
|
||||
|
||||
// --- TorType screenCode consistency ---
|
||||
|
||||
@Test
|
||||
fun torType_screenCodes_areUnique() {
|
||||
val codes = TorType.entries.map { it.screenCode }
|
||||
assertEquals(codes.size, codes.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torType_allValues_roundTripViaParse() {
|
||||
TorType.entries.forEach { type ->
|
||||
assertEquals(type, parseTorType(type.screenCode))
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseTorPresetType ---
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code0_returnsOnlyWhenNeeded() {
|
||||
assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code1_returnsDefault() {
|
||||
assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code2_returnsSmallPayloads() {
|
||||
assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code3_returnsFullPrivacy() {
|
||||
assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_unknownCode_defaultsToCustom() {
|
||||
assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_null_defaultsToCustom() {
|
||||
assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torPresetType_screenCodes_areUnique() {
|
||||
val codes = TorPresetType.entries.map { it.screenCode }
|
||||
assertEquals(codes.size, codes.toSet().size)
|
||||
}
|
||||
|
||||
// --- Preset definitions ---
|
||||
|
||||
@Test
|
||||
fun onlyWhenNeededPreset_onlyOnionEnabled() {
|
||||
assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.profilePicsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.imagesViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.videosViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultPreset_onionDmNewEnabled() {
|
||||
assertTrue(torDefaultPreset.onionRelaysViaTor)
|
||||
assertTrue(torDefaultPreset.dmRelaysViaTor)
|
||||
assertTrue(torDefaultPreset.newRelaysViaTor)
|
||||
assertFalse(torDefaultPreset.trustedRelaysViaTor)
|
||||
assertFalse(torDefaultPreset.urlPreviewsViaTor)
|
||||
assertFalse(torDefaultPreset.imagesViaTor)
|
||||
assertFalse(torDefaultPreset.videosViaTor)
|
||||
assertFalse(torDefaultPreset.moneyOperationsViaTor)
|
||||
assertFalse(torDefaultPreset.nip05VerificationsViaTor)
|
||||
assertFalse(torDefaultPreset.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smallPayloadsPreset_addsPreviewsNip05Money() {
|
||||
assertTrue(torSmallPayloadsPreset.onionRelaysViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.dmRelaysViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.newRelaysViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.profilePicsViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.imagesViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.videosViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor)
|
||||
assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullPrivacyPreset_allEnabled() {
|
||||
assertTrue(torFullyPrivate.onionRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.dmRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.newRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.trustedRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.urlPreviewsViaTor)
|
||||
assertTrue(torFullyPrivate.profilePicsViaTor)
|
||||
assertTrue(torFullyPrivate.imagesViaTor)
|
||||
assertTrue(torFullyPrivate.videosViaTor)
|
||||
assertTrue(torFullyPrivate.moneyOperationsViaTor)
|
||||
assertTrue(torFullyPrivate.nip05VerificationsViaTor)
|
||||
assertTrue(torFullyPrivate.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
// --- Preset hierarchy: each level is a superset of the previous ---
|
||||
|
||||
@Test
|
||||
fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() {
|
||||
// Default enables DM + new relays on top of onlyWhenNeeded
|
||||
assertTrue(torDefaultPreset.dmRelaysViaTor)
|
||||
assertTrue(torDefaultPreset.newRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() {
|
||||
// Full privacy adds images, videos, media uploads
|
||||
assertTrue(torFullyPrivate.imagesViaTor)
|
||||
assertTrue(torFullyPrivate.videosViaTor)
|
||||
assertTrue(torFullyPrivate.mediaUploadsViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.imagesViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.videosViaTor)
|
||||
assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
// --- whichPreset ---
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesOnlyWhenNeeded() {
|
||||
assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesDefault() {
|
||||
assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesSmallPayloads() {
|
||||
assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesFullPrivacy() {
|
||||
assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_returnsCustomForMixedSettings() {
|
||||
val mixed =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = false, // differs from DEFAULT
|
||||
trustedRelaysViaTor = true, // differs from DEFAULT
|
||||
)
|
||||
assertEquals(TorPresetType.CUSTOM, whichPreset(mixed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_ignoresProfilePicsInComparison() {
|
||||
// profilePicsViaTor is commented out in isPreset()
|
||||
val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true)
|
||||
assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_ignoresTorTypeAndPort() {
|
||||
// whichPreset only compares boolean flags, not torType/port
|
||||
val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234)
|
||||
assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal))
|
||||
}
|
||||
|
||||
// --- isPreset ---
|
||||
|
||||
@Test
|
||||
fun isPreset_exactMatch_returnsTrue() {
|
||||
assertTrue(isPreset(torFullyPrivate, torFullyPrivate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isPreset_differentFlag_returnsFalse() {
|
||||
val modified = torFullyPrivate.copy(imagesViaTor = false)
|
||||
assertFalse(isPreset(modified, torFullyPrivate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isPreset_torTypeDifference_ignored() {
|
||||
val withOff = torDefaultPreset.copy(torType = TorType.OFF)
|
||||
assertTrue(isPreset(withOff, torDefaultPreset))
|
||||
}
|
||||
|
||||
// --- TorSettings data class ---
|
||||
|
||||
@Test
|
||||
fun torSettings_defaultValues() {
|
||||
val defaults = TorSettings()
|
||||
assertEquals(TorType.INTERNAL, defaults.torType)
|
||||
assertEquals(9050, defaults.externalSocksPort)
|
||||
assertTrue(defaults.onionRelaysViaTor)
|
||||
assertTrue(defaults.dmRelaysViaTor)
|
||||
assertTrue(defaults.newRelaysViaTor)
|
||||
assertFalse(defaults.trustedRelaysViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torSettings_equality_worksForDistinctUntilChanged() {
|
||||
val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050)
|
||||
val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050)
|
||||
assertEquals(a, b)
|
||||
assertEquals(a.hashCode(), b.hashCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torSettings_copy_changesOneField() {
|
||||
val original = TorSettings()
|
||||
val modified = original.copy(torType = TorType.OFF)
|
||||
assertEquals(TorType.OFF, modified.torType)
|
||||
assertEquals(original.externalSocksPort, modified.externalSocksPort)
|
||||
assertNotEquals(original, modified)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface for Tor daemon management.
|
||||
*
|
||||
* Implementations are reactive: status is derived from settings changes.
|
||||
* Android uses tor-android + jtorctl. Desktop uses kmp-tor.
|
||||
*/
|
||||
interface ITorManager {
|
||||
val status: StateFlow<TorServiceStatus>
|
||||
val activePortOrNull: StateFlow<Int?>
|
||||
|
||||
suspend fun dormant()
|
||||
|
||||
suspend fun active()
|
||||
|
||||
suspend fun newIdentity()
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface for persisting Tor settings.
|
||||
*
|
||||
* Android uses DataStore. Desktop uses java.util.prefs.Preferences.
|
||||
*/
|
||||
interface ITorSettingsPersistence {
|
||||
fun load(): TorSettings
|
||||
|
||||
fun save(settings: TorSettings)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
|
||||
class TorRelayEvaluation(
|
||||
val torSettings: TorRelaySettings,
|
||||
val trustedRelayList: Set<NormalizedRelayUrl>,
|
||||
val dmRelayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
fun useTor(relay: NormalizedRelayUrl): Boolean =
|
||||
if (torSettings.torType == TorType.OFF) {
|
||||
false
|
||||
} else {
|
||||
if (relay.isLocalHost()) {
|
||||
false
|
||||
} else if (relay.isOnion()) {
|
||||
torSettings.onionRelaysViaTor
|
||||
} else if (relay in dmRelayList) {
|
||||
torSettings.dmRelaysViaTor
|
||||
} else if (relay in trustedRelayList) {
|
||||
torSettings.trustedRelaysViaTor
|
||||
} else {
|
||||
torSettings.newRelaysViaTor
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
data class TorRelaySettings(
|
||||
val torType: TorType = TorType.OFF,
|
||||
val onionRelaysViaTor: Boolean = true,
|
||||
val dmRelaysViaTor: Boolean = false,
|
||||
val newRelaysViaTor: Boolean = false,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
sealed class TorServiceStatus {
|
||||
data class Active(
|
||||
val port: Int,
|
||||
) : TorServiceStatus()
|
||||
|
||||
data object Off : TorServiceStatus()
|
||||
|
||||
data object Connecting : TorServiceStatus()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : TorServiceStatus()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
data class TorSettings(
|
||||
val torType: TorType = TorType.INTERNAL,
|
||||
val externalSocksPort: Int = 9050,
|
||||
val onionRelaysViaTor: Boolean = true,
|
||||
val dmRelaysViaTor: Boolean = true,
|
||||
val newRelaysViaTor: Boolean = true,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val urlPreviewsViaTor: Boolean = false,
|
||||
val profilePicsViaTor: Boolean = false,
|
||||
val imagesViaTor: Boolean = false,
|
||||
val videosViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
val nip05VerificationsViaTor: Boolean = false,
|
||||
val mediaUploadsViaTor: Boolean = false,
|
||||
)
|
||||
|
||||
enum class TorType(
|
||||
val screenCode: Int,
|
||||
) {
|
||||
OFF(0),
|
||||
INTERNAL(1),
|
||||
EXTERNAL(2),
|
||||
}
|
||||
|
||||
fun parseTorType(code: Int?): TorType =
|
||||
when (code) {
|
||||
TorType.OFF.screenCode -> TorType.OFF
|
||||
TorType.INTERNAL.screenCode -> TorType.INTERNAL
|
||||
TorType.EXTERNAL.screenCode -> TorType.EXTERNAL
|
||||
else -> TorType.INTERNAL
|
||||
}
|
||||
|
||||
enum class TorPresetType(
|
||||
val screenCode: Int,
|
||||
) {
|
||||
ONLY_WHEN_NEEDED(0),
|
||||
DEFAULT(1),
|
||||
SMALL_PAYLOADS(2),
|
||||
FULL_PRIVACY(3),
|
||||
CUSTOM(4),
|
||||
}
|
||||
|
||||
fun parseTorPresetType(code: Int?): TorPresetType =
|
||||
when (code) {
|
||||
TorPresetType.ONLY_WHEN_NEEDED.screenCode -> TorPresetType.ONLY_WHEN_NEEDED
|
||||
TorPresetType.DEFAULT.screenCode -> TorPresetType.DEFAULT
|
||||
TorPresetType.SMALL_PAYLOADS.screenCode -> TorPresetType.SMALL_PAYLOADS
|
||||
TorPresetType.FULL_PRIVACY.screenCode -> TorPresetType.FULL_PRIVACY
|
||||
else -> TorPresetType.CUSTOM
|
||||
}
|
||||
|
||||
fun isPreset(
|
||||
torSettings: TorSettings,
|
||||
preset: TorSettings,
|
||||
): Boolean =
|
||||
torSettings.onionRelaysViaTor == preset.onionRelaysViaTor &&
|
||||
torSettings.dmRelaysViaTor == preset.dmRelaysViaTor &&
|
||||
torSettings.newRelaysViaTor == preset.newRelaysViaTor &&
|
||||
torSettings.trustedRelaysViaTor == preset.trustedRelaysViaTor &&
|
||||
torSettings.urlPreviewsViaTor == preset.urlPreviewsViaTor &&
|
||||
// torSettings.profilePicsViaTor == preset.profilePicsViaTor &&
|
||||
torSettings.imagesViaTor == preset.imagesViaTor &&
|
||||
torSettings.videosViaTor == preset.videosViaTor &&
|
||||
torSettings.moneyOperationsViaTor == preset.moneyOperationsViaTor &&
|
||||
torSettings.nip05VerificationsViaTor == preset.nip05VerificationsViaTor &&
|
||||
torSettings.mediaUploadsViaTor == preset.mediaUploadsViaTor
|
||||
|
||||
fun whichPreset(torSettings: TorSettings): TorPresetType {
|
||||
if (isPreset(torSettings, torOnlyWhenNeededPreset)) return TorPresetType.ONLY_WHEN_NEEDED
|
||||
if (isPreset(torSettings, torDefaultPreset)) return TorPresetType.DEFAULT
|
||||
if (isPreset(torSettings, torSmallPayloadsPreset)) return TorPresetType.SMALL_PAYLOADS
|
||||
if (isPreset(torSettings, torFullyPrivate)) return TorPresetType.FULL_PRIVACY
|
||||
return TorPresetType.CUSTOM
|
||||
}
|
||||
|
||||
val torOnlyWhenNeededPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = false,
|
||||
newRelaysViaTor = false,
|
||||
trustedRelaysViaTor = false,
|
||||
urlPreviewsViaTor = false,
|
||||
profilePicsViaTor = false,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = false,
|
||||
nip05VerificationsViaTor = false,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torDefaultPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = false,
|
||||
urlPreviewsViaTor = false,
|
||||
profilePicsViaTor = false,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = false,
|
||||
nip05VerificationsViaTor = false,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torSmallPayloadsPreset =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = true,
|
||||
urlPreviewsViaTor = true,
|
||||
profilePicsViaTor = true,
|
||||
imagesViaTor = false,
|
||||
videosViaTor = false,
|
||||
moneyOperationsViaTor = true,
|
||||
nip05VerificationsViaTor = true,
|
||||
mediaUploadsViaTor = false,
|
||||
)
|
||||
val torFullyPrivate =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = true,
|
||||
trustedRelaysViaTor = true,
|
||||
urlPreviewsViaTor = true,
|
||||
profilePicsViaTor = true,
|
||||
imagesViaTor = true,
|
||||
videosViaTor = true,
|
||||
moneyOperationsViaTor = true,
|
||||
nip05VerificationsViaTor = true,
|
||||
mediaUploadsViaTor = true,
|
||||
)
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TorRelayEvaluationTest {
|
||||
private val clearnetRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
private val onionRelay = NormalizedRelayUrl("wss://abc123.onion/")
|
||||
private val localhostRelay = NormalizedRelayUrl("ws://127.0.0.1:8080/")
|
||||
private val localhostNameRelay = NormalizedRelayUrl("ws://localhost:8080/")
|
||||
private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/")
|
||||
private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/")
|
||||
private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/")
|
||||
|
||||
private fun buildEvaluation(
|
||||
torType: TorType = TorType.INTERNAL,
|
||||
onionViaTor: Boolean = true,
|
||||
dmViaTor: Boolean = true,
|
||||
newViaTor: Boolean = true,
|
||||
trustedViaTor: Boolean = false,
|
||||
dmRelays: Set<NormalizedRelayUrl> = setOf(dmRelay),
|
||||
trustedRelays: Set<NormalizedRelayUrl> = setOf(trustedRelay),
|
||||
) = TorRelayEvaluation(
|
||||
torSettings =
|
||||
TorRelaySettings(
|
||||
torType = torType,
|
||||
onionRelaysViaTor = onionViaTor,
|
||||
dmRelaysViaTor = dmViaTor,
|
||||
newRelaysViaTor = newViaTor,
|
||||
trustedRelaysViaTor = trustedViaTor,
|
||||
),
|
||||
trustedRelayList = trustedRelays,
|
||||
dmRelayList = dmRelays,
|
||||
)
|
||||
|
||||
// --- Tor OFF ---
|
||||
@Test
|
||||
fun torOff_alwaysFalse() {
|
||||
val eval = buildEvaluation(torType = TorType.OFF)
|
||||
assertFalse(eval.useTor(clearnetRelay))
|
||||
assertFalse(eval.useTor(onionRelay))
|
||||
assertFalse(eval.useTor(dmRelay))
|
||||
}
|
||||
|
||||
// --- Localhost bypass ---
|
||||
@Test
|
||||
fun localhost_alwaysFalse() {
|
||||
val eval = buildEvaluation()
|
||||
assertFalse(eval.useTor(localhostRelay))
|
||||
assertFalse(eval.useTor(localhostNameRelay))
|
||||
assertFalse(eval.useTor(localNetworkRelay))
|
||||
}
|
||||
|
||||
// --- .onion ---
|
||||
@Test
|
||||
fun onion_enabled_returnsTrue() {
|
||||
val eval = buildEvaluation(onionViaTor = true)
|
||||
assertTrue(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onion_disabled_returnsFalse() {
|
||||
val eval = buildEvaluation(onionViaTor = false)
|
||||
assertFalse(eval.useTor(onionRelay))
|
||||
}
|
||||
|
||||
// --- DM relays ---
|
||||
@Test
|
||||
fun dm_enabled_returnsTrue() = assertTrue(buildEvaluation(dmViaTor = true).useTor(dmRelay))
|
||||
|
||||
@Test
|
||||
fun dm_disabled_returnsFalse() = assertFalse(buildEvaluation(dmViaTor = false).useTor(dmRelay))
|
||||
|
||||
// --- Trusted relays ---
|
||||
@Test
|
||||
fun trusted_enabled_returnsTrue() = assertTrue(buildEvaluation(trustedViaTor = true).useTor(trustedRelay))
|
||||
|
||||
@Test
|
||||
fun trusted_disabled_returnsFalse() = assertFalse(buildEvaluation(trustedViaTor = false).useTor(trustedRelay))
|
||||
|
||||
// --- New/unknown relays ---
|
||||
@Test
|
||||
fun unknown_enabled_returnsTrue() = assertTrue(buildEvaluation(newViaTor = true).useTor(clearnetRelay))
|
||||
|
||||
@Test
|
||||
fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay))
|
||||
|
||||
// --- Priority ---
|
||||
@Test
|
||||
fun onionInDmList_treatedAsOnion() {
|
||||
val onionDm = NormalizedRelayUrl("wss://dmrelay.onion/")
|
||||
val eval = buildEvaluation(onionViaTor = false, dmViaTor = true, dmRelays = setOf(onionDm))
|
||||
assertFalse(eval.useTor(onionDm)) // onion check first, disabled
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayInBothDmAndTrusted_dmTakesPrecedence() {
|
||||
val both = NormalizedRelayUrl("wss://both.relay.com/")
|
||||
val eval = buildEvaluation(dmViaTor = true, trustedViaTor = false, dmRelays = setOf(both), trustedRelays = setOf(both))
|
||||
assertTrue(eval.useTor(both))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayInBothDmAndTrusted_dmDisabled_noFallToTrusted() {
|
||||
val both = NormalizedRelayUrl("wss://both.relay.com/")
|
||||
val eval = buildEvaluation(dmViaTor = false, trustedViaTor = true, dmRelays = setOf(both), trustedRelays = setOf(both))
|
||||
assertFalse(eval.useTor(both))
|
||||
}
|
||||
|
||||
// --- Empty lists ---
|
||||
@Test
|
||||
fun emptyLists_allAreNew() {
|
||||
val eval = buildEvaluation(newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet())
|
||||
assertTrue(eval.useTor(clearnetRelay))
|
||||
assertTrue(eval.useTor(dmRelay)) // not in list, treated as new
|
||||
}
|
||||
|
||||
// --- External mode ---
|
||||
@Test
|
||||
fun torExternal_routesLikeInternal() {
|
||||
val eval = buildEvaluation(torType = TorType.EXTERNAL, newViaTor = true)
|
||||
assertTrue(eval.useTor(clearnetRelay))
|
||||
assertFalse(eval.useTor(localhostRelay))
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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.commons.tor
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TorSettingsTest {
|
||||
@Test
|
||||
fun parseTorType_code0_returnsOff() = assertEquals(TorType.OFF, parseTorType(0))
|
||||
|
||||
@Test
|
||||
fun parseTorType_code1_returnsInternal() = assertEquals(TorType.INTERNAL, parseTorType(1))
|
||||
|
||||
@Test
|
||||
fun parseTorType_code2_returnsExternal() = assertEquals(TorType.EXTERNAL, parseTorType(2))
|
||||
|
||||
@Test
|
||||
fun parseTorType_null_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(null))
|
||||
|
||||
@Test
|
||||
fun parseTorType_unknownCode_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(99))
|
||||
|
||||
@Test
|
||||
fun parseTorType_negativeCode_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(-1))
|
||||
|
||||
@Test
|
||||
fun torType_screenCodes_areUnique() {
|
||||
val codes = TorType.entries.map { it.screenCode }
|
||||
assertEquals(codes.size, codes.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torType_allValues_roundTripViaParse() {
|
||||
TorType.entries.forEach { type ->
|
||||
assertEquals(type, parseTorType(type.screenCode))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code0_returnsOnlyWhenNeeded() = assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0))
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code1_returnsDefault() = assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1))
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code2_returnsSmallPayloads() = assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2))
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_code3_returnsFullPrivacy() = assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3))
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_unknownCode_defaultsToCustom() = assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99))
|
||||
|
||||
@Test
|
||||
fun parseTorPresetType_null_defaultsToCustom() = assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null))
|
||||
|
||||
@Test
|
||||
fun torPresetType_screenCodes_areUnique() {
|
||||
val codes = TorPresetType.entries.map { it.screenCode }
|
||||
assertEquals(codes.size, codes.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyWhenNeededPreset_onlyOnionEnabled() {
|
||||
assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.imagesViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.videosViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor)
|
||||
assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultPreset_onionDmNewEnabled() {
|
||||
assertTrue(torDefaultPreset.onionRelaysViaTor)
|
||||
assertTrue(torDefaultPreset.dmRelaysViaTor)
|
||||
assertTrue(torDefaultPreset.newRelaysViaTor)
|
||||
assertFalse(torDefaultPreset.trustedRelaysViaTor)
|
||||
assertFalse(torDefaultPreset.urlPreviewsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullPrivacyPreset_allEnabled() {
|
||||
assertTrue(torFullyPrivate.onionRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.dmRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.newRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.trustedRelaysViaTor)
|
||||
assertTrue(torFullyPrivate.urlPreviewsViaTor)
|
||||
assertTrue(torFullyPrivate.imagesViaTor)
|
||||
assertTrue(torFullyPrivate.videosViaTor)
|
||||
assertTrue(torFullyPrivate.moneyOperationsViaTor)
|
||||
assertTrue(torFullyPrivate.nip05VerificationsViaTor)
|
||||
assertTrue(torFullyPrivate.mediaUploadsViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesOnlyWhenNeeded() = assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset))
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesDefault() = assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset))
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesSmallPayloads() = assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset))
|
||||
|
||||
@Test
|
||||
fun whichPreset_matchesFullPrivacy() = assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate))
|
||||
|
||||
@Test
|
||||
fun whichPreset_returnsCustomForMixedSettings() {
|
||||
val mixed =
|
||||
TorSettings(
|
||||
onionRelaysViaTor = true,
|
||||
dmRelaysViaTor = true,
|
||||
newRelaysViaTor = false,
|
||||
trustedRelaysViaTor = true,
|
||||
)
|
||||
assertEquals(TorPresetType.CUSTOM, whichPreset(mixed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whichPreset_ignoresProfilePicsInComparison() {
|
||||
val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true)
|
||||
assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isPreset_exactMatch_returnsTrue() = assertTrue(isPreset(torFullyPrivate, torFullyPrivate))
|
||||
|
||||
@Test
|
||||
fun isPreset_differentFlag_returnsFalse() {
|
||||
val modified = torFullyPrivate.copy(imagesViaTor = false)
|
||||
assertFalse(isPreset(modified, torFullyPrivate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torSettings_defaultValues() {
|
||||
val defaults = TorSettings()
|
||||
assertEquals(TorType.INTERNAL, defaults.torType)
|
||||
assertEquals(9050, defaults.externalSocksPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torSettings_equality() {
|
||||
val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050)
|
||||
val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050)
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torSettings_copy_changesOneField() {
|
||||
val original = TorSettings()
|
||||
val modified = original.copy(torType = TorType.OFF)
|
||||
assertEquals(TorType.OFF, modified.torType)
|
||||
assertNotEquals(original, modified)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -159,6 +160,10 @@ sealed class DesktopScreen {
|
||||
data object Settings : DesktopScreen()
|
||||
}
|
||||
|
||||
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
|
||||
@Volatile
|
||||
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
|
||||
|
||||
fun main() {
|
||||
Log.minLevel = LogLevel.DEBUG
|
||||
DesktopImageLoaderSetup.setup()
|
||||
@@ -167,6 +172,8 @@ fun main() {
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.shutdown()
|
||||
VlcjPlayerPool.shutdown()
|
||||
// Stop Tor daemon if running — reference set by App composable
|
||||
activeTorManager?.stopSync()
|
||||
},
|
||||
)
|
||||
// Pre-init VLC on background thread so first play is fast
|
||||
@@ -178,6 +185,7 @@ fun main() {
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center),
|
||||
)
|
||||
var appRestartKey by remember { mutableStateOf(0) }
|
||||
var showComposeDialog by remember { mutableStateOf(false) }
|
||||
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
||||
val deckScope = rememberCoroutineScope()
|
||||
@@ -185,6 +193,23 @@ fun main() {
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
var showAddColumnDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Tor state at Window level — survives key() app rebuild
|
||||
var torSettings by remember {
|
||||
mutableStateOf(
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
|
||||
.load(),
|
||||
)
|
||||
}
|
||||
val torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) }
|
||||
val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) }
|
||||
val windowScope = rememberCoroutineScope()
|
||||
val torManager =
|
||||
remember {
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, windowScope).also {
|
||||
activeTorManager = it
|
||||
}
|
||||
}
|
||||
var layoutMode by remember {
|
||||
mutableStateOf(
|
||||
try {
|
||||
@@ -404,25 +429,32 @@ fun main() {
|
||||
LocalAwtWindow provides window,
|
||||
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
|
||||
) {
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
onShowReplyDialog = { event ->
|
||||
replyToNote = event
|
||||
showComposeDialog = true
|
||||
},
|
||||
onDismissComposeDialog = {
|
||||
showComposeDialog = false
|
||||
replyToNote = null
|
||||
},
|
||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||
replyToNote = replyToNote,
|
||||
)
|
||||
key(appRestartKey) {
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
onShowReplyDialog = { event ->
|
||||
replyToNote = event
|
||||
showComposeDialog = true
|
||||
},
|
||||
onDismissComposeDialog = {
|
||||
showComposeDialog = false
|
||||
replyToNote = null
|
||||
},
|
||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||
replyToNote = replyToNote,
|
||||
onRestartApp = { appRestartKey++ },
|
||||
torManager = torManager,
|
||||
torTypeFlow = torTypeFlow,
|
||||
externalPortFlow = externalPortFlow,
|
||||
initialTorSettings = torSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,12 +473,102 @@ fun App(
|
||||
onDismissAddColumnDialog: () -> Unit,
|
||||
onShowAddColumnDialog: () -> Unit,
|
||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
onRestartApp: () -> Unit = {},
|
||||
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
||||
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
||||
) {
|
||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
|
||||
var torSettings by remember {
|
||||
mutableStateOf(
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
|
||||
.load(),
|
||||
)
|
||||
}
|
||||
|
||||
// Gate: block EVERYTHING until Tor proxy is ready (when Tor expected)
|
||||
// This must be before any OkHttpClient/Coil/relay creation
|
||||
val torStatus by torManager.status.collectAsState()
|
||||
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
|
||||
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier =
|
||||
androidx.compose.ui.Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
|
||||
) {
|
||||
androidx.compose.material3.CircularProgressIndicator()
|
||||
androidx.compose.foundation.layout.Spacer(
|
||||
modifier =
|
||||
androidx.compose.ui.Modifier
|
||||
.height(16.dp),
|
||||
)
|
||||
if (torStatus is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error) {
|
||||
androidx.compose.material3.Text(
|
||||
"Tor error: ${(torStatus as com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error).message}",
|
||||
)
|
||||
} else {
|
||||
androidx.compose.material3.Text("Connecting to Tor...")
|
||||
}
|
||||
}
|
||||
}
|
||||
return // Nothing below runs until Tor is Active
|
||||
}
|
||||
|
||||
val localCache = remember { DesktopLocalCache() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
// Build TorRelayEvaluation for per-relay routing
|
||||
val torRelayEvaluation =
|
||||
remember(torSettings) {
|
||||
com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation(
|
||||
torSettings =
|
||||
com.vitorpamplona.amethyst.commons.tor.TorRelaySettings(
|
||||
torType = torSettings.torType,
|
||||
onionRelaysViaTor = torSettings.onionRelaysViaTor,
|
||||
dmRelaysViaTor = torSettings.dmRelaysViaTor,
|
||||
newRelaysViaTor = torSettings.newRelaysViaTor,
|
||||
trustedRelaysViaTor = torSettings.trustedRelaysViaTor,
|
||||
),
|
||||
trustedRelayList = emptySet(), // TODO: populate from account relay lists
|
||||
dmRelayList = emptySet(), // TODO: populate from account relay lists
|
||||
)
|
||||
}
|
||||
|
||||
val httpClient =
|
||||
remember(torRelayEvaluation) {
|
||||
com.vitorpamplona.amethyst.desktop.network
|
||||
.DesktopHttpClient(
|
||||
torManager = torManager,
|
||||
shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) },
|
||||
torTypeProvider = { torTypeFlow.value },
|
||||
scope = scope,
|
||||
).also {
|
||||
com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
.setInstance(it)
|
||||
}
|
||||
}
|
||||
// Clean up old httpClient's connection pool on rebuild
|
||||
DisposableEffect(httpClient) {
|
||||
onDispose {
|
||||
httpClient.sharedConnectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize Coil after setInstance so image loads use the Tor-aware client
|
||||
remember(httpClient) {
|
||||
httpClient.evictConnections()
|
||||
com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
.setup()
|
||||
}
|
||||
|
||||
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
|
||||
|
||||
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||
val subscriptionsCoordinator =
|
||||
@@ -541,20 +663,39 @@ fun App(
|
||||
accountManager.loadNwcConnection()
|
||||
}
|
||||
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = scope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAddColumnDialog = onShowAddColumnDialog,
|
||||
)
|
||||
val currentTorStatus = torManager.status.collectAsState().value
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
|
||||
status = currentTorStatus,
|
||||
settings = torSettings,
|
||||
onSettingsChanged = { newSettings ->
|
||||
torSettings = newSettings
|
||||
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
|
||||
.save(newSettings)
|
||||
torTypeFlow.value = newSettings.torType
|
||||
externalPortFlow.value = newSettings.externalSocksPort
|
||||
// Rebuild app to apply Tor changes
|
||||
onRestartApp()
|
||||
},
|
||||
),
|
||||
) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAddColumnDialog = onShowAddColumnDialog,
|
||||
)
|
||||
}
|
||||
|
||||
// Compose dialog
|
||||
if (showComposeDialog) {
|
||||
@@ -602,6 +743,7 @@ fun MainContent(
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
appScope: CoroutineScope,
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onShowAddColumnDialog: () -> Unit,
|
||||
@@ -774,6 +916,7 @@ fun MainContent(
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
torStatus = torStatus,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
@@ -857,6 +1000,11 @@ fun RelaySettingsScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
account: AccountState.LoggedIn,
|
||||
accountManager: AccountManager,
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus = com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Off,
|
||||
torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings =
|
||||
com.vitorpamplona.amethyst.commons.tor
|
||||
.TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF),
|
||||
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {},
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
@@ -972,6 +1120,16 @@ fun RelaySettingsScreen(
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Tor Settings
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection(
|
||||
torStatus = torStatus,
|
||||
currentSettings = torSettings,
|
||||
onSettingsChanged = onTorSettingsChanged,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Developer Settings Section (only in debug mode)
|
||||
if (DebugConfig.isDebugMode) {
|
||||
com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ class AccountManager internal constructor(
|
||||
private suspend fun getOrCreateNip46Client(): INostrClient =
|
||||
nip46ClientMutex.withLock {
|
||||
nip46Client ?: NostrClient(
|
||||
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
|
||||
BasicOkHttpWebSocket.Builder { url -> DesktopHttpClient.currentClient() },
|
||||
).also {
|
||||
nip46Client = it
|
||||
it.connect()
|
||||
|
||||
+146
-7
@@ -20,21 +20,160 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.network
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.ITorManager
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object DesktopHttpClient {
|
||||
private val client: OkHttpClient by lazy {
|
||||
/**
|
||||
* Desktop HTTP client with optional SOCKS proxy support for Tor.
|
||||
*
|
||||
* Maintains two clients: one with SOCKS proxy (for Tor-routed traffic)
|
||||
* and one without (for direct connections). Selects the appropriate
|
||||
* client per relay URL based on Tor settings.
|
||||
*
|
||||
* When Tor is OFF, all relays use the direct client.
|
||||
* When Tor is ON, .onion relays always use the proxy client.
|
||||
* Localhost relays always use the direct client.
|
||||
*/
|
||||
class DesktopHttpClient(
|
||||
torManager: ITorManager,
|
||||
private val shouldUseTorForRelay: (NormalizedRelayUrl) -> Boolean,
|
||||
private val torTypeProvider: () -> TorType,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/** Returns true if user expects Tor routing (INTERNAL or EXTERNAL mode). */
|
||||
fun isTorExpected(): Boolean = torTypeProvider() != TorType.OFF
|
||||
|
||||
val sharedConnectionPool = ConnectionPool()
|
||||
|
||||
/** Evict all idle connections — call on Tor state change to kill stale direct/proxy connections. */
|
||||
fun evictConnections() {
|
||||
sharedConnectionPool.evictAll()
|
||||
}
|
||||
|
||||
private val directClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.connectionPool(sharedConnectionPool)
|
||||
.connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client
|
||||
/** Proxy client, rebuilt when SOCKS port changes. */
|
||||
val proxyClient: StateFlow<OkHttpClient?> =
|
||||
torManager.activePortOrNull
|
||||
.map { port ->
|
||||
if (port == null) {
|
||||
null
|
||||
} else {
|
||||
val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))
|
||||
val torTimeout = BASE_TIMEOUT_SECONDS * TOR_TIMEOUT_MULTIPLIER
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectionPool(sharedConnectionPool)
|
||||
.proxy(proxy)
|
||||
.connectTimeout(torTimeout, TimeUnit.SECONDS)
|
||||
.readTimeout(torTimeout, TimeUnit.SECONDS)
|
||||
.writeTimeout(torTimeout, TimeUnit.SECONDS)
|
||||
.pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* Returns the appropriate OkHttpClient for the given relay URL.
|
||||
*
|
||||
* - Localhost relays → always direct (no proxy)
|
||||
* - .onion relays when Tor active → proxy client
|
||||
* - Other relays → based on TorRelayEvaluation routing decision
|
||||
* - If proxy needed but not available (Tor bootstrapping) → direct client as fallback
|
||||
*/
|
||||
fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient {
|
||||
if (url.isLocalHost()) return directClient
|
||||
|
||||
val torActive = proxyClient.value != null
|
||||
if (!torActive) return directClient
|
||||
|
||||
// .onion always needs Tor
|
||||
if (url.isOnion()) return proxyClient.value ?: directClient
|
||||
|
||||
// Delegate to TorRelayEvaluation for routing decision
|
||||
return if (shouldUseTorForRelay(url)) {
|
||||
proxyClient.value ?: directClient
|
||||
} else {
|
||||
directClient
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the direct (non-proxy) client for non-relay HTTP traffic. */
|
||||
fun getNonProxyClient(): OkHttpClient = directClient
|
||||
|
||||
companion object {
|
||||
private const val BASE_TIMEOUT_SECONDS = 30L
|
||||
private const val TOR_TIMEOUT_MULTIPLIER = 2 // Desktop: 2x, not Android's 3x
|
||||
|
||||
lateinit var instance: DesktopHttpClient
|
||||
private set
|
||||
|
||||
fun setInstance(client: DesktopHttpClient) {
|
||||
instance = client
|
||||
}
|
||||
|
||||
/** Fail-closed client: SOCKS proxy on dead port 1. Requests fail instead of leaking IP. */
|
||||
private val failClosedClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 1)))
|
||||
.connectTimeout(1, TimeUnit.SECONDS)
|
||||
.readTimeout(1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Simple direct client for pre-init only (tests, startup). */
|
||||
private val simpleClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current Tor-aware client.
|
||||
* - Tor active → proxy client (SOCKS)
|
||||
* - Tor off → direct client
|
||||
* - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail not leak)
|
||||
* - Instance not set → simpleClient (pre-init/tests only)
|
||||
*/
|
||||
fun currentClient(): OkHttpClient {
|
||||
if (!::instance.isInitialized) return simpleClient
|
||||
val client = instance
|
||||
val proxyClient = client.proxyClient.value
|
||||
if (proxyClient != null) return proxyClient
|
||||
return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient()
|
||||
}
|
||||
|
||||
/** Backward-compatible static accessor for relay WebSocket builder. */
|
||||
fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = currentClient()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -24,9 +24,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc
|
||||
|
||||
/**
|
||||
* Desktop-specific relay connection manager that configures OkHttp for websockets.
|
||||
* Delegates to the shared RelayConnectionManager from commons.
|
||||
* Now Tor-aware: passes the DesktopHttpClient's getHttpClient which selects
|
||||
* proxy or direct client per relay URL based on Tor settings.
|
||||
*/
|
||||
class DesktopRelayConnectionManager :
|
||||
RelayConnectionManager(
|
||||
websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
|
||||
class DesktopRelayConnectionManager(
|
||||
httpClient: DesktopHttpClient,
|
||||
) : RelayConnectionManager(
|
||||
websocketBuilder = BasicOkHttpWebSocket.Builder(httpClient::getHttpClient),
|
||||
)
|
||||
|
||||
+44
@@ -23,11 +23,22 @@ package com.vitorpamplona.amethyst.desktop.service.images
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import coil3.size.Precision
|
||||
import coil3.svg.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import okhttp3.Call
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
|
||||
@@ -44,6 +55,7 @@ object DesktopImageLoaderSetup {
|
||||
.diskCache { newDiskCache() }
|
||||
.precision(Precision.INEXACT)
|
||||
.components {
|
||||
add(TorAwareOkHttpFactory { DesktopHttpClient.currentClient() })
|
||||
add(SvgDecoder.Factory())
|
||||
add(SkiaGifDecoder.Factory())
|
||||
add(DesktopBase64Fetcher.Factory)
|
||||
@@ -92,3 +104,35 @@ object DesktopImageLoaderSetup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Coil Fetcher.Factory that routes all image requests through the Tor-aware client.
|
||||
* Pattern ported from Android's OkHttpFactory in ImageLoaderSetup.kt.
|
||||
*
|
||||
* Each image request calls [clientProvider] to get the current OkHttpClient,
|
||||
* which returns the SOCKS proxy client when Tor is active or the direct client when off.
|
||||
*/
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private class TorAwareOkHttpFactory(
|
||||
val clientProvider: () -> Call.Factory,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
if (data.scheme != "http" && data.scheme != "https") return null
|
||||
|
||||
return NetworkFetcher(
|
||||
url = data.toString(),
|
||||
options = options,
|
||||
networkClient = lazy { clientProvider().asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { ConnectivityChecker(options.context) },
|
||||
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
|
||||
*/
|
||||
object EncryptedMediaService {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val httpClient get() = DesktopHttpClient.currentClient()
|
||||
private val cache = ConcurrentHashMap<String, ByteArray>()
|
||||
private const val MAX_CACHE_ENTRIES = 20
|
||||
|
||||
|
||||
+2
-10
@@ -20,20 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ServerHealthCheck {
|
||||
private val httpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
enum class ServerStatus {
|
||||
ONLINE,
|
||||
OFFLINE,
|
||||
@@ -53,7 +45,7 @@ object ServerHealthCheck {
|
||||
.url(url)
|
||||
.head()
|
||||
.build()
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val response = DesktopHttpClient.currentClient().newCall(request).execute()
|
||||
response.use {
|
||||
if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE
|
||||
}
|
||||
|
||||
+4
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -34,8 +35,10 @@ import okio.source
|
||||
import java.io.File
|
||||
|
||||
class DesktopBlossomClient(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient(),
|
||||
private val clientOverride: OkHttpClient? = null,
|
||||
) {
|
||||
private val okHttpClient: OkHttpClient get() = clientOverride ?: DesktopHttpClient.currentClient()
|
||||
|
||||
suspend fun upload(
|
||||
file: File,
|
||||
contentType: String,
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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
|
||||
// Retry start — previous daemon may still be stopping
|
||||
var started = false
|
||||
for (attempt in 1..3) {
|
||||
try {
|
||||
runtime.startDaemonAsync()
|
||||
started = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
if (attempt < 3) {
|
||||
Log.d("DesktopTorManager") { "Start attempt $attempt failed, retrying..." }
|
||||
kotlinx.coroutines.delay(1000L * attempt)
|
||||
} else {
|
||||
Log.e("DesktopTorManager", "Failed to start Tor after 3 attempts", 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. Waits for daemon exit. */
|
||||
fun stopSync() {
|
||||
try {
|
||||
runtime.stopDaemonSync()
|
||||
// Wait for daemon process to fully exit
|
||||
Thread.sleep(1000)
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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.INTERNAL.name) } ?: TorType.INTERNAL,
|
||||
externalSocksPort = prefs.getInt("tor_external_port", 9050),
|
||||
onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true),
|
||||
dmRelaysViaTor = prefs.getBoolean("tor_dm_relays", true),
|
||||
newRelaysViaTor = prefs.getBoolean("tor_new_relays", true),
|
||||
trustedRelaysViaTor = prefs.getBoolean("tor_trusted_relays", true),
|
||||
urlPreviewsViaTor = prefs.getBoolean("tor_url_previews", true),
|
||||
profilePicsViaTor = prefs.getBoolean("tor_profile_pics", true),
|
||||
imagesViaTor = prefs.getBoolean("tor_images", true),
|
||||
videosViaTor = prefs.getBoolean("tor_videos", true),
|
||||
moneyOperationsViaTor = prefs.getBoolean("tor_money", true),
|
||||
nip05VerificationsViaTor = prefs.getBoolean("tor_nip05", true),
|
||||
mediaUploadsViaTor = prefs.getBoolean("tor_media_uploads", true),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
|
||||
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -84,10 +85,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
|
||||
@@ -936,12 +935,7 @@ private suspend fun zapNote(
|
||||
}
|
||||
|
||||
// Create HTTP client and resolver
|
||||
val httpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
val httpClient = DesktopHttpClient.currentClient()
|
||||
val resolver = LightningAddressResolver(httpClient)
|
||||
|
||||
// Get relay URLs for zap request
|
||||
|
||||
+9
-1
@@ -312,7 +312,15 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Settings -> {
|
||||
RelaySettingsScreen(relayManager, account, accountManager)
|
||||
val torState = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current
|
||||
RelaySettingsScreen(
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
accountManager = accountManager,
|
||||
torStatus = torState.status,
|
||||
torSettings = torState.settings,
|
||||
onTorSettingsChanged = torState.onSettingsChanged,
|
||||
)
|
||||
}
|
||||
|
||||
is DeckColumnType.Profile -> {
|
||||
|
||||
+7
-1
@@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
||||
|
||||
@Composable
|
||||
fun DeckSidebar(
|
||||
@@ -50,6 +52,7 @@ fun DeckSidebar(
|
||||
onOpenSettings: () -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
torStatus: TorServiceStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -87,7 +90,10 @@ fun DeckSidebar(
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Spacer(Modifier.size(4.dp))
|
||||
TorStatusIndicator(status = torStatus, onClick = onOpenSettings)
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
|
||||
+13
@@ -68,6 +68,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -161,6 +163,17 @@ fun SinglePaneLayout(
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
// Tor status — always last so it's never pushed off screen
|
||||
val torState = LocalTorState.current
|
||||
TorStatusIndicator(
|
||||
status = torState.status,
|
||||
onClick = {
|
||||
currentColumnType = DeckColumnType.Settings
|
||||
navState.clear()
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-9
@@ -35,27 +35,20 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.Codec
|
||||
import org.jetbrains.skia.Data
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF
|
||||
private const val MIN_FRAME_DURATION_MS = 20
|
||||
|
||||
private val gifHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
private val gifHttpClient get() = DesktopHttpClient.currentClient()
|
||||
|
||||
fun isAnimatedGifUrl(url: String): Boolean {
|
||||
val lower = url.lowercase()
|
||||
|
||||
+2
-2
@@ -20,16 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
|
||||
object SaveMediaAction {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val httpClient get() = DesktopHttpClient.currentClient()
|
||||
|
||||
/**
|
||||
* Opens a save dialog and downloads the media URL to the chosen file.
|
||||
|
||||
+30
-5
@@ -41,7 +41,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
|
||||
/**
|
||||
* Card displaying the status of a Nostr relay connection.
|
||||
@@ -99,11 +102,33 @@ fun RelayStatusCard(
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
status.url.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
status.url.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
// .onion badge: show "Requires Tor" when Tor is off
|
||||
if (status.url.isOnion()) {
|
||||
val torState = LocalTorState.current
|
||||
val badgeColor =
|
||||
if (torState.status is TorServiceStatus.Off) {
|
||||
Color(0xFFF44336) // Red — Tor required but off
|
||||
} else {
|
||||
Color(0xFF4CAF50) // Green — routed via Tor
|
||||
}
|
||||
val badgeText =
|
||||
if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor"
|
||||
Text(
|
||||
badgeText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = badgeColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (status.connected && status.pingMs != null) {
|
||||
Text(
|
||||
"${status.pingMs}ms${if (status.compressed) " • compressed" else ""}",
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.ui.tor
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
|
||||
/**
|
||||
* Composition locals for Tor state, avoiding threading params through every layer.
|
||||
*/
|
||||
data class TorState(
|
||||
val status: TorServiceStatus = TorServiceStatus.Off,
|
||||
val settings: TorSettings = TorSettings(torType = TorType.OFF),
|
||||
val onSettingsChanged: (TorSettings) -> Unit = {},
|
||||
)
|
||||
|
||||
val LocalTorState = compositionLocalOf { TorState() }
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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.ui.tor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogWindow
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
|
||||
import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.whichPreset
|
||||
|
||||
@Composable
|
||||
fun TorSettingsDialog(
|
||||
currentSettings: TorSettings,
|
||||
torStatus: TorServiceStatus,
|
||||
onSettingsChanged: (TorSettings) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var editSettings by remember { mutableStateOf(currentSettings) }
|
||||
|
||||
var showRestartConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
DialogWindow(
|
||||
onCloseRequest = onDismiss,
|
||||
title = "Tor Settings",
|
||||
state = rememberDialogState(size = DpSize(480.dp, 640.dp)),
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
// Scrollable content
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Status
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TorStatusIndicator(status = torStatus)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
when (torStatus) {
|
||||
is TorServiceStatus.Off -> "Tor is off"
|
||||
is TorServiceStatus.Connecting -> "Connecting to Tor..."
|
||||
is TorServiceStatus.Active -> "Connected via Tor"
|
||||
is TorServiceStatus.Error -> "Error: ${torStatus.message}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Mode selector
|
||||
Text("Mode", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
TorType.entries.forEachIndexed { index, torType ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(index, TorType.entries.size),
|
||||
onClick = { editSettings = editSettings.copy(torType = torType) },
|
||||
selected = editSettings.torType == torType,
|
||||
) {
|
||||
Text(torType.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editSettings.torType == TorType.EXTERNAL) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = editSettings.externalSocksPort.toString(),
|
||||
onValueChange = { text ->
|
||||
text.toIntOrNull()?.let { port ->
|
||||
if (port in 1..65535) {
|
||||
editSettings = editSettings.copy(externalSocksPort = port)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("SOCKS Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(150.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Presets
|
||||
Text("Preset", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
val currentPreset = whichPreset(editSettings)
|
||||
PresetRow("Only When Needed", TorPresetType.ONLY_WHEN_NEEDED, currentPreset) {
|
||||
editSettings = torOnlyWhenNeededPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
|
||||
}
|
||||
PresetRow("Default", TorPresetType.DEFAULT, currentPreset) {
|
||||
editSettings = torDefaultPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
|
||||
}
|
||||
PresetRow("Small Payloads", TorPresetType.SMALL_PAYLOADS, currentPreset) {
|
||||
editSettings = torSmallPayloadsPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
|
||||
}
|
||||
PresetRow("Full Privacy", TorPresetType.FULL_PRIVACY, currentPreset) {
|
||||
editSettings = torFullyPrivate.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort)
|
||||
}
|
||||
PresetRow("Custom", TorPresetType.CUSTOM, currentPreset) {}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Relay Routing
|
||||
Text("Relay Routing", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ToggleRow(".onion relays via Tor", editSettings.onionRelaysViaTor) { editSettings = editSettings.copy(onionRelaysViaTor = it) }
|
||||
ToggleRow("DM relays via Tor", editSettings.dmRelaysViaTor) { editSettings = editSettings.copy(dmRelaysViaTor = it) }
|
||||
ToggleRow("Trusted relays via Tor", editSettings.trustedRelaysViaTor) { editSettings = editSettings.copy(trustedRelaysViaTor = it) }
|
||||
ToggleRow("New/unknown relays via Tor", editSettings.newRelaysViaTor) { editSettings = editSettings.copy(newRelaysViaTor = it) }
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Content Routing
|
||||
Text("Content Routing", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ToggleRow("URL previews via Tor", editSettings.urlPreviewsViaTor) { editSettings = editSettings.copy(urlPreviewsViaTor = it) }
|
||||
ToggleRow("Profile pictures via Tor", editSettings.profilePicsViaTor) { editSettings = editSettings.copy(profilePicsViaTor = it) }
|
||||
ToggleRow("Images via Tor", editSettings.imagesViaTor) { editSettings = editSettings.copy(imagesViaTor = it) }
|
||||
ToggleRow("Videos via Tor", editSettings.videosViaTor) { editSettings = editSettings.copy(videosViaTor = it) }
|
||||
ToggleRow("NIP-05 verifications via Tor", editSettings.nip05VerificationsViaTor) { editSettings = editSettings.copy(nip05VerificationsViaTor = it) }
|
||||
ToggleRow("Money operations via Tor", editSettings.moneyOperationsViaTor) { editSettings = editSettings.copy(moneyOperationsViaTor = it) }
|
||||
ToggleRow("Media uploads via Tor", editSettings.mediaUploadsViaTor) { editSettings = editSettings.copy(mediaUploadsViaTor = it) }
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
} // end scrollable content
|
||||
|
||||
// Sticky bottom buttons — always visible
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = { showRestartConfirm = true }) { Text("Save") }
|
||||
}
|
||||
|
||||
if (showRestartConfirm) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showRestartConfirm = false },
|
||||
title = { Text("Restart Required") },
|
||||
text = { Text("Tor changes require restarting. Proceed?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSettingsChanged(editSettings)
|
||||
onDismiss()
|
||||
}) { Text("Restart") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetRow(
|
||||
label: String,
|
||||
presetType: TorPresetType,
|
||||
currentPreset: TorPresetType,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = currentPreset == presetType,
|
||||
onClick = onClick,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.ui.tor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
|
||||
/**
|
||||
* Inline Tor settings section for the desktop settings screen.
|
||||
* Shows mode selector (Off/Internal/External) with status indicator
|
||||
* and an "Advanced..." button to open the full dialog.
|
||||
*/
|
||||
@Composable
|
||||
fun TorSettingsSection(
|
||||
torStatus: TorServiceStatus,
|
||||
currentSettings: TorSettings,
|
||||
onSettingsChanged: (TorSettings) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var pendingSettings by remember { mutableStateOf<TorSettings?>(null) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Tor",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
TorStatusIndicator(status = torStatus)
|
||||
}
|
||||
TextButton(onClick = { showDialog = true }) {
|
||||
Text("Advanced...")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"Route relay connections through Tor for privacy. Internal mode bundles a Tor daemon; External mode connects to your own SOCKS proxy.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Mode selector
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
TorType.entries.forEachIndexed { index, torType ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size),
|
||||
onClick = { pendingSettings = currentSettings.copy(torType = torType) },
|
||||
selected = currentSettings.torType == torType,
|
||||
) {
|
||||
Text(torType.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// External port input
|
||||
if (currentSettings.torType == TorType.EXTERNAL) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"SOCKS Port:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = currentSettings.externalSocksPort.toString(),
|
||||
onValueChange = { text ->
|
||||
text.toIntOrNull()?.let { port ->
|
||||
if (port in 1..65535) {
|
||||
onSettingsChanged(currentSettings.copy(externalSocksPort = port))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(100.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
TorSettingsDialog(
|
||||
currentSettings = currentSettings,
|
||||
torStatus = torStatus,
|
||||
onSettingsChanged = onSettingsChanged,
|
||||
onDismiss = { showDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Restart confirmation dialog
|
||||
pendingSettings?.let { settings ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingSettings = null },
|
||||
title = { Text("Restart Required") },
|
||||
text = { Text("Changing Tor mode requires restarting. Your session will be briefly interrupted.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSettingsChanged(settings)
|
||||
pendingSettings = null
|
||||
}) { Text("Restart") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingSettings = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.ui.tor
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.TooltipPlacement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
|
||||
/**
|
||||
* Small shield icon showing Tor connection status in the sidebar footer.
|
||||
* Clickable to open Tor settings. Tooltip shows status text.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TorStatusIndicator(
|
||||
status: TorServiceStatus,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (icon, tint, tooltip) =
|
||||
when (status) {
|
||||
is TorServiceStatus.Off -> {
|
||||
Triple(Icons.Outlined.Shield, Color.Gray, "Tor: Off")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Connecting -> {
|
||||
Triple(Icons.Filled.Shield, Color(0xFFFFB300), "Tor: Connecting...")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Active -> {
|
||||
Triple(Icons.Filled.Shield, Color(0xFF4CAF50), "Tor: Connected")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Error -> {
|
||||
Triple(Icons.Outlined.Shield, Color(0xFFF44336), "Tor: ${status.message}")
|
||||
}
|
||||
}
|
||||
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd, offset = DpOffset(0.dp, 16.dp)),
|
||||
) {
|
||||
if (onClick != null) {
|
||||
IconButton(onClick = onClick, modifier = modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = tooltip,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = tooltip,
|
||||
tint = tint,
|
||||
modifier = modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-41
@@ -20,17 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.network
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopHttpClientTest {
|
||||
// --- Simple (companion) client tests ---
|
||||
|
||||
@Test
|
||||
fun testGetHttpClientReturnsConfiguredClient() {
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io")
|
||||
val client = DesktopHttpClient.getHttpClient(url)
|
||||
fun simpleClient_returnsConfiguredClient() {
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val client = DesktopHttpClient.getSimpleHttpClient(url)
|
||||
|
||||
assertNotNull(client)
|
||||
assertEquals(30_000, client.connectTimeoutMillis)
|
||||
@@ -41,52 +49,106 @@ class DesktopHttpClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetHttpClientReturnsSameInstance() {
|
||||
val url1 = NormalizedRelayUrl("wss://relay.damus.io")
|
||||
val url2 = NormalizedRelayUrl("wss://nos.lol")
|
||||
fun simpleClient_returnsSameInstance() {
|
||||
val url1 = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val url2 = NormalizedRelayUrl("wss://nos.lol/")
|
||||
assertEquals(DesktopHttpClient.getSimpleHttpClient(url1), DesktopHttpClient.getSimpleHttpClient(url2))
|
||||
}
|
||||
|
||||
val client1 = DesktopHttpClient.getHttpClient(url1)
|
||||
val client2 = DesktopHttpClient.getHttpClient(url2)
|
||||
// --- Tor-aware client tests ---
|
||||
|
||||
// Should return the same singleton instance
|
||||
assertEquals(client1, client2)
|
||||
private fun buildTorAwareClient(
|
||||
torPort: Int? = null,
|
||||
shouldUseTor: (NormalizedRelayUrl) -> Boolean = { false },
|
||||
torType: com.vitorpamplona.amethyst.commons.tor.TorType = if (torPort != null) com.vitorpamplona.amethyst.commons.tor.TorType.INTERNAL else com.vitorpamplona.amethyst.commons.tor.TorType.OFF,
|
||||
): DesktopHttpClient {
|
||||
val statusFlow =
|
||||
MutableStateFlow<TorServiceStatus>(
|
||||
if (torPort != null) TorServiceStatus.Active(torPort) else TorServiceStatus.Off,
|
||||
)
|
||||
val portFlow = MutableStateFlow<Int?>(torPort)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
val fakeTorManager =
|
||||
object : com.vitorpamplona.amethyst.commons.tor.ITorManager {
|
||||
override val status = statusFlow
|
||||
override val activePortOrNull = portFlow
|
||||
|
||||
override suspend fun dormant() {}
|
||||
|
||||
override suspend fun active() {}
|
||||
|
||||
override suspend fun newIdentity() {}
|
||||
}
|
||||
return DesktopHttpClient(fakeTorManager, shouldUseTor, { torType }, scope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpClientHasExpectedTimeouts() {
|
||||
val url = NormalizedRelayUrl("wss://relay.nostr.band")
|
||||
val client = DesktopHttpClient.getHttpClient(url)
|
||||
fun torOff_returnsDirectClient() {
|
||||
val httpClient = buildTorAwareClient(torPort = null)
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
assertNotNull(client)
|
||||
assertNull(client.proxy, "Should not have proxy when Tor is off")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOn_localhostRelay_returnsDirectClient() {
|
||||
val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true })
|
||||
val url = NormalizedRelayUrl("ws://127.0.0.1:8080/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
assertNull(client.proxy, "Localhost should never use proxy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOn_clearnetRelay_shouldUseTorTrue_returnsProxiedClient() {
|
||||
val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true })
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
assertNotNull(client.proxy, "Should have SOCKS proxy when Tor routing is enabled")
|
||||
assertEquals(java.net.Proxy.Type.SOCKS, client.proxy!!.type())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOn_clearnetRelay_shouldUseTorFalse_returnsDirectClient() {
|
||||
val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false })
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
assertNull(client.proxy, "Should not proxy when shouldUseTor returns false")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torOn_onionRelay_alwaysProxied() {
|
||||
val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false })
|
||||
val url = NormalizedRelayUrl("wss://abc123.onion/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
assertNotNull(client.proxy, ".onion relays should always use proxy when Tor is active")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proxyClient_hasDoubledTimeouts() {
|
||||
val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true })
|
||||
val url = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
val client = httpClient.getHttpClient(url)
|
||||
|
||||
// Desktop uses 2x multiplier: 30 * 2 = 60 seconds
|
||||
assertEquals(60_000, client.connectTimeoutMillis)
|
||||
assertEquals(60_000, client.readTimeoutMillis)
|
||||
assertEquals(60_000, client.writeTimeoutMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directClient_hasBaseTimeouts() {
|
||||
val httpClient = buildTorAwareClient(torPort = null)
|
||||
val client = httpClient.getNonProxyClient()
|
||||
|
||||
// Verify all timeouts are 30 seconds
|
||||
assertEquals(30_000, client.connectTimeoutMillis)
|
||||
assertEquals(30_000, client.readTimeoutMillis)
|
||||
assertEquals(30_000, client.writeTimeoutMillis)
|
||||
assertEquals(30_000, client.pingIntervalMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpClientHasRetryEnabled() {
|
||||
val url = NormalizedRelayUrl("wss://relay.snort.social")
|
||||
val client = DesktopHttpClient.getHttpClient(url)
|
||||
|
||||
assertTrue(client.retryOnConnectionFailure, "Retry on connection failure should be enabled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpClientIsLazyInitialized() {
|
||||
// This test verifies the lazy initialization pattern
|
||||
// The client should be created only once even with multiple calls
|
||||
val url1 = NormalizedRelayUrl("wss://relay1.example.com")
|
||||
val url2 = NormalizedRelayUrl("wss://relay2.example.com")
|
||||
val url3 = NormalizedRelayUrl("wss://relay3.example.com")
|
||||
|
||||
val client1 = DesktopHttpClient.getHttpClient(url1)
|
||||
val client2 = DesktopHttpClient.getHttpClient(url2)
|
||||
val client3 = DesktopHttpClient.getHttpClient(url3)
|
||||
|
||||
// All should be the same instance due to lazy singleton
|
||||
assertEquals(client1, client2)
|
||||
assertEquals(client2, client3)
|
||||
assertEquals(client1, client3)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -20,20 +20,41 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.network
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopRelayConnectionManagerTest {
|
||||
private fun createManager(): DesktopRelayConnectionManager {
|
||||
val fakeTorManager =
|
||||
object : com.vitorpamplona.amethyst.commons.tor.ITorManager {
|
||||
override val status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val activePortOrNull = MutableStateFlow<Int?>(null)
|
||||
|
||||
override suspend fun dormant() {}
|
||||
|
||||
override suspend fun active() {}
|
||||
|
||||
override suspend fun newIdentity() {}
|
||||
}
|
||||
val scope = CoroutineScope(SupervisorJob())
|
||||
val httpClient = DesktopHttpClient(fakeTorManager, { false }, { com.vitorpamplona.amethyst.commons.tor.TorType.OFF }, scope)
|
||||
return DesktopRelayConnectionManager(httpClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRelayConnectionManagerCanBeInstantiated() {
|
||||
val manager = DesktopRelayConnectionManager()
|
||||
val manager = createManager()
|
||||
assertNotNull(manager)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRelayConnectionManagerHasNoActiveConnectionsInitially() {
|
||||
val manager = DesktopRelayConnectionManager()
|
||||
val manager = createManager()
|
||||
val connectedRelays = manager.connectedRelays.value
|
||||
val availableRelays = manager.availableRelays.value
|
||||
assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Manual Testing: Desktop Tor Support
|
||||
|
||||
## Prerequisites
|
||||
|
||||
All tools are available natively on macOS:
|
||||
- `tcpdump` — DNS leak monitoring
|
||||
- `curl` — Tor routing verification
|
||||
- `lsof` — find SOCKS port
|
||||
|
||||
Optional:
|
||||
```bash
|
||||
brew install tor # For external Tor mode testing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Launch & Find SOCKS Port
|
||||
|
||||
```bash
|
||||
cd AmethystMultiplatform-tor-support
|
||||
./gradlew :desktopApp:run > /tmp/amethyst-tor.log 2>&1 &
|
||||
|
||||
# Wait ~30s for Tor bootstrap, then find the SOCKS port:
|
||||
lsof -i -P -n | grep java | grep LISTEN
|
||||
# Look for a high port (e.g. 35607) — that's the kmp-tor SOCKS port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Verify Tor Routing
|
||||
|
||||
Replace `PORT` with the actual SOCKS port from Step 1.
|
||||
|
||||
```bash
|
||||
# Your real IP
|
||||
curl -s https://icanhazip.com
|
||||
# → e.g. 203.0.113.42
|
||||
|
||||
# IP through Tor
|
||||
curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com
|
||||
# → e.g. 185.220.101.xx (different from above)
|
||||
|
||||
# Confirm Tor Project says it's Tor
|
||||
curl -s --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip
|
||||
# → {"IsTor":true,"IP":"185.220.101.xx"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Verify No DNS Leaks
|
||||
|
||||
```bash
|
||||
# In a separate terminal:
|
||||
sudo tcpdump -i any port 53 -n -l 2>/dev/null | grep --line-buffered -v "mdns"
|
||||
|
||||
# In the app: scroll feed, load profiles, open threads
|
||||
# tcpdump should show NO relay hostname DNS queries from Java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
### UI — Default State
|
||||
- [ ] App launches with shield icon visible in sidebar (bottom)
|
||||
- [ ] Shield starts gray/yellow, turns green after ~10-30s (Tor bootstrap)
|
||||
- [ ] Hover shield → tooltip shows "Tor: Connected"
|
||||
- [ ] Click shield → navigates to Settings
|
||||
- [ ] Settings → Tor section shows **Internal** mode selected
|
||||
- [ ] Click "Advanced..." → **Full Privacy** preset selected, all toggles ON
|
||||
|
||||
### UI — Mode Switching
|
||||
- [ ] Select "Off" → shield turns gray, relays reconnect directly
|
||||
- [ ] Select "Internal" → shield yellow → green, relays reconnect via Tor
|
||||
- [ ] Select "External" → SOCKS port field appears
|
||||
- [ ] Enter port 9050 (if system Tor running) → shield turns green
|
||||
|
||||
### UI — Advanced Dialog
|
||||
- [ ] Preset radio buttons: Only When Needed / Default / Small Payloads / Full Privacy / Custom
|
||||
- [ ] Selecting "Only When Needed" → only .onion toggle ON
|
||||
- [ ] Selecting "Full Privacy" → all toggles ON
|
||||
- [ ] Toggling individual item → preset auto-switches to "Custom"
|
||||
- [ ] Scroll dialog content → Cancel/Save buttons stay at bottom (sticky)
|
||||
- [ ] Cancel → no changes saved
|
||||
- [ ] Save → settings applied and persisted
|
||||
|
||||
### UI — .onion Relay Badge
|
||||
- [ ] Add a .onion relay URL → badge shows "via Tor" (green) when Tor ON
|
||||
- [ ] Switch Tor OFF → badge shows "Requires Tor" (red)
|
||||
|
||||
### UI — Persistence
|
||||
- [ ] Change to "Default" preset, close app
|
||||
- [ ] Relaunch → shows Internal + Default preset, Tor auto-connects
|
||||
|
||||
### Network — Tor Routing Verified
|
||||
- [ ] `curl -s https://icanhazip.com` → real IP: _______________
|
||||
- [ ] `curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com` → Tor IP: _______________
|
||||
- [ ] IPs are different: ___
|
||||
- [ ] `curl --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip` → `{"IsTor":true}`: ___
|
||||
|
||||
### Network — No DNS Leaks
|
||||
- [ ] `sudo tcpdump -i any port 53 -n` shows no relay hostname queries while Tor active
|
||||
|
||||
### Build — No Regressions
|
||||
- [ ] `./gradlew :commons:jvmTest` — all pass
|
||||
- [ ] `./gradlew :desktopApp:test` — all pass (1 pre-existing failure in DesktopCachePipelineTest)
|
||||
- [ ] `./gradlew :amethyst:testPlayDebugUnitTest` — all pass
|
||||
- [ ] `./gradlew :amethyst:compilePlayDebugKotlin` — Android compiles clean
|
||||
- [ ] `./gradlew spotlessApply` — formatting clean
|
||||
|
||||
---
|
||||
|
||||
## Proof of Work Evidence
|
||||
|
||||
Paste terminal output of:
|
||||
|
||||
```bash
|
||||
# 1. SOCKS port discovered
|
||||
lsof -i -P -n | grep java | grep LISTEN
|
||||
|
||||
# 2. Tor routing confirmed
|
||||
curl -s --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip
|
||||
|
||||
# 3. IP mismatch confirmed
|
||||
echo "Real: $(curl -s https://icanhazip.com) | Tor: $(curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com)"
|
||||
|
||||
# 4. Tests passing
|
||||
./gradlew :commons:jvmTest :desktopApp:test :amethyst:testPlayDebugUnitTest 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Screenshot of:
|
||||
- Shield icon green in sidebar
|
||||
- Tor settings section showing Internal + Full Privacy
|
||||
- Advanced dialog with all toggles ON
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: "fix: Desktop Tor traffic leaks — zero direct connections when Tor ON"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
deepened: 2026-04-01
|
||||
origin: docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md
|
||||
---
|
||||
|
||||
# fix: Desktop Tor traffic leaks
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-04-01
|
||||
**Agents:** Security sentinel, Simplicity reviewer, Coil3 API verifier, Performance oracle
|
||||
|
||||
### Critical Findings from Deepening
|
||||
1. **SECURITY: Fail-closed** — `proxyClient.value ?: directClient` silently leaks during Tor bootstrap. Must return a dead-socket client when Tor expected but not ready.
|
||||
2. **SECURITY: NIP-46 bunker** — `AccountManager` uses `simpleClient` (always direct). Bunker relay connections bypass Tor entirely.
|
||||
3. **SECURITY: DesktopBlossomClient** — must use `get() =` not `=` (stale capture at construction).
|
||||
4. **PERFORMANCE: Stagger relay reconnect** — 30 relays × Tor circuit simultaneously = 30-90s freeze. Need 500ms jitter.
|
||||
5. **SIMPLICITY: lateinit var** — fail loudly on misconfiguration rather than silently falling back to direct client (IP leak).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Manual testing with `lsof` revealed that with Tor ON (Full Privacy), only relay WebSocket connections go through the SOCKS proxy. 8 other network egress paths create their own `OkHttpClient()` and bypass Tor — leaking user IP to image CDNs, media servers, lightning providers, and Blossom hosts.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
With Tor enabled, the Java process should have ONLY:
|
||||
- Connections to `127.0.0.1:PORT` (local SOCKS proxy)
|
||||
- The Tor daemon's own guard node connections
|
||||
|
||||
Instead, `lsof` shows 9+ direct external TCP connections from Java to Cloudflare, Hetzner, and relay IPs — defeating Tor's privacy guarantees.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
**Global `DesktopHttpClient.currentClient()` accessor** (see brainstorm: `docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md`). Each leak site changes 1-2 lines to use the Tor-aware client instead of a bare `OkHttpClient()`.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: Add fail-closed global accessor to DesktopHttpClient
|
||||
|
||||
**File:** `desktopApp/.../network/DesktopHttpClient.kt`
|
||||
|
||||
Add to companion object:
|
||||
```kotlin
|
||||
lateinit var instance: DesktopHttpClient
|
||||
private set
|
||||
|
||||
fun setInstance(client: DesktopHttpClient) { instance = client }
|
||||
|
||||
/** Fail-closed client — routes to dead proxy, requests fail instead of leaking. */
|
||||
private val failClosedClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.proxy(java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", 1)))
|
||||
.connectTimeout(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current Tor-aware client.
|
||||
* - Tor active → proxy client (SOCKS)
|
||||
* - Tor off → direct client
|
||||
* - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail)
|
||||
*/
|
||||
fun currentClient(): OkHttpClient {
|
||||
if (!::instance.isInitialized) return simpleClient // Pre-init only (tests, startup)
|
||||
val client = instance
|
||||
val proxyClient = client.proxyClient.value
|
||||
if (proxyClient != null) return proxyClient
|
||||
// Tor not active — check if it SHOULD be
|
||||
return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient()
|
||||
}
|
||||
```
|
||||
|
||||
Add `isTorExpected()` to `DesktopHttpClient` instance:
|
||||
```kotlin
|
||||
/** Returns true if user's settings expect Tor routing (INTERNAL or EXTERNAL mode). */
|
||||
fun isTorExpected(): Boolean = torTypeFlow.value != TorType.OFF
|
||||
```
|
||||
|
||||
**File:** `desktopApp/.../Main.kt`
|
||||
|
||||
After `httpClient` creation in `App`:
|
||||
```kotlin
|
||||
DesktopHttpClient.setInstance(httpClient)
|
||||
```
|
||||
|
||||
### Phase 2: Fix 8 leak sites
|
||||
|
||||
| File | Before | After |
|
||||
|------|--------|-------|
|
||||
| `AnimatedGifImage.kt:52` | `private val gifHttpClient by lazy { OkHttpClient.Builder()...build() }` | `private val gifHttpClient get() = DesktopHttpClient.currentClient()` |
|
||||
| `SaveMediaAction.kt:32` | `private val httpClient = OkHttpClient()` | `private val httpClient get() = DesktopHttpClient.currentClient()` |
|
||||
| `EncryptedMediaService.kt:36` | `private val httpClient = OkHttpClient()` | `private val httpClient get() = DesktopHttpClient.currentClient()` |
|
||||
| `ServerHealthCheck.kt:30` | `private val httpClient = OkHttpClient.Builder()...build()` | Remove property. Inline `DesktopHttpClient.currentClient()` at call site. |
|
||||
| `NoteActions.kt:939` | `val httpClient = OkHttpClient.Builder()...build()` | `val httpClient = DesktopHttpClient.currentClient()` |
|
||||
| `DesktopBlossomClient.kt:37` | `private val okHttpClient: OkHttpClient = OkHttpClient()` | `private val okHttpClient: OkHttpClient get() = DesktopHttpClient.currentClient()` |
|
||||
| `AccountManager.kt:141` | `DesktopHttpClient::getSimpleHttpClient` | `{ url -> DesktopHttpClient.currentClient() }` |
|
||||
|
||||
**Note on DesktopBlossomClient:** Must use `get() =` (not `=`) so each upload picks up current Tor state. If Tor activates after dialog opens, uploads still route through Tor.
|
||||
|
||||
**Note on AccountManager (NEW):** NIP-46 bunker relay connections currently always bypass Tor via `getSimpleHttpClient`. Security review flagged this as HIGH — bunker connections associate user's IP with signing operations.
|
||||
|
||||
### Phase 3: Wire Coil image loader through Tor
|
||||
|
||||
**File:** `desktopApp/.../service/images/DesktopImageLoaderSetup.kt`
|
||||
|
||||
```kotlin
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcher
|
||||
|
||||
ImageLoader.Builder(PlatformContext.INSTANCE)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcher.factory { DesktopHttpClient.currentClient() })
|
||||
// ... existing decoders (SvgDecoder, SkiaGifDecoder, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
Confirmed API: `OkHttpNetworkFetcher.factory(callFactory: () -> Call.Factory)` — lambda called per-request, so each image load picks up current Tor state. Coil's memory + disk caches (256MB + 512MB) mean cached images don't re-trigger network.
|
||||
|
||||
### Phase 4: Staggered relay reconnection on Tor Active
|
||||
|
||||
**File:** `desktopApp/.../Main.kt`
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(torManager) {
|
||||
var previouslyActive = false
|
||||
torManager.status.collect { status ->
|
||||
val nowActive = status is TorServiceStatus.Active
|
||||
if (nowActive && !previouslyActive) {
|
||||
// Tor just became active — stagger reconnect to avoid thundering herd
|
||||
// NostrClient.reconnect() disconnects all then reconnects
|
||||
// 30 relays × Tor circuit = potential 30-90s if simultaneous
|
||||
delay(500) // Brief delay for proxy client to propagate
|
||||
relayManager.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
}
|
||||
previouslyActive = nowActive
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Performance oracle recommends staggering inside `NostrClient.reconnect()`, but that requires modifying quartz. For this fix, the `delay(500)` ensures the proxy client StateFlow has propagated before reconnection starts. Full staggered reconnection is a follow-up.
|
||||
|
||||
### Phase 5: Tests
|
||||
|
||||
**New unit tests** (`desktopApp/src/jvmTest/.../network/DesktopHttpClientTest.kt`):
|
||||
|
||||
```
|
||||
// Add to existing DesktopHttpClientTest:
|
||||
- currentClient_instanceNotSet_returnsSimpleClient
|
||||
- currentClient_torActive_returnsProxyClient
|
||||
- currentClient_torOff_returnsDirectClient
|
||||
- currentClient_torExpectedButBootstrapping_returnsFailClosed
|
||||
- currentClient_failClosed_hasSocksProxy // Verify it has a proxy, not direct
|
||||
- currentClient_failClosed_pointsToDeadPort // port 1, will fail fast
|
||||
```
|
||||
|
||||
**Manual verification (lsof):**
|
||||
```bash
|
||||
# WITH Tor ON (Full Privacy):
|
||||
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
|
||||
# Expected: EMPTY — zero direct connections
|
||||
|
||||
# WITH Tor OFF:
|
||||
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
|
||||
# Expected: Direct connections to relay IPs (control test)
|
||||
|
||||
# DURING Tor bootstrap (first 10-30s):
|
||||
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
|
||||
# Expected: EMPTY — fail-closed prevents leaks during bootstrap
|
||||
```
|
||||
|
||||
**Grep CI check (prevent regressions):**
|
||||
```bash
|
||||
# Should return ZERO matches outside DesktopHttpClient.kt:
|
||||
grep -r "OkHttpClient()" desktopApp/src/jvmMain/ --include="*.kt" | grep -v "DesktopHttpClient.kt" | grep -v "Test"
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `currentClient()` returns proxy client when Tor active
|
||||
- [ ] `currentClient()` returns direct client when Tor off
|
||||
- [ ] `currentClient()` returns **fail-closed client** when Tor expected but bootstrapping
|
||||
- [ ] `currentClient()` throws (lateinit) if instance never set (misconfiguration = loud failure)
|
||||
- [ ] Fail-closed client has SOCKS proxy pointing to dead port (requests fail, don't leak)
|
||||
- [ ] `AnimatedGifImage` uses `currentClient()` via `get() =`
|
||||
- [ ] `SaveMediaAction` uses `currentClient()` via `get() =`
|
||||
- [ ] `EncryptedMediaService` uses `currentClient()` via `get() =`
|
||||
- [ ] `ServerHealthCheck` inlines `currentClient()` at call site
|
||||
- [ ] `NoteActions.zapNote()` uses `currentClient()`
|
||||
- [ ] `DesktopBlossomClient` uses `currentClient()` via `get() =` (not `=`)
|
||||
- [ ] `AccountManager` NIP-46 bunker uses `currentClient()` (not `simpleClient`)
|
||||
- [ ] Coil uses `OkHttpNetworkFetcher.factory { currentClient() }`
|
||||
- [ ] Relays reconnect through SOCKS when Tor transitions to Active
|
||||
- [ ] `lsof` shows ZERO direct external connections from Java when Tor ON
|
||||
- [ ] `lsof` shows ZERO direct connections during Tor bootstrap (fail-closed)
|
||||
- [ ] `lsof` shows direct connections when Tor OFF (control)
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Images load through Tor (slower but functional)
|
||||
- [ ] Zaps work through Tor
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change | Lines |
|
||||
|------|--------|-------|
|
||||
| `DesktopHttpClient.kt` | `lateinit`, `currentClient()`, `failClosedClient`, `isTorExpected()` | +25 |
|
||||
| `Main.kt` | `setInstance`, reconnect `LaunchedEffect` | +12 |
|
||||
| `AnimatedGifImage.kt` | `get() = currentClient()` | 1 |
|
||||
| `SaveMediaAction.kt` | `get() = currentClient()` | 1 |
|
||||
| `EncryptedMediaService.kt` | `get() = currentClient()` | 1 |
|
||||
| `ServerHealthCheck.kt` | Inline `currentClient()` at call site, remove property | -3, +1 |
|
||||
| `NoteActions.kt` | Replace inline builder | 1 |
|
||||
| `DesktopBlossomClient.kt` | `get() = currentClient()` | 1 |
|
||||
| `AccountManager.kt` | Replace `getSimpleHttpClient` with `currentClient()` | 1 |
|
||||
| `DesktopImageLoaderSetup.kt` | Add `OkHttpNetworkFetcher.factory` | 2 |
|
||||
| `DesktopHttpClientTest.kt` | Add fail-closed + currentClient tests | +25 |
|
||||
|
||||
**Total: ~70 lines changed across 11 files.**
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm:** [docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md](docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md)
|
||||
- Key decisions: Global accessor (Option A), fail-closed during bootstrap, Coil3 per-request lambda
|
||||
|
||||
### Deepening Findings
|
||||
|
||||
| Agent | Key Finding | Impact |
|
||||
|-------|------------|--------|
|
||||
| Security | `?: directClient` fallback leaks during bootstrap | **CRITICAL** — added fail-closed |
|
||||
| Security | NIP-46 bunker bypasses Tor via `simpleClient` | **HIGH** — added AccountManager fix |
|
||||
| Simplicity | `DesktopBlossomClient` `=` captures stale client | **HIGH** — changed to `get() =` |
|
||||
| Simplicity | `lateinit var` fails loudly vs silent leak | Applied |
|
||||
| Simplicity | ServerHealthCheck: inline, drop custom timeout | Applied |
|
||||
| Coil3 | `OkHttpNetworkFetcher.factory { client }` correct API | Confirmed |
|
||||
| Performance | 30-relay thundering herd through Tor = 30-90s | Added delay, follow-up for stagger |
|
||||
| Performance | Image latency +450-1250ms first load, cached OK | Expected, documented |
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: "feat: Tor toggle restart UX — confirmation dialog + app rebuild"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
deepened: 2026-04-02
|
||||
---
|
||||
|
||||
# feat: Tor toggle restart UX
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-04-02
|
||||
**Agents:** Cleanup verifier, Simplicity reviewer, Compose Desktop edge cases
|
||||
|
||||
### Key Improvements
|
||||
1. **No new files** — inline AlertDialog, merge restart into `onSettingsChanged`
|
||||
2. **DisposableEffect for TorManager** — critical: stop Tor daemon on unmount to prevent process leak
|
||||
3. **Restart on ALL settings changes** — simpler than mode-only distinction, and toggle changes don't propagate at runtime either
|
||||
4. **key() confirmed safe** — well-understood Compose pattern, scopes cancel, remember blocks reset
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
When user changes any Tor setting, show confirmation dialog. On confirm: save prefs → `appRestartKey++` → Compose unmounts App tree → remounts fresh. Window stays open, user stays logged in.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Toggling Tor at runtime breaks relay subscriptions. TCP connections switch but `DesktopRelaySubscriptionsCoordinator` doesn't re-subscribe. Hot-toggle requires deep quartz changes. Simpler: rebuild app state via `key()`.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: Add `appRestartKey` + TorManager DisposableEffect
|
||||
|
||||
**File: `Main.kt`**
|
||||
|
||||
At Window level (~line 187):
|
||||
```kotlin
|
||||
var appRestartKey by remember { mutableStateOf(0) }
|
||||
```
|
||||
|
||||
Wrap `App(...)` call (~line 413):
|
||||
```kotlin
|
||||
key(appRestartKey) {
|
||||
App(...)
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL: Add DisposableEffect for TorManager inside App:**
|
||||
```kotlin
|
||||
DisposableEffect(torManager) {
|
||||
onDispose {
|
||||
torManager.stopSync()
|
||||
activeTorManager = null
|
||||
}
|
||||
}
|
||||
```
|
||||
Without this, old Tor daemon keeps running → two Tor processes → port conflicts.
|
||||
|
||||
**Merge restart into onSettingsChanged** in CompositionLocalProvider:
|
||||
```kotlin
|
||||
TorState(
|
||||
status = currentTorStatus,
|
||||
settings = torSettings,
|
||||
onSettingsChanged = { newSettings ->
|
||||
torSettings = newSettings
|
||||
DesktopTorPreferences.save(newSettings)
|
||||
torTypeFlow.value = newSettings.torType
|
||||
externalPortFlow.value = newSettings.externalSocksPort
|
||||
// Restart app to apply Tor changes
|
||||
appRestartKey++
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
No separate `onRestartRequired` callback needed.
|
||||
|
||||
### Phase 2: Add confirmation dialog to TorSettingsSection
|
||||
|
||||
**File: `TorSettingsSection.kt`**
|
||||
|
||||
Before applying changes, show inline confirmation:
|
||||
```kotlin
|
||||
var pendingSettings by remember { mutableStateOf<TorSettings?>(null) }
|
||||
|
||||
// Mode selector onClick:
|
||||
onClick = {
|
||||
pendingSettings = currentSettings.copy(torType = torType)
|
||||
}
|
||||
|
||||
// Confirmation dialog:
|
||||
pendingSettings?.let { settings ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingSettings = null },
|
||||
title = { Text("Restart Required") },
|
||||
text = { Text("Changing Tor settings requires restarting. Your session will be briefly interrupted.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSettingsChanged(settings)
|
||||
pendingSettings = null
|
||||
}) { Text("Restart") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingSettings = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Add confirmation to TorSettingsDialog Save
|
||||
|
||||
**File: `TorSettingsDialog.kt`**
|
||||
|
||||
Same pattern — Save button shows confirmation before calling `onSettingsChanged`:
|
||||
```kotlin
|
||||
var showRestartConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
// Save button:
|
||||
TextButton(onClick = { showRestartConfirm = true }) { Text("Save") }
|
||||
|
||||
if (showRestartConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestartConfirm = false },
|
||||
title = { Text("Restart Required") },
|
||||
text = { Text("Tor changes require restarting. Proceed?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSettingsChanged(editSettings)
|
||||
onDismiss()
|
||||
}) { Text("Restart") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Cleanup dead code
|
||||
|
||||
Remove from `Main.kt`:
|
||||
- Any reconnect-related code (torReconnectJob, evictConnections calls)
|
||||
- Debug logging (TorReconnect log lines)
|
||||
|
||||
Remove `evictConnections()` from `DesktopHttpClient.kt` (YAGNI — add back when needed).
|
||||
|
||||
## What Survives the key() Restart
|
||||
|
||||
| Component | Survives? | Why |
|
||||
|-----------|-----------|-----|
|
||||
| AccountManager | Yes | Outside App, at Window level |
|
||||
| DeckState (columns) | Yes | Outside App, at Window level |
|
||||
| Window position/size | Yes | windowState is outside App |
|
||||
| Login session | Yes | AccountManager persists |
|
||||
| Tor settings | Yes | Saved to java.util.prefs before restart |
|
||||
| CoroutineScope | No — recreated | DisposableEffect cancels old |
|
||||
| TorManager | No — recreated | DisposableEffect stops daemon |
|
||||
| RelayManager | No — recreated | DisposableEffect disconnects |
|
||||
| Subscriptions | No — recreated | DisposableEffect clears |
|
||||
| Coil ImageLoader | Yes | Process-global singleton, not in Compose tree |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Any Tor setting change shows "Restart Required" dialog
|
||||
- [ ] Cancel reverts — no settings change, no restart
|
||||
- [ ] Confirm saves prefs + rebuilds app (window stays open)
|
||||
- [ ] User stays logged in after rebuild
|
||||
- [ ] Column layout preserved after rebuild
|
||||
- [ ] Relays connected with correct mode after rebuild
|
||||
- [ ] Feed loads after rebuild
|
||||
- [ ] Old Tor daemon stopped before new one starts (no process leak)
|
||||
- [ ] Shield icon correct after rebuild
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Main.kt` | Add `appRestartKey`, `key()` wrapper, `DisposableEffect` for TorManager, restart in `onSettingsChanged` |
|
||||
| `TorSettingsSection.kt` | Confirmation dialog before applying mode change |
|
||||
| `TorSettingsDialog.kt` | Confirmation dialog before Save |
|
||||
|
||||
**3 files changed, 0 new files, ~40 lines added.**
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user