diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 29f3da9d8..3668f8ca5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,9 @@ jobs:
with:
tool: just
+ - name: Set up repository
+ run: just setup
+
- name: Setup Java
uses: actions/setup-java@v5
with:
@@ -106,6 +109,9 @@ jobs:
with:
tool: just
+ - name: Set up repository
+ run: just setup
+
- name: Build Rust FFI bindings
run: just build-ios
@@ -140,6 +146,12 @@ jobs:
with:
toolchain: stable
+ - name: Set up just
+ uses: extractions/setup-just@v1
+
+ - name: Set up repository
+ run: just setup
+
- name: Run tests
run: cd rust && cargo test --workspace
@@ -153,4 +165,10 @@ jobs:
components: clippy
override: true
+ - name: Set up just
+ uses: extractions/setup-just@v1
+
+ - name: Set up repository
+ run: just setup
+
- run: cd rust && cargo clippy -- -D warnings
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..802c3f7f4
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "rust/external/bdk"]
+ path = rust/external/bdk
+ url = https://github.com/bitcoindevkit/bdk.git
+ shallow = true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b8e23a26c..03bbca77d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,11 +24,14 @@ The `COVE_KEYSTORE_*` variables in `.envrc.example` are only needed for signed A
## Quick Start
1. Clone the repository
-2. Build the Rust library and bindings:
+2. Run setup for submodules:
+ - `just setup`
+ - This initializes `rust/external/bdk` and applies project patches from `rust/patches/bdk/`
+3. Build the Rust library and bindings:
- iOS: `just build-ios` (`just bi`) for simulator or `just build-ios-debug-device` (`just bidd`) for device
- Android: `just build-android` (`just ba`)
-3. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`)
-4. Build and run
+4. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`)
+5. Build and run
## Release Builds
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ebad1a184..0396da315 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,10 @@
+
+
+
+
Network->Node navigation
+ var pendingNodeUrl by mutableStateOf("")
+ var pendingNodeName by mutableStateOf("")
+ var pendingNodeTypeName by mutableStateOf("")
+ var pendingNodeAwaitingTorSetup by mutableStateOf(false)
+ var pendingNodeTorValidated by mutableStateOf(false)
+
// prices and fees
var prices: PriceResponse? by mutableStateOf(runCatching { rust.prices() }.getOrNull())
private set
@@ -99,6 +107,7 @@ class AppManager private constructor() : FfiReconcile {
Log.d(tag, "Initializing AppManager")
rust.listenForUpdates(this)
wallets = runCatching { Database().wallets().all() }.getOrElse { emptyList() }
+ warmupTorIfConfigured()
}
/**
@@ -533,6 +542,29 @@ class AppManager private constructor() : FfiReconcile {
.onFailure { Log.e(tag, "Unable to dispatch app action $action", it) }
}
+ private fun warmupTorIfConfigured() {
+ val globalConfig = database.globalConfig()
+ if (!globalConfig.useTor()) {
+ return
+ }
+
+ val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull()
+ val mode = parseCoreTorMode(modeName)
+ if (mode != TorMode.BUILT_IN) {
+ Log.d(tag, "Tor enabled with mode=$mode; no built-in warmup needed")
+ return
+ }
+
+ mainScope.launch(Dispatchers.IO) {
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ Log.d(tag, "Built-in Tor warmup started at $endpoint")
+ }.onFailure { error ->
+ Log.e(tag, "Failed to warm up built-in Tor on launch: ${error.message}", error)
+ }
+ }
+ }
+
companion object {
@Volatile
private var instance: AppManager? = null
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
index 14ef7be6e..754307057 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
@@ -59,6 +59,7 @@ 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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -81,10 +82,15 @@ import org.bitcoinppl.cove.cloudbackup.CloudBackupPresentationHost
import org.bitcoinppl.cove.cloudbackup.ForegroundUiBridge
import org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingContainer
import org.bitcoinppl.cove.flows.TapSignerFlow.TapSignerContainer
+import org.bitcoinppl.cove.flows.SettingsFlow.OrbotPackageHelper
+import org.bitcoinppl.cove.flows.SettingsFlow.isOnionNodeUrl
+import org.bitcoinppl.cove.flows.SettingsFlow.switchToFirstClearnetPresetNode
import org.bitcoinppl.cove.navigation.CoveNavDisplay
import org.bitcoinppl.cove.nfc.NfcScanSheet
import org.bitcoinppl.cove.nfc.TapCardNfcManager
import org.bitcoinppl.cove.sidebar.SidebarContainer
+import org.bitcoinppl.cove.tor.parseCoreTorMode
+import org.bitcoinppl.cove.tor.testSocksEndpoint
import org.bitcoinppl.cove.ui.theme.CoveTheme
import org.bitcoinppl.cove.views.LockView
import org.bitcoinppl.cove_core.bootstrap
@@ -110,9 +116,11 @@ import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.RouteFactory
import org.bitcoinppl.cove_core.SettingsRoute
import org.bitcoinppl.cove_core.TapSignerRoute
+import org.bitcoinppl.cove_core.TorMode
import org.bitcoinppl.cove_core.Wallet
import org.bitcoinppl.cove_core.WalletType
import org.bitcoinppl.cove_core.CloudBackupStatus
+import org.bitcoinppl.cove_core.NodeSelector
import org.bitcoinppl.cove_core.types.ColorSchemeSelection
internal enum class StartupMode {
@@ -383,6 +391,97 @@ class MainActivity : FragmentActivity() {
null
}
}
+ val context = LocalContext.current
+ val uiScope = rememberCoroutineScope()
+ var startupTorUnavailableMode by remember { mutableStateOf(null) }
+ var startupTorUnavailableEndpoint by remember { mutableStateOf("") }
+ var startupTorCheckCompleted by remember { mutableStateOf(false) }
+
+ suspend fun fallbackToClearnetAndDisableTor() {
+ var fallbackNodeName: String? = null
+ if (isOnionNodeUrl(app.selectedNode.url)) {
+ val selector = NodeSelector()
+ try {
+ val fallbackResult = switchToFirstClearnetPresetNode(selector)
+ if (fallbackResult.isFailure) {
+ val reason =
+ fallbackResult.exceptionOrNull()?.message
+ ?: "unknown error"
+ app.alertState =
+ TaggedItem(
+ AppAlertState.General(
+ title = "Fallback failed",
+ message = "Could not switch to a clearnet node: $reason",
+ ),
+ )
+ return
+ }
+ fallbackNodeName = fallbackResult.getOrThrow().name
+ } finally {
+ selector.close()
+ }
+ }
+
+ app.pendingNodeAwaitingTorSetup = false
+ app.pendingNodeTorValidated = false
+ app.pendingNodeUrl = ""
+ app.pendingNodeName = ""
+ app.pendingNodeTypeName = ""
+ app.database.globalConfig().setUseTor(false)
+
+ val confirmation =
+ if (fallbackNodeName != null) {
+ "Switched to clearnet node \"$fallbackNodeName\" and disabled Tor."
+ } else {
+ "Disabled Tor."
+ }
+ app.alertState =
+ TaggedItem(
+ AppAlertState.General(
+ title = "Tor disabled",
+ message = confirmation,
+ ),
+ )
+ }
+
+ LaunchedEffect(app.isTermsAccepted) {
+ if (!app.isTermsAccepted || startupTorCheckCompleted) {
+ return@LaunchedEffect
+ }
+ startupTorCheckCompleted = true
+
+ val globalConfig = app.database.globalConfig()
+ val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false)
+ if (!useTor) {
+ return@LaunchedEffect
+ }
+
+ val mode = parseCoreTorMode(runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull())
+
+ when (mode) {
+ TorMode.BUILT_IN -> Unit
+ TorMode.ORBOT -> {
+ val reachable = testSocksEndpoint("127.0.0.1", 9050, 1500).isSuccess
+ if (!reachable) {
+ startupTorUnavailableMode = TorMode.ORBOT
+ startupTorUnavailableEndpoint = "127.0.0.1:9050"
+ }
+ }
+ TorMode.EXTERNAL -> {
+ val host =
+ runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) }
+ .getOrNull()
+ ?.takeIf { it.isNotBlank() }
+ ?: "127.0.0.1"
+ val port = runCatching { globalConfig.torExternalPort().toInt() }.getOrDefault(9050)
+ val reachable = testSocksEndpoint(host, port, 1500).isSuccess
+ if (!reachable) {
+ startupTorUnavailableMode = TorMode.EXTERNAL
+ startupTorUnavailableEndpoint = "$host:$port"
+ }
+ }
+ }
+ }
// compute dark theme based on user preference
val systemDarkTheme = isSystemInDarkTheme()
@@ -457,6 +556,85 @@ class MainActivity : FragmentActivity() {
)
}
}
+
+ if (startupTorUnavailableMode != null) {
+ AlertDialog(
+ onDismissRequest = {
+ startupTorUnavailableMode = null
+ startupTorUnavailableEndpoint = ""
+ },
+ title = {
+ Text(
+ when (startupTorUnavailableMode) {
+ TorMode.ORBOT -> "Orbot is not active"
+ TorMode.EXTERNAL -> "External Tor proxy is unavailable"
+ else -> "Tor proxy unavailable"
+ },
+ )
+ },
+ text = {
+ Text(
+ when (startupTorUnavailableMode) {
+ TorMode.ORBOT ->
+ "Tor mode is set to Orbot, but $startupTorUnavailableEndpoint is unavailable. " +
+ "Start Orbot or switch to a clearnet node and disable Tor."
+ TorMode.EXTERNAL ->
+ "Tor mode is set to external SOCKS5, but $startupTorUnavailableEndpoint is unavailable. " +
+ "Fix proxy settings or switch to a clearnet node."
+ else -> "Tor proxy is unavailable."
+ },
+ )
+ },
+ confirmButton = {
+ Column(horizontalAlignment = Alignment.End) {
+ if (startupTorUnavailableMode == TorMode.ORBOT) {
+ TextButton(
+ onClick = {
+ startupTorUnavailableMode = null
+ startupTorUnavailableEndpoint = ""
+ val opened = OrbotPackageHelper.openOrbot(context)
+ if (!opened) {
+ OrbotPackageHelper.openInstallPage(context)
+ }
+ },
+ ) {
+ Text("Open Orbot")
+ }
+ }
+ if (startupTorUnavailableMode == TorMode.EXTERNAL) {
+ TextButton(
+ onClick = {
+ startupTorUnavailableMode = null
+ startupTorUnavailableEndpoint = ""
+ app.pushRoute(Route.Settings(SettingsRoute.Network))
+ },
+ ) {
+ Text("Open network settings")
+ }
+ }
+ TextButton(
+ onClick = {
+ startupTorUnavailableMode = null
+ startupTorUnavailableEndpoint = ""
+ uiScope.launch {
+ fallbackToClearnetAndDisableTor()
+ }
+ },
+ ) {
+ Text("Use clearnet node")
+ }
+ TextButton(
+ onClick = {
+ startupTorUnavailableMode = null
+ startupTorUnavailableEndpoint = ""
+ },
+ ) {
+ Text("Ignore")
+ }
+ }
+ },
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt
index 3c37a7cb4..9edf1c203 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt
@@ -254,6 +254,7 @@ class WalletManager :
}
}
}
+ errorAlert = null
}
is WalletManagerReconcileMessage.UpdatedTransactions -> {
@@ -264,14 +265,17 @@ class WalletManager :
is WalletLoadState.LOADED ->
WalletLoadState.LOADED(message.v1)
}
+ errorAlert = null
}
is WalletManagerReconcileMessage.ScanComplete -> {
loadState = WalletLoadState.LOADED(message.v1)
+ errorAlert = null
}
is WalletManagerReconcileMessage.WalletBalanceChanged -> {
balance = message.v1
+ errorAlert = null
}
is WalletManagerReconcileMessage.UnsignedTransactionsChanged -> {
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt
index 5ed10714d..bbfad1667 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt
@@ -82,6 +82,8 @@ fun SelectedWalletContainer(
app.alertState = TaggedItem(
AppAlertState.WalletDatabaseCorrupted(walletId = e.`id`, error = e.`error`)
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
android.util.Log.e(tag, "something went very wrong", e)
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt
index 838b277d3..75922064d 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt
@@ -14,10 +14,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCode2
@@ -49,10 +59,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@@ -70,16 +84,112 @@ import org.bitcoinppl.cove.ui.theme.ForceLightStatusBarIcons
import org.bitcoinppl.cove.views.AutoSizeText
import org.bitcoinppl.cove.views.BitcoinShieldIcon
import org.bitcoinppl.cove_core.FiatOrBtc
+import org.bitcoinppl.cove_core.GlobalConfigKey
import org.bitcoinppl.cove_core.HotWalletRoute
import org.bitcoinppl.cove_core.NewWalletRoute
import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.SettingsRoute
+import org.bitcoinppl.cove_core.TorMode
+import org.bitcoinppl.cove_core.WalletErrorAlert
import org.bitcoinppl.cove_core.WalletManagerAction
import org.bitcoinppl.cove_core.WalletSettingsRoute
import org.bitcoinppl.cove_core.WalletType
+import org.bitcoinppl.cove_core.builtInTorBootstrapStatus
+import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap
+import org.bitcoinppl.cove_core.torConnectionLogs
import org.bitcoinppl.cove_core.types.WalletId
+import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot
+import org.bitcoinppl.cove.tor.parseCoreTorMode
+import org.bitcoinppl.cove.tor.testSocksEndpoint
import java.util.concurrent.atomic.AtomicBoolean
+private enum class TorStatusDot {
+ Green,
+ Yellow,
+ Red,
+ Gray,
+}
+
+private data class TorQuickStatus(
+ val enabled: Boolean = false,
+ val overall: TorStatusDot = TorStatusDot.Gray,
+ val torConnection: TorStatusDot = TorStatusDot.Gray,
+ val nodeReachable: TorStatusDot = TorStatusDot.Gray,
+ val nodeSynced: TorStatusDot = TorStatusDot.Gray,
+ val torMessage: String = "",
+ val nodeMessage: String = "",
+ val syncMessage: String = "",
+ val logs: List = emptyList(),
+)
+
+private fun TorStatusDot.color(): Color =
+ when (this) {
+ TorStatusDot.Green -> Color(0xFF34C759)
+ TorStatusDot.Yellow -> Color(0xFFFFC107)
+ TorStatusDot.Red -> Color(0xFFFF3B30)
+ TorStatusDot.Gray -> Color(0xFF9E9E9E)
+ }
+
+private fun recentTorQuickLogs(logs: List): List {
+ val usefulMarkers =
+ listOf(
+ "arti_client::status",
+ "tor_dirmgr",
+ "tor_guardmgr",
+ "tor_runtime",
+ "bootstrapped",
+ "bootstrap",
+ "directory",
+ "consensus",
+ "microdescriptors",
+ "failed",
+ "error",
+ "warn",
+ )
+
+ return logs
+ .asSequence()
+ .filter { line ->
+ usefulMarkers.any { marker -> line.contains(marker, ignoreCase = true) }
+ }.map { line ->
+ line.replace(Regex("""^\[(INFO|WARN|ERROR|DEBUG) [^\]]+]\s*"""), "")
+ }.filter { line -> line.isNotBlank() }
+ .distinct()
+ .toList()
+ .takeLast(6)
+}
+
+private fun leadingPercent(message: String): Int? =
+ Regex("""^(\d{1,3})%:""")
+ .find(message)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toIntOrNull()
+ ?.coerceIn(0, 100)
+
+private fun parseEndpointHostPort(endpoint: String): Pair? {
+ if (endpoint.startsWith('[')) {
+ val closingBracket = endpoint.indexOf(']')
+ if (closingBracket <= 1) {
+ return null
+ }
+ val host = endpoint.substring(1, closingBracket)
+ val portSeparator = endpoint.indexOf(':', startIndex = closingBracket + 1)
+ val port = endpoint.substring(closingBracket + 2).toIntOrNull()
+ if (portSeparator != closingBracket + 1 || host.isBlank() || port == null || port <= 0) {
+ return null
+ }
+ return host to port
+ }
+
+ val host = endpoint.substringBefore(':', "")
+ val port = endpoint.substringAfter(':', "").toIntOrNull()
+ if (host.isBlank() || port == null || port <= 0) {
+ return null
+ }
+ return host to port
+}
+
@Preview(showBackground = true, backgroundColor = 0xFF0D1B2A)
@Composable
private fun SelectedWalletFunctionalPreview(
@@ -176,6 +286,203 @@ fun SelectedWalletScreen(
var showRenameMenu by remember { mutableStateOf(false) }
val isColdWallet = manager.walletMetadata?.walletType == WalletType.COLD
val isWatchOnly = manager.walletMetadata?.walletType == WalletType.WATCH_ONLY
+ val globalConfig = remember(app) { app.database.globalConfig() }
+ var showTorStatusMenu by remember { mutableStateOf(false) }
+ var builtInWarmupRequested by remember { mutableStateOf(false) }
+ var builtInEndpoint by remember { mutableStateOf("127.0.0.1:39050") }
+ val torBusyBlinkTransition = rememberInfiniteTransition(label = "torBusyBlink")
+ val torBusyBlinkAlpha =
+ torBusyBlinkTransition.animateFloat(
+ initialValue = 0.35f,
+ targetValue = 1f,
+ animationSpec =
+ infiniteRepeatable(
+ animation = tween(durationMillis = 1400, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "torBusyBlinkAlpha",
+ )
+ val torDisabledText = stringResource(R.string.selected_wallet_tor_status_disabled)
+ val torBuiltInReadyText = stringResource(R.string.selected_wallet_tor_status_built_in_ready)
+ val torOrbotReachableText = stringResource(R.string.selected_wallet_tor_status_orbot_reachable)
+ val torOrbotUnavailableText = stringResource(R.string.selected_wallet_tor_status_orbot_unavailable)
+ val torExternalReachableText = stringResource(R.string.selected_wallet_tor_status_external_reachable)
+ val torExternalUnavailableText = stringResource(R.string.selected_wallet_tor_status_external_unavailable)
+ val torBuiltInErrorText = stringResource(R.string.selected_wallet_tor_status_built_in_error)
+ val torBuiltInBootstrappingText = stringResource(R.string.selected_wallet_tor_status_built_in_bootstrapping)
+ val nodeReachableText = stringResource(R.string.selected_wallet_node_reachable)
+ val nodeConnectionFailedText = stringResource(R.string.selected_wallet_node_connection_failed)
+ val checkingNodeConnectionText = stringResource(R.string.selected_wallet_checking_node_connection)
+ val nodeStatusUnavailableText = stringResource(R.string.selected_wallet_node_status_unavailable)
+ val nodeSyncedText = stringResource(R.string.selected_wallet_node_synced)
+ val nodeSyncingText = stringResource(R.string.selected_wallet_node_syncing)
+ val nodeSyncFailedText = stringResource(R.string.selected_wallet_node_sync_failed)
+ val syncStatusUnavailableText = stringResource(R.string.selected_wallet_sync_status_unavailable)
+ val torQuickStatusDefaults =
+ remember(torDisabledText, nodeStatusUnavailableText, syncStatusUnavailableText) {
+ TorQuickStatus(
+ torMessage = torDisabledText,
+ nodeMessage = nodeStatusUnavailableText,
+ syncMessage = syncStatusUnavailableText,
+ )
+ }
+ var torQuickStatus by remember { mutableStateOf(torQuickStatusDefaults) }
+ val torOverallDotAlpha =
+ if (torQuickStatus.overall == TorStatusDot.Yellow) torBusyBlinkAlpha.value else 1f
+ LaunchedEffect(manager, app, globalConfig) {
+ while (true) {
+ val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false)
+ val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull()
+ val torMode = parseCoreTorMode(modeName)
+ val torLogs = runCatching { torConnectionLogs() }.getOrDefault(emptyList())
+ val bootstrap = deriveBuiltInBootstrapSnapshot(torLogs)
+ val builtInStatus = runCatching { builtInTorBootstrapStatus() }.getOrNull()
+
+ val torConnection: TorStatusDot
+ val torMessage: String
+ if (!useTor) {
+ torConnection = TorStatusDot.Red
+ torMessage = torDisabledText
+ } else {
+ when (torMode) {
+ TorMode.BUILT_IN -> {
+ if (!builtInWarmupRequested) {
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ builtInEndpoint = endpoint
+ builtInWarmupRequested = true
+ }
+ }
+ val (builtInHost, builtInPort) =
+ parseEndpointHostPort(builtInEndpoint)
+ ?: ("127.0.0.1" to 39050)
+ val socksReady = testSocksEndpoint(builtInHost, builtInPort, 1200).isSuccess
+ val hasStructuredStatus = builtInStatus?.launched == true
+ val bootstrapReady = builtInStatus?.ready ?: bootstrap.isReady
+ val bootstrapError =
+ builtInStatus?.lastError ?: if (!hasStructuredStatus && bootstrap.hasError) bootstrap.step else null
+ val bootstrapMessage =
+ bootstrapError
+ ?: builtInStatus
+ ?.blocked
+ ?.let { "Blocked: $it" }
+ ?: bootstrap
+ .step
+ .takeIf { leadingPercent(it) != null }
+ ?: builtInStatus
+ ?.message
+ ?.takeIf { hasStructuredStatus && it.isNotBlank() }
+ ?: bootstrap.step
+ val bootstrapPercent =
+ leadingPercent(bootstrapMessage)
+ ?: builtInStatus
+ ?.takeIf { hasStructuredStatus }
+ ?.percent
+ ?.toInt()
+ ?.coerceIn(0, 100)
+ ?: bootstrap.percent
+ torConnection =
+ when {
+ bootstrapReady && socksReady -> TorStatusDot.Green
+ bootstrapError != null -> TorStatusDot.Red
+ else -> TorStatusDot.Yellow
+ }
+ torMessage =
+ when (torConnection) {
+ TorStatusDot.Green -> torBuiltInReadyText
+ TorStatusDot.Red -> torBuiltInErrorText.format(bootstrapMessage)
+ else -> torBuiltInBootstrappingText.format(bootstrapPercent)
+ }
+ }
+ TorMode.ORBOT -> {
+ val socksReady = testSocksEndpoint("127.0.0.1", 9050, 1200).isSuccess
+ torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red
+ torMessage =
+ if (socksReady) {
+ torOrbotReachableText
+ } else {
+ torOrbotUnavailableText
+ }
+ }
+ TorMode.EXTERNAL -> {
+ val host =
+ runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) }
+ .getOrNull()
+ ?.takeIf { it.isNotBlank() }
+ ?: "127.0.0.1"
+ val port =
+ runCatching { globalConfig.torExternalPort().toInt() }
+ .getOrDefault(9050)
+ val socksReady = testSocksEndpoint(host, port, 1200).isSuccess
+ torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red
+ torMessage =
+ if (socksReady) {
+ torExternalReachableText
+ } else {
+ torExternalUnavailableText
+ }
+ }
+ }
+ }
+
+ val nodeReachable =
+ when {
+ useTor && torConnection == TorStatusDot.Yellow -> TorStatusDot.Yellow
+ manager.errorAlert is WalletErrorAlert.NodeConnectionFailed -> TorStatusDot.Red
+ manager.loadState is WalletLoadState.LOADING -> TorStatusDot.Yellow
+ else -> TorStatusDot.Green
+ }
+ val nodeMessage =
+ when (nodeReachable) {
+ TorStatusDot.Green -> nodeReachableText
+ TorStatusDot.Red -> nodeConnectionFailedText
+ TorStatusDot.Yellow -> checkingNodeConnectionText
+ TorStatusDot.Gray -> nodeStatusUnavailableText
+ }
+
+ val nodeSynced =
+ when {
+ useTor && torConnection == TorStatusDot.Yellow -> TorStatusDot.Yellow
+ manager.loadState is WalletLoadState.LOADED -> TorStatusDot.Green
+ manager.loadState is WalletLoadState.SCANNING -> TorStatusDot.Yellow
+ manager.loadState == WalletLoadState.LOADING -> TorStatusDot.Yellow
+ else -> TorStatusDot.Gray
+ }
+ val syncMessage =
+ when (nodeSynced) {
+ TorStatusDot.Green -> nodeSyncedText
+ TorStatusDot.Yellow -> nodeSyncingText
+ TorStatusDot.Red -> nodeSyncFailedText
+ TorStatusDot.Gray -> syncStatusUnavailableText
+ }
+
+ val overall =
+ when {
+ listOf(torConnection, nodeReachable, nodeSynced).all { it == TorStatusDot.Green } ->
+ TorStatusDot.Green
+ listOf(torConnection, nodeReachable, nodeSynced).any { it == TorStatusDot.Red } ->
+ TorStatusDot.Red
+ listOf(torConnection, nodeReachable, nodeSynced).all { it == TorStatusDot.Gray } ->
+ TorStatusDot.Gray
+ else -> TorStatusDot.Yellow
+ }
+
+ torQuickStatus =
+ TorQuickStatus(
+ enabled = useTor,
+ overall = overall,
+ torConnection = torConnection,
+ nodeReachable = nodeReachable,
+ nodeSynced = nodeSynced,
+ torMessage = torMessage,
+ nodeMessage = nodeMessage,
+ syncMessage = syncMessage,
+ logs = recentTorQuickLogs(torLogs),
+ )
+
+ delay(2500)
+ }
+ }
// force white status bar icons for midnight blue background
ForceLightStatusBarIcons()
@@ -254,27 +561,138 @@ fun SelectedWalletScreen(
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back",
+ contentDescription = stringResource(R.string.content_description_back),
)
}
} else {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.Menu,
- contentDescription = "Menu",
+ contentDescription = stringResource(R.string.content_description_menu),
)
}
}
},
actions = {
- Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (torQuickStatus.enabled) {
+ Box {
+ Row(
+ modifier =
+ Modifier
+ .padding(end = 4.dp)
+ .clickable { showTorStatusMenu = true }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.icon_tor_onion),
+ contentDescription = stringResource(R.string.content_description_tor_onion),
+ tint = Color.White,
+ modifier = Modifier.size(20.dp),
+ )
+ Icon(
+ imageVector = Icons.Filled.FiberManualRecord,
+ contentDescription = stringResource(R.string.content_description_tor_status),
+ tint = torQuickStatus.overall.color(),
+ modifier =
+ Modifier
+ .size(10.dp)
+ .alpha(torOverallDotAlpha),
+ )
+ }
+ DropdownMenu(
+ expanded = showTorStatusMenu,
+ onDismissRequest = { showTorStatusMenu = false },
+ modifier = Modifier.background(MaterialTheme.colorScheme.surface),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .width(280.dp)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.selected_wallet_tor_network_status),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ TorStatusRow(
+ stringResource(R.string.selected_wallet_tor_connection_title),
+ torQuickStatus.torConnection,
+ torQuickStatus.torMessage,
+ )
+ TorStatusRow(
+ stringResource(R.string.selected_wallet_node_reachable_title),
+ torQuickStatus.nodeReachable,
+ torQuickStatus.nodeMessage,
+ )
+ TorStatusRow(
+ stringResource(R.string.selected_wallet_node_synced_title),
+ torQuickStatus.nodeSynced,
+ torQuickStatus.syncMessage,
+ )
+ }
+
+ if (torQuickStatus.logs.isNotEmpty()) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(CoveColor.midnightBlue.copy(alpha = 0.05f))
+ .padding(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.selected_wallet_recent_logs),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ torQuickStatus.logs.forEach { line ->
+ Text(
+ text = line,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+
+ Text(
+ text = stringResource(R.string.selected_wallet_network_settings),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ showTorStatusMenu = false
+ app.pushRoute(Route.Settings(SettingsRoute.Network))
+ }.padding(vertical = 4.dp),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
IconButton(
onClick = onQrCode,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.QrCode2,
- contentDescription = "QR Code",
+ contentDescription = stringResource(R.string.content_description_qr_code),
)
}
IconButton(
@@ -283,7 +701,7 @@ fun SelectedWalletScreen(
) {
Icon(
imageVector = Icons.Filled.MoreVert,
- contentDescription = "More",
+ contentDescription = stringResource(R.string.content_description_more),
)
}
}
@@ -581,3 +999,61 @@ private fun TransactionsLoadingView(
}
}
}
+
+@Composable
+private fun TorStatusRow(
+ title: String,
+ dot: TorStatusDot,
+ subtitle: String,
+) {
+ val blinkTransition = rememberInfiniteTransition(label = "torRowBlink")
+ val blinkAlpha =
+ blinkTransition.animateFloat(
+ initialValue = 0.35f,
+ targetValue = 1f,
+ animationSpec =
+ infiniteRepeatable(
+ animation = tween(durationMillis = 1400, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "torRowBlinkAlpha",
+ )
+ val dotAlpha = if (dot == TorStatusDot.Yellow) blinkAlpha.value else 1f
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ letterSpacing = 0.5.sp,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ if (dot == TorStatusDot.Green) {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ tint = dot.color(),
+ modifier = Modifier.size(16.dp).alpha(dotAlpha),
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Filled.FiberManualRecord,
+ contentDescription = null,
+ tint = dot.color(),
+ modifier = Modifier.size(14.dp).alpha(dotAlpha),
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt
index a71fe32f9..797cce77f 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt
@@ -1,46 +1,129 @@
package org.bitcoinppl.cove.flows.SettingsFlow
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.FiberManualRecord
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Terminal
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.bitcoinppl.cove.R
import org.bitcoinppl.cove.views.MaterialDivider
import org.bitcoinppl.cove.views.MaterialSection
+import org.bitcoinppl.cove.views.MaterialSettingsItem
import org.bitcoinppl.cove.views.SectionHeader
+import org.bitcoinppl.cove_core.ApiType
import org.bitcoinppl.cove_core.AppAction
+import org.bitcoinppl.cove_core.Database
+import org.bitcoinppl.cove_core.GlobalConfigKey
+import org.bitcoinppl.cove_core.GlobalFlagKey
+import org.bitcoinppl.cove_core.Node
+import org.bitcoinppl.cove_core.NodeSelector
+import org.bitcoinppl.cove_core.NodeSelectorException
+import org.bitcoinppl.cove_core.TorMode as CoreTorMode
+import org.bitcoinppl.cove_core.builtInTorBootstrapStatus
+import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap
+import org.bitcoinppl.cove_core.torConnectionLogs
import org.bitcoinppl.cove_core.types.Network
import org.bitcoinppl.cove_core.types.allNetworks
+import org.bitcoinppl.cove.Log
+import org.bitcoinppl.cove.ui.theme.CoveColor
+import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot
+import org.bitcoinppl.cove.tor.parseCoreTorMode
+import org.bitcoinppl.cove.tor.testSocksEndpoint
+import org.bitcoinppl.cove.tor.testTorApiThroughSocks
+import java.net.SocketTimeoutException
+import java.time.LocalTime
+
+private enum class TorTestStepStatus {
+ Pending,
+ Running,
+ Passed,
+ Failed,
+}
+
+private data class TorTestStep(
+ val key: String,
+ val title: String,
+ val detail: String,
+ val status: TorTestStepStatus = TorTestStepStatus.Pending,
+)
+
+private data class TorConnectionTestState(
+ val running: Boolean = false,
+ val finished: Boolean = false,
+ val steps: List = emptyList(),
+ val logs: List = emptyList(),
+)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,15 +131,726 @@ fun NetworkSettingsScreen(
app: org.bitcoinppl.cove.AppManager,
modifier: Modifier = Modifier,
) {
+ val logTag = "NetworkSettingsScreen"
+ val context = LocalContext.current
+ val clipboardManager = LocalClipboardManager.current
val networks = remember { allNetworks() }
val selectedNetwork = app.selectedNetwork
var pendingNetworkChange by remember { mutableStateOf(null) }
+ val database = remember { Database() }
+ val globalConfig = remember { database.globalConfig() }
+ val globalFlag = remember { database.globalFlag() }
+ val nodeSelector = remember { NodeSelector() }
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val persistedUseTor = remember { runCatching { globalConfig.useTor() }.getOrDefault(false) }
+ val torSettingsDiscovered = remember {
+ persistedUseTor ||
+ runCatching {
+ globalFlag.getBoolConfig(GlobalFlagKey.TOR_SETTINGS_DISCOVERED)
+ }.getOrDefault(false)
+ }
+ val persistedTorMode = remember {
+ parseTorMode(runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull())
+ }
+ val persistedExternalHost = remember {
+ runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) }.getOrNull()
+ ?.takeIf { it.isNotBlank() }
+ ?: "127.0.0.1"
+ }
+ val persistedExternalPort = remember {
+ runCatching { globalConfig.torExternalPort() }.getOrDefault(9050).toString()
+ }
+
+ var uiState by
+ remember {
+ mutableStateOf(
+ TorUiState(
+ enabled = persistedUseTor,
+ mode = persistedTorMode,
+ externalHost = persistedExternalHost,
+ externalPort = persistedExternalPort,
+ externalValidationError = validateExternalConfig(persistedExternalHost, persistedExternalPort),
+ ),
+ )
+ }
+ var showFullLogDialog by remember { mutableStateOf(false) }
+ var showModeDialog by remember { mutableStateOf(false) }
+ var showTorTestDialog by remember { mutableStateOf(false) }
+ var showDisableTorOnionDialog by remember { mutableStateOf(false) }
+ var rustTorLogCount by remember { mutableStateOf(0) }
+ var builtInWarmupRequested by remember { mutableStateOf(false) }
+ var torTestState by remember { mutableStateOf(TorConnectionTestState()) }
+ var autoPendingTestKey by remember { mutableStateOf(null) }
+
+ fun appendTorLog(message: String) {
+ val timestamp = LocalTime.now().withNano(0)
+ val entry = "[$timestamp] $message"
+ val merged = (uiState.logLines + entry).takeLast(300)
+ uiState = uiState.copy(latestLogLine = message, logLines = merged)
+ }
+
+ fun syncRustTorLogs() {
+ if (!uiState.enabled || uiState.mode != TorMode.BuiltIn) {
+ return
+ }
+
+ runCatching { torConnectionLogs() }
+ .onSuccess { rustLogs ->
+ if (rustLogs.size < rustTorLogCount) {
+ rustTorLogCount = 0
+ }
+
+ val newLogs = rustLogs.drop(rustTorLogCount)
+ rustTorLogCount = rustLogs.size
+
+ val merged =
+ if (newLogs.isEmpty()) {
+ uiState.logLines
+ } else {
+ (uiState.logLines + newLogs).takeLast(300)
+ }
+ val snapshot = deriveBuiltInBootstrapSnapshot(rustLogs)
+ val structuredStatus = runCatching { builtInTorBootstrapStatus() }.getOrNull()
+ val hasStructuredStatus = structuredStatus?.launched == true
+ val status =
+ when {
+ !uiState.enabled -> TorStatus.Disabled
+ structuredStatus?.ready == true -> TorStatus.Ready
+ structuredStatus?.lastError != null -> TorStatus.Error
+ hasStructuredStatus -> TorStatus.Bootstrapping
+ snapshot.isReady -> TorStatus.Ready
+ snapshot.hasError -> TorStatus.Error
+ else -> TorStatus.Bootstrapping
+ }
+ val currentStep =
+ structuredStatus
+ ?.lastError
+ ?: structuredStatus
+ ?.blocked
+ ?.let { "Blocked: $it" }
+ ?: snapshot
+ .step
+ .takeIf { Regex("""^\d{1,3}%:""").containsMatchIn(it) }
+ ?: structuredStatus
+ ?.message
+ ?.takeIf { hasStructuredStatus && it.isNotBlank() }
+ ?: snapshot.step
+ val messagePercent =
+ Regex("""^(\d{1,3})%:""")
+ .find(currentStep)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toIntOrNull()
+ ?.coerceIn(0, 100)
+ val structuredPercent =
+ structuredStatus
+ ?.takeIf { hasStructuredStatus }
+ ?.percent
+ ?.toInt()
+ ?.coerceIn(0, 100)
+ val progressPercent =
+ when {
+ status == TorStatus.Ready -> 100
+ else -> messagePercent ?: structuredPercent ?: snapshot.percent
+ }
+
+ uiState =
+ uiState.copy(
+ status = status,
+ currentStep = currentStep,
+ latestLogLine = snapshot.lastLine,
+ logLines = merged,
+ progressPercent = progressPercent,
+ )
+ }.onFailure { error ->
+ Log.e(logTag, "syncRustTorLogs failed: ${error.message}", error)
+ }
+ }
+
+ fun clearPendingOnionDraft() {
+ app.pendingNodeAwaitingTorSetup = false
+ app.pendingNodeTorValidated = false
+ app.pendingNodeUrl = ""
+ app.pendingNodeName = ""
+ app.pendingNodeTypeName = ""
+ autoPendingTestKey = null
+ }
+
+ fun disableTorState() {
+ runCatching {
+ globalConfig.setUseTor(false)
+ }.onFailure { error ->
+ appendTorLog("Failed to disable Tor: ${error.message ?: "unknown error"}")
+ scope.launch {
+ snackbarHostState.showSnackbar("Could not disable Tor: ${error.message ?: "unknown error"}")
+ }
+ return
+ }
+
+ uiState = uiState.copy(enabled = false)
+ builtInWarmupRequested = false
+ }
+
+ fun enableTorState(): Boolean =
+ runCatching {
+ globalConfig.setUseTor(true)
+ }.onFailure { error ->
+ appendTorLog("Failed to enable Tor: ${error.message ?: "unknown error"}")
+ scope.launch {
+ snackbarHostState.showSnackbar("Could not enable Tor: ${error.message ?: "unknown error"}")
+ }
+ }.isSuccess
+
+ fun setPersistedTorMode(
+ mode: TorMode,
+ onFailure: (Throwable) -> Unit = {},
+ ): Boolean {
+ val persistedMode =
+ when (mode) {
+ TorMode.BuiltIn -> CoreTorMode.BUILT_IN.name
+ TorMode.Orbot -> CoreTorMode.ORBOT.name
+ TorMode.External -> CoreTorMode.EXTERNAL.name
+ }
+
+ return runCatching {
+ globalConfig.set(GlobalConfigKey.TorMode, persistedMode)
+ }.onFailure { error ->
+ appendTorLog("Failed to save Tor mode: ${error.message ?: "unknown error"}")
+ scope.launch {
+ snackbarHostState.showSnackbar("Could not save Tor mode: ${error.message ?: "unknown error"}")
+ }
+ onFailure(error)
+ }.isSuccess
+ }
+
+ fun saveExternalTorConfig(
+ host: String,
+ port: UShort,
+ proxyLog: String = redactedProxyForLog(host, port),
+ ): Boolean =
+ runCatching {
+ globalConfig.set(GlobalConfigKey.TorExternalHost, host)
+ globalConfig.setTorExternalPort(port)
+ }.onFailure { error ->
+ appendTorLog("Failed to save external Tor config $proxyLog: ${error.message ?: "unknown error"}")
+ scope.launch {
+ snackbarHostState.showSnackbar("Could not save Tor proxy configuration: ${error.message ?: "unknown error"}")
+ }
+ }.isSuccess
+
+ fun persistTorTestConfiguration(host: String, port: UShort): Boolean {
+ if (!setPersistedTorMode(uiState.mode)) {
+ return false
+ }
+ if (uiState.mode == TorMode.External && !saveExternalTorConfig(host, port)) {
+ return false
+ }
+ return enableTorState()
+ }
+
+ suspend fun testTorProxy(host: String, port: UShort): Result =
+ try {
+ val proxyLog = redactedProxyForLog(host, port)
+ syncRustTorLogs()
+ appendTorLog("Testing SOCKS endpoint $proxyLog")
+ Log.d(logTag, "testTorProxy start: proxy=$proxyLog")
+ testSocksEndpoint(host, port.toInt()).getOrThrow()
+ Log.d(logTag, "testTorProxy success: proxy=$proxyLog")
+ appendTorLog("SOCKS endpoint reachable: $proxyLog")
+ syncRustTorLogs()
+ Result.success(Unit)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ val proxyLog = redactedProxyForLog(host, port)
+ Log.e(logTag, "testTorProxy failed: proxy=$proxyLog, reason=${error.message}", error)
+ appendTorLog("SOCKS endpoint failed: $proxyLog (${error.message ?: "unknown error"})")
+ syncRustTorLogs()
+ Result.failure(error)
+ }
+
+ suspend fun resolveNodeForTorTest(): Node {
+ val (node, logMessage) =
+ withContext(Dispatchers.IO) {
+ if (app.pendingNodeAwaitingTorSetup && app.pendingNodeUrl.isNotBlank()) {
+ val typeName = app.pendingNodeTypeName.ifBlank { "Custom Electrum" }
+ Log.d(logTag, "resolveNodeForTorTest using pending node: type=$typeName, ${redactedEndpointForLog(app.pendingNodeUrl)}")
+ val parsed = nodeSelector.parseCustomNode(app.pendingNodeUrl, typeName, app.pendingNodeName)
+ parsed to "Using pending node for Tor test: ${redactedNodeForLog(parsed)}"
+ } else {
+ val selected = app.selectedNode
+ Log.d(logTag, "resolveNodeForTorTest using selected node: ${redactedNodeForLog(selected)}")
+ selected to "Using selected node for Tor test: ${redactedNodeForLog(selected)}"
+ }
+ }
+
+ appendTorLog(logMessage)
+ return node
+ }
+
+ suspend fun runNodeTorTest(node: Node): Result =
+ try {
+ syncRustTorLogs()
+ val nodeLog = redactedNodeForLog(node)
+ appendTorLog("Checking node via Tor: $nodeLog")
+ Log.d(logTag, "runNodeTorTest start: $nodeLog")
+ withContext(Dispatchers.IO) {
+ nodeSelector.checkSelectedNode(node)
+ }
+ Log.d(logTag, "runNodeTorTest success: $nodeLog")
+ appendTorLog("Node check passed: $nodeLog")
+ syncRustTorLogs()
+ Result.success(Unit)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ val nodeLog = redactedNodeForLog(node)
+ Log.e(logTag, "runNodeTorTest failed: $nodeLog, reason=${error.message}", error)
+ appendTorLog("Node check failed: $nodeLog (${error.message ?: "unknown error"})")
+ syncRustTorLogs()
+ Result.failure(error)
+ }
+
+ fun appendTorTestLog(message: String) {
+ torTestState = torTestState.copy(logs = (torTestState.logs + message).takeLast(150))
+ }
+
+ fun updateTorTestStep(
+ stepKey: String,
+ status: TorTestStepStatus,
+ detail: String? = null,
+ ) {
+ torTestState =
+ torTestState.copy(
+ steps =
+ torTestState.steps.map { step ->
+ if (step.key != stepKey) {
+ step
+ } else {
+ step.copy(
+ status = status,
+ detail = detail ?: step.detail,
+ )
+ }
+ },
+ )
+ }
+
+ fun parseEndpointHostPort(endpoint: String): Pair? {
+ val host = endpoint.substringBefore(':', "")
+ val port = endpoint.substringAfter(':', "").toIntOrNull()
+ if (host.isBlank() || port == null || port !in 1..65535) {
+ return null
+ }
+ return host to port.toUShort()
+ }
+
+ suspend fun runTorApiTestWithRetries(
+ host: String,
+ port: Int,
+ ): Result {
+ val maxAttempts = 5
+ var timeoutMs = 15000
+ var lastError: Throwable? = null
+
+ repeat(maxAttempts) { index ->
+ val attempt = index + 1
+ appendTorTestLog("Tor API check attempt $attempt/$maxAttempts (timeout=${timeoutMs}ms)")
+
+ val result = testTorApiThroughSocks(host, port, timeoutMs)
+ if (result.isSuccess) {
+ return result
+ }
+
+ val error = result.exceptionOrNull()
+ lastError = error
+ val timeoutLike =
+ error is SocketTimeoutException ||
+ (error?.message?.lowercase()?.contains("timeout") == true) ||
+ (error?.message?.lowercase()?.contains("timed out") == true)
+
+ if (!timeoutLike || attempt >= maxAttempts) {
+ return Result.failure(error ?: IllegalStateException("unknown tor api error"))
+ }
+
+ val snapshot = deriveBuiltInBootstrapSnapshot(runCatching { torConnectionLogs() }.getOrDefault(emptyList()))
+ appendTorTestLog(
+ "Tor API timed out; retrying while Tor bootstraps (${snapshot.percent}% ${snapshot.step.lowercase()})",
+ )
+ syncRustTorLogs()
+ delay((attempt * 1200L).coerceAtMost(6000L))
+ timeoutMs = (timeoutMs + 4000).coerceAtMost(30000)
+ }
+
+ return Result.failure(lastError ?: IllegalStateException("unknown tor api error"))
+ }
+
+ suspend fun runProgressiveTorTest() {
+ val endpoint: Pair =
+ try {
+ when (uiState.mode) {
+ TorMode.BuiltIn -> {
+ val endpointValue = ensureBuiltInTorBootstrap()
+ parseEndpointHostPort(endpointValue)
+ ?: ("127.0.0.1" to 39050u.toUShort())
+ }
+ TorMode.Orbot -> "127.0.0.1" to 9050u.toUShort()
+ TorMode.External -> {
+ val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort)
+ if (validationError != null) {
+ uiState = uiState.copy(externalValidationError = validationError)
+ snackbarHostState.showSnackbar(validationError)
+ return
+ }
+ uiState.externalHost to (uiState.externalPort.toUShortOrNull() ?: 9050u.toUShort())
+ }
+ }
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ appendTorLog("Failed to prepare Tor endpoint: ${error.message ?: "unknown error"}")
+ snackbarHostState.showSnackbar("Failed to prepare Tor endpoint: ${error.message ?: "unknown error"}")
+ return
+ }
+
+ val (host, port) = endpoint
+ if (!persistTorTestConfiguration(host, port)) {
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ return
+ }
+
+ val proxyLog = redactedProxyForLog(host, port)
+ showTorTestDialog = true
+ torTestState =
+ TorConnectionTestState(
+ running = true,
+ finished = false,
+ steps =
+ listOf(
+ TorTestStep(
+ key = "proxy",
+ title = "Tor proxy reachable",
+ detail = "Checking SOCKS endpoint $proxyLog",
+ ),
+ TorTestStep(
+ key = "api",
+ title = "Tor API reports Tor exit",
+ detail = "Checking torproject API over SOCKS",
+ ),
+ TorTestStep(
+ key = "node",
+ title = "Node reachable via Tor",
+ detail = "Checking selected node over Tor",
+ ),
+ ),
+ logs = listOf("Starting Tor connection test (${uiState.mode})"),
+ )
+
+ when (uiState.mode) {
+ TorMode.BuiltIn -> {
+ if (!builtInWarmupRequested) {
+ builtInWarmupRequested = true
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpointValue ->
+ appendTorTestLog("Built-in Tor warmup requested at $endpointValue")
+ }.onFailure { error ->
+ appendTorTestLog("Built-in Tor warmup failed: ${error.message}")
+ }
+ }
+ }
+ TorMode.Orbot,
+ TorMode.External,
+ -> Unit
+ }
+
+ updateTorTestStep("proxy", TorTestStepStatus.Running)
+ val proxyResult = testTorProxy(host, port)
+ if (proxyResult.isFailure) {
+ val message = proxyResult.exceptionOrNull()?.message ?: "unknown error"
+ updateTorTestStep("proxy", TorTestStepStatus.Failed, "SOCKS check failed: $message")
+ appendTorTestLog("SOCKS check failed: $message")
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ torTestState = torTestState.copy(running = false, finished = true)
+ snackbarHostState.showSnackbar("Tor proxy unavailable: $message")
+ return
+ }
+ updateTorTestStep("proxy", TorTestStepStatus.Passed, "SOCKS endpoint reachable")
+ appendTorTestLog("SOCKS endpoint reachable")
+
+ updateTorTestStep("api", TorTestStepStatus.Running)
+ val torApiResult = runTorApiTestWithRetries(host, port.toInt())
+ if (torApiResult.isFailure) {
+ val message = torApiResult.exceptionOrNull()?.message ?: "unknown error"
+ updateTorTestStep("api", TorTestStepStatus.Failed, "Tor API request failed: $message")
+ appendTorTestLog("Tor API request failed: $message")
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ torTestState = torTestState.copy(running = false, finished = true)
+ snackbarHostState.showSnackbar("Tor API check failed: $message")
+ return
+ }
+
+ val apiSnapshot = torApiResult.getOrThrow()
+ if (!apiSnapshot.isTor) {
+ updateTorTestStep("api", TorTestStepStatus.Failed, "Tor API response did not confirm Tor routing")
+ appendTorTestLog("Tor API did not confirm Tor routing: ${apiSnapshot.raw}")
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ torTestState = torTestState.copy(running = false, finished = true)
+ snackbarHostState.showSnackbar("Tor API check failed: traffic is not exiting through Tor")
+ return
+ }
+ updateTorTestStep("api", TorTestStepStatus.Passed, "Tor API confirmed Tor routing${apiSnapshot.ip?.let { " ($it)" } ?: ""}")
+ appendTorTestLog("Tor API confirmed Tor routing${apiSnapshot.ip?.let { " via $it" } ?: ""}")
+
+ updateTorTestStep("node", TorTestStepStatus.Running)
+ val node = resolveNodeForTorTest()
+ val nodeResult = runNodeTorTest(node)
+ if (nodeResult.isFailure) {
+ val errorText = (nodeResult.exceptionOrNull() as? NodeSelectorException.NodeAccessException)?.v1
+ ?: (nodeResult.exceptionOrNull()?.message ?: "unknown error")
+ updateTorTestStep("node", TorTestStepStatus.Failed, "Node check failed: $errorText")
+ appendTorTestLog("Node check failed: $errorText")
+ app.pendingNodeTorValidated = false
+ uiState = uiState.copy(status = TorStatus.Error)
+ torTestState = torTestState.copy(running = false, finished = true)
+ snackbarHostState.showSnackbar("Node test failed: $errorText")
+ return
+ }
+
+ updateTorTestStep("node", TorTestStepStatus.Passed, "Node reachable via Tor")
+ appendTorTestLog("Node reachable via Tor")
+ app.pendingNodeTorValidated = true
+ if (uiState.mode == TorMode.BuiltIn) {
+ syncRustTorLogs()
+ } else {
+ uiState = uiState.copy(status = TorStatus.Ready, progressPercent = 100)
+ }
+ appendTorLog(
+ when (uiState.mode) {
+ TorMode.BuiltIn -> "Built-in Tor validation passed"
+ TorMode.External -> "External Tor validation passed"
+ TorMode.Orbot -> "Orbot Tor validation passed"
+ },
+ )
+ if (app.pendingNodeAwaitingTorSetup) {
+ appendTorTestLog("Pending onion node validated; applying configuration")
+ appendTorLog("Pending onion node validated; applying configuration")
+ app.popRoute()
+ }
+ torTestState = torTestState.copy(running = false, finished = true)
+ snackbarHostState.showSnackbar("Tor connection test passed")
+ }
+
+ val statusDisabledText = stringResource(R.string.tor_status_disabled)
+ val logDisabledText = stringResource(R.string.tor_log_disabled)
+ val torStatusTesting = stringResource(R.string.tor_status_testing)
+ val torStatusConfigured = stringResource(R.string.tor_status_configured)
+ val torStatusInstallOrbot = stringResource(R.string.tor_status_install_orbot)
+ val torStatusActionRequired = stringResource(R.string.tor_status_action_required)
+ val torActionSaveConfig = stringResource(R.string.tor_action_save_config)
+ val torActionTestConnection = stringResource(R.string.tor_action_test_connection)
+ val torActionOpenOrbot = stringResource(R.string.tor_action_open_orbot)
+ val torActionInstallOrbot = stringResource(R.string.tor_action_install_orbot)
+ val torErrorConfigInvalid = stringResource(R.string.tor_error_config_invalid)
+ val torSuccessConfigurationSaved = stringResource(R.string.tor_success_configuration_saved)
+ val torSuccessConnectionValid = stringResource(R.string.tor_success_connection_valid)
+ val torDisableOnionDialogTitle = stringResource(R.string.tor_disable_onion_dialog_title)
+ val torDisableOnionDialogBody = stringResource(R.string.tor_disable_onion_dialog_body)
+ val torDisableOnionDialogConfirm = stringResource(R.string.tor_disable_onion_dialog_confirm)
+ val torDisableOnionDialogCancel = stringResource(R.string.tor_disable_onion_dialog_cancel)
+ val torFallbackFailed = stringResource(R.string.tor_fallback_failed)
+ val torFallbackApplied = stringResource(R.string.tor_fallback_applied)
+ val copiedText = stringResource(R.string.btn_copied)
+
+ LaunchedEffect(Unit) {
+ rustTorLogCount = 0
+ appendTorLog("Opened Tor connection logs")
+ val (status, version) = OrbotPackageHelper.detect(context)
+ uiState = uiState.copy(orbotStatus = status, orbotVersion = version)
+ appendTorLog(
+ when (status) {
+ OrbotStatus.Detected -> "Orbot detected${version?.let { " ($it)" } ?: ""}"
+ OrbotStatus.NotDetected -> "Orbot not detected"
+ OrbotStatus.Checking -> "Checking Orbot status"
+ },
+ )
+ if (uiState.enabled && uiState.mode == TorMode.BuiltIn && !builtInWarmupRequested) {
+ builtInWarmupRequested = true
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ appendTorLog("Built-in Tor bootstrap started at $endpoint")
+ }.onFailure { error ->
+ appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}")
+ }
+ }
+ syncRustTorLogs()
+ }
+
+ LaunchedEffect(uiState.enabled, uiState.mode) {
+ if (!uiState.enabled || uiState.mode != TorMode.BuiltIn) {
+ return@LaunchedEffect
+ }
+
+ if (!builtInWarmupRequested) {
+ builtInWarmupRequested = true
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ appendTorLog("Built-in Tor bootstrap started at $endpoint")
+ }.onFailure { error ->
+ appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}")
+ }
+ }
+
+ while (uiState.enabled && uiState.mode == TorMode.BuiltIn) {
+ syncRustTorLogs()
+ delay(1000)
+ }
+ }
+
+ LaunchedEffect(uiState.enabled, uiState.mode, uiState.orbotStatus) {
+ if (!uiState.enabled) {
+ uiState =
+ uiState.copy(
+ status = TorStatus.Disabled,
+ progressPercent = 0,
+ currentStep = statusDisabledText,
+ latestLogLine = logDisabledText,
+ )
+ appendTorLog("Tor disabled")
+ return@LaunchedEffect
+ }
+
+ val awaitingValidation = !app.pendingNodeTorValidated
+
+ when (uiState.mode) {
+ TorMode.BuiltIn -> {
+ syncRustTorLogs()
+ }
+
+ TorMode.External -> {
+ val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort)
+ uiState =
+ uiState.copy(
+ status = if (validationError == null) TorStatus.Bootstrapping else TorStatus.Error,
+ progressPercent = if (validationError == null) 50 else 0,
+ currentStep = if (validationError == null) torActionSaveConfig else torErrorConfigInvalid,
+ latestLogLine =
+ if (validationError == null) {
+ redactedProxyForLog(
+ uiState.externalHost,
+ uiState.externalPort.toUShortOrNull() ?: 9050u,
+ )
+ } else {
+ validationError
+ },
+ )
+ appendTorLog(
+ if (validationError == null) {
+ "External Tor selected: ${
+ redactedProxyForLog(
+ uiState.externalHost,
+ uiState.externalPort.toUShortOrNull() ?: 9050u,
+ )
+ }"
+ } else {
+ "External Tor config invalid: $validationError"
+ },
+ )
+ }
+
+ TorMode.Orbot -> {
+ if (uiState.orbotStatus == OrbotStatus.Detected) {
+ uiState =
+ uiState.copy(
+ status = if (awaitingValidation) TorStatus.Bootstrapping else TorStatus.Ready,
+ progressPercent = if (awaitingValidation) 50 else 100,
+ currentStep = if (awaitingValidation) torActionOpenOrbot else torStatusConfigured,
+ latestLogLine = if (awaitingValidation) torActionTestConnection else torSuccessConnectionValid,
+ )
+ appendTorLog(
+ if (awaitingValidation) {
+ "Orbot mode selected, waiting for connection test"
+ } else {
+ "Orbot mode validated"
+ },
+ )
+ } else {
+ uiState =
+ uiState.copy(
+ status = TorStatus.Error,
+ progressPercent = 0,
+ currentStep = torStatusInstallOrbot,
+ latestLogLine = torActionInstallOrbot,
+ )
+ appendTorLog("Orbot mode selected but Orbot is not installed")
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(
+ uiState.enabled,
+ uiState.mode,
+ uiState.status,
+ uiState.orbotStatus,
+ uiState.externalHost,
+ uiState.externalPort,
+ app.pendingNodeAwaitingTorSetup,
+ app.pendingNodeUrl,
+ app.pendingNodeTorValidated,
+ torTestState.running,
+ ) {
+ if (!app.pendingNodeAwaitingTorSetup || app.pendingNodeUrl.isBlank()) {
+ autoPendingTestKey = null
+ return@LaunchedEffect
+ }
+ if (!uiState.enabled || app.pendingNodeTorValidated || torTestState.running) {
+ return@LaunchedEffect
+ }
+
+ val flowKey = "${uiState.mode}|${app.pendingNodeUrl}|${uiState.externalHost}:${uiState.externalPort}"
+ if (autoPendingTestKey == flowKey) {
+ return@LaunchedEffect
+ }
+
+ val readyForAutoTest =
+ when (uiState.mode) {
+ TorMode.BuiltIn -> uiState.status == TorStatus.Ready
+ TorMode.Orbot ->
+ uiState.orbotStatus == OrbotStatus.Detected &&
+ testSocksEndpoint("127.0.0.1", 9050, 1200).isSuccess
+ TorMode.External -> {
+ val externalPort = uiState.externalPort.toIntOrNull()
+ validateExternalConfig(uiState.externalHost, uiState.externalPort) == null &&
+ externalPort != null &&
+ testSocksEndpoint(uiState.externalHost, externalPort, 1200).isSuccess
+ }
+ }
+
+ if (!readyForAutoTest) {
+ return@LaunchedEffect
+ }
+
+ autoPendingTestKey = flowKey
+ appendTorLog("Pending onion node detected; starting automatic Tor validation")
+ scope.launch {
+ runProgressiveTorTest()
+ }
+ }
+
Scaffold(
modifier =
modifier
.fillMaxSize()
.padding(WindowInsets.safeDrawing.asPaddingValues()),
+ snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = @Composable {
TopAppBar(
title = {
@@ -103,10 +897,494 @@ fun NetworkSettingsScreen(
}
}
}
+
+ if (torSettingsDiscovered) {
+ SectionHeader(stringResource(R.string.tor_section_privacy), showDivider = false)
+ MaterialSection {
+ Column {
+ MaterialSettingsItem(
+ title = stringResource(R.string.tor_use_tor_title),
+ subtitle = stringResource(R.string.tor_use_tor_subtitle),
+ icon = Icons.Default.Lock,
+ isSwitch = true,
+ switchCheckedState = uiState.enabled,
+ onCheckChanged = { enabled ->
+ Log.d(logTag, "toggle useTor: enabled=$enabled")
+ if (!enabled) {
+ val activeNodeIsOnion = isOnionNodeUrl(app.selectedNode.url)
+ val pendingOnionExists =
+ app.pendingNodeAwaitingTorSetup &&
+ app.pendingNodeUrl.isNotBlank() &&
+ isOnionNodeUrl(app.pendingNodeUrl)
+ if (activeNodeIsOnion || pendingOnionExists) {
+ showDisableTorOnionDialog = true
+ return@MaterialSettingsItem
+ }
+
+ clearPendingOnionDraft()
+ disableTorState()
+ appendTorLog("Tor disabled")
+ return@MaterialSettingsItem
+ }
+
+ if (!enableTorState()) {
+ return@MaterialSettingsItem
+ }
+ uiState = uiState.copy(enabled = true)
+ if (uiState.mode == TorMode.BuiltIn && !builtInWarmupRequested) {
+ scope.launch {
+ builtInWarmupRequested = true
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ appendTorLog("Built-in Tor bootstrap started at $endpoint")
+ }.onFailure { error ->
+ appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}")
+ }
+ }
+ }
+ },
+ )
+ MaterialDivider()
+ val modeEnabled = uiState.enabled
+ val modeSubtitle = when (uiState.mode) {
+ TorMode.BuiltIn -> stringResource(R.string.tor_mode_builtin)
+ TorMode.Orbot -> stringResource(R.string.tor_mode_orbot)
+ TorMode.External -> stringResource(R.string.tor_mode_external)
+ }
+ MaterialSettingsItem(
+ title = stringResource(R.string.tor_mode_title),
+ subtitle = modeSubtitle,
+ icon = Icons.Default.Public,
+ modifier = Modifier.alpha(if (modeEnabled) 1f else 0.5f),
+ onClick = if (modeEnabled) { { showModeDialog = true } } else null,
+ )
+ MaterialDivider()
+ val statusLabel = when (uiState.status) {
+ TorStatus.Disabled -> stringResource(R.string.tor_status_disabled)
+ TorStatus.Bootstrapping -> torStatusTesting
+ TorStatus.Ready -> torStatusConfigured
+ TorStatus.Error -> torStatusActionRequired
+ }
+ MaterialSettingsItem(
+ title = stringResource(R.string.tor_status_title),
+ subtitle = statusLabel,
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ modifier = Modifier.alpha(if (modeEnabled) 1f else 0.5f),
+ trailingContent = {
+ if (uiState.enabled && uiState.status == TorStatus.Ready) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ },
+ )
+ }
+ }
+ }
+
+ if (torSettingsDiscovered && uiState.enabled) {
+ if (uiState.mode == TorMode.BuiltIn) {
+ SectionHeader(stringResource(R.string.tor_section_bootstrap), showDivider = false)
+ MaterialSection {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.tor_progress_title),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = "${uiState.progressPercent}%",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ LinearProgressIndicator(
+ progress = { uiState.progressPercent / 100f },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ )
+ Text(
+ text = "${stringResource(R.string.tor_current_step_prefix)} ${uiState.currentStep}",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = {
+ scope.launch {
+ appendTorLog("Built-in Tor connection test requested")
+ Log.d(logTag, "built-in test connection tapped: pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}")
+ runProgressiveTorTest()
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(torActionTestConnection)
+ }
+ }
+ }
+ }
+
+ MaterialSection {
+ Column {
+ MaterialSettingsItem(
+ title = stringResource(R.string.tor_view_full_log_title),
+ subtitle = stringResource(R.string.tor_view_full_log_subtitle),
+ icon = Icons.Default.Terminal,
+ onClick = { showFullLogDialog = true },
+ )
+ }
+ }
+ }
+
+ if (uiState.mode == TorMode.External) {
+ SectionHeader(stringResource(R.string.tor_section_external), showDivider = false)
+ MaterialSection {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Text(
+ text = stringResource(R.string.tor_external_config_notice),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ OutlinedTextField(
+ value = uiState.externalHost,
+ onValueChange = { value ->
+ uiState =
+ uiState.copy(
+ externalHost = value,
+ externalValidationError = validateExternalConfig(value, uiState.externalPort),
+ )
+ },
+ label = { Text(stringResource(R.string.tor_external_host_label)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = uiState.externalPort,
+ onValueChange = { value ->
+ uiState =
+ uiState.copy(
+ externalPort = value,
+ externalValidationError = validateExternalConfig(uiState.externalHost, value),
+ )
+ },
+ label = { Text(stringResource(R.string.tor_external_port_label)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ isError = uiState.externalValidationError != null,
+ supportingText = {
+ if (uiState.externalValidationError != null) {
+ Text(uiState.externalValidationError!!)
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = {
+ val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort)
+ if (validationError != null) {
+ uiState = uiState.copy(externalValidationError = validationError)
+ scope.launch { snackbarHostState.showSnackbar(validationError) }
+ return@Button
+ }
+
+ val proxyLog =
+ redactedProxyForLog(
+ uiState.externalHost,
+ uiState.externalPort.toUShortOrNull() ?: 9050u,
+ )
+ Log.d(logTag, "save external tor config: proxy=$proxyLog")
+ val externalPort = uiState.externalPort.toUShortOrNull()
+ if (externalPort == null ||
+ !saveExternalTorConfig(uiState.externalHost, externalPort, proxyLog)
+ ) {
+ return@Button
+ }
+ app.pendingNodeTorValidated = false
+ appendTorLog("Saved external Tor config: $proxyLog")
+ scope.launch { snackbarHostState.showSnackbar(torSuccessConfigurationSaved) }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(torActionSaveConfig)
+ }
+
+ Button(
+ onClick = {
+ val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort)
+ if (validationError != null) {
+ uiState = uiState.copy(externalValidationError = validationError)
+ scope.launch { snackbarHostState.showSnackbar(validationError) }
+ return@Button
+ }
+
+ scope.launch {
+ val proxyLog =
+ redactedProxyForLog(
+ uiState.externalHost,
+ uiState.externalPort.toUShortOrNull() ?: 9050u,
+ )
+ appendTorLog("External Tor connection test requested for $proxyLog")
+ Log.d(logTag, "external test connection tapped: proxy=$proxyLog, pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}")
+ runProgressiveTorTest()
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(torActionTestConnection)
+ }
+ }
+ }
+ }
+ }
+
+ if (uiState.mode == TorMode.Orbot) {
+ SectionHeader(stringResource(R.string.tor_orbot_status_title), showDivider = false)
+ MaterialSection {
+ Column {
+ val orbotTitle = when (uiState.orbotStatus) {
+ OrbotStatus.Checking -> stringResource(R.string.tor_orbot_checking)
+ OrbotStatus.Detected -> {
+ val version = uiState.orbotVersion
+ if (version.isNullOrBlank()) {
+ stringResource(R.string.tor_orbot_detected)
+ } else {
+ stringResource(R.string.tor_orbot_detected_version, version)
+ }
+ }
+ OrbotStatus.NotDetected -> stringResource(R.string.tor_orbot_not_detected)
+ }
+ val orbotSubtitle = if (uiState.orbotStatus == OrbotStatus.Detected) {
+ stringResource(R.string.tor_open_orbot_subtitle)
+ } else {
+ stringResource(R.string.tor_install_orbot_subtitle)
+ }
+ MaterialSettingsItem(
+ title = orbotTitle,
+ subtitle = orbotSubtitle,
+ icon = Icons.Default.Settings,
+ onClick = {
+ if (uiState.orbotStatus == OrbotStatus.Detected) {
+ OrbotPackageHelper.openOrbot(context)
+ } else {
+ OrbotPackageHelper.openInstallPage(context)
+ }
+ },
+ )
+ MaterialDivider()
+ MaterialSettingsItem(
+ title = stringResource(R.string.tor_detect_orbot_title),
+ subtitle = stringResource(R.string.tor_detect_orbot_subtitle),
+ icon = Icons.Default.Refresh,
+ onClick = {
+ val (status, version) = OrbotPackageHelper.detect(context)
+ uiState = uiState.copy(orbotStatus = status, orbotVersion = version)
+ appendTorLog(
+ when (status) {
+ OrbotStatus.Detected -> "Detected Orbot${version?.let { " ($it)" } ?: ""}"
+ OrbotStatus.NotDetected -> "Orbot not detected"
+ OrbotStatus.Checking -> "Checking Orbot status"
+ },
+ )
+ },
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = {
+ if (uiState.orbotStatus == OrbotStatus.Detected) {
+ OrbotPackageHelper.openOrbot(context)
+ } else {
+ OrbotPackageHelper.openInstallPage(context)
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(if (uiState.orbotStatus == OrbotStatus.Detected) torActionOpenOrbot else torActionInstallOrbot)
+ }
+
+ Button(
+ onClick = {
+ if (uiState.orbotStatus != OrbotStatus.Detected) {
+ scope.launch { snackbarHostState.showSnackbar(torStatusInstallOrbot) }
+ return@Button
+ }
+
+ scope.launch {
+ appendTorLog("Orbot Tor connection test requested")
+ Log.d(logTag, "orbot test connection tapped: pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}")
+ runProgressiveTorTest()
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(torActionTestConnection)
+ }
+ }
+ }
+ }
+ }
+ }
}
},
)
+ if (showModeDialog) {
+ AlertDialog(
+ onDismissRequest = { showModeDialog = false },
+ title = { Text(stringResource(R.string.tor_mode_title)) },
+ text = {
+ Column {
+ TorModeOption(
+ title = stringResource(R.string.tor_mode_builtin),
+ selected = uiState.mode == TorMode.BuiltIn,
+ onClick = {
+ Log.d(logTag, "set tor mode: BUILT_IN")
+ if (!setPersistedTorMode(TorMode.BuiltIn)) {
+ return@TorModeOption
+ }
+ app.pendingNodeTorValidated = false
+ autoPendingTestKey = null
+ uiState = uiState.copy(mode = TorMode.BuiltIn)
+ appendTorLog("Switched Tor mode to built-in")
+ builtInWarmupRequested = false
+ if (uiState.enabled) {
+ scope.launch {
+ builtInWarmupRequested = true
+ runCatching { ensureBuiltInTorBootstrap() }
+ .onSuccess { endpoint ->
+ appendTorLog("Built-in Tor bootstrap started at $endpoint")
+ }.onFailure { error ->
+ appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}")
+ }
+ }
+ }
+ showModeDialog = false
+ },
+ )
+ TorModeOption(
+ title = stringResource(R.string.tor_mode_orbot),
+ selected = uiState.mode == TorMode.Orbot,
+ onClick = {
+ Log.d(logTag, "set tor mode: ORBOT")
+ if (!setPersistedTorMode(TorMode.Orbot)) {
+ return@TorModeOption
+ }
+ app.pendingNodeTorValidated = false
+ autoPendingTestKey = null
+ uiState = uiState.copy(mode = TorMode.Orbot)
+ appendTorLog("Switched Tor mode to Orbot")
+ showModeDialog = false
+ },
+ )
+ TorModeOption(
+ title = stringResource(R.string.tor_mode_external),
+ selected = uiState.mode == TorMode.External,
+ onClick = {
+ Log.d(logTag, "set tor mode: EXTERNAL")
+ if (!setPersistedTorMode(TorMode.External)) {
+ return@TorModeOption
+ }
+ app.pendingNodeTorValidated = false
+ autoPendingTestKey = null
+ uiState = uiState.copy(mode = TorMode.External)
+ appendTorLog("Switched Tor mode to external proxy")
+ showModeDialog = false
+ },
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showModeDialog = false }) {
+ Text(stringResource(R.string.btn_cancel))
+ }
+ },
+ )
+ }
+
+ if (showDisableTorOnionDialog) {
+ AlertDialog(
+ onDismissRequest = { showDisableTorOnionDialog = false },
+ title = { Text(torDisableOnionDialogTitle) },
+ text = { Text(torDisableOnionDialogBody) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showDisableTorOnionDialog = false
+ scope.launch {
+ if (isOnionNodeUrl(app.selectedNode.url)) {
+ val fallbackResult = switchToFirstClearnetPresetNode(nodeSelector)
+ if (fallbackResult.isFailure) {
+ val reason =
+ fallbackResult.exceptionOrNull()?.message
+ ?: "unknown error"
+ appendTorLog("Unable to disable Tor: clearnet fallback failed ($reason)")
+ snackbarHostState.showSnackbar("$torFallbackFailed: $reason")
+ return@launch
+ }
+
+ val fallbackNode = fallbackResult.getOrThrow()
+ appendTorLog(
+ "Switched active node to ${redactedNodeForLog(fallbackNode)} before disabling Tor",
+ )
+ snackbarHostState.showSnackbar(
+ torFallbackApplied.format(fallbackNode.name),
+ )
+ } else if (
+ app.pendingNodeAwaitingTorSetup &&
+ app.pendingNodeUrl.isNotBlank() &&
+ isOnionNodeUrl(app.pendingNodeUrl)
+ ) {
+ appendTorLog("Discarded pending onion node draft before disabling Tor")
+ }
+
+ clearPendingOnionDraft()
+ disableTorState()
+ appendTorLog("Tor disabled")
+ }
+ },
+ ) {
+ Text(torDisableOnionDialogConfirm)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDisableTorOnionDialog = false }) {
+ Text(torDisableOnionDialogCancel)
+ }
+ },
+ )
+ }
+
// network change confirmation dialog
pendingNetworkChange?.let { network ->
AlertDialog(
@@ -134,6 +1412,253 @@ fun NetworkSettingsScreen(
},
)
}
+
+ if (showFullLogDialog) {
+ val fullLogText = uiState.logLines.joinToString(separator = "\n")
+ AlertDialog(
+ onDismissRequest = { showFullLogDialog = false },
+ title = { Text(stringResource(R.string.tor_full_log_title)) },
+ text = {
+ Column(
+ modifier =
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
+ .padding(8.dp),
+ ) {
+ uiState.logLines.forEach { line ->
+ Text(
+ text = "> $line",
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 2.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showFullLogDialog = false }) {
+ Text(stringResource(R.string.btn_done))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(fullLogText))
+ scope.launch { snackbarHostState.showSnackbar(copiedText) }
+ },
+ ) {
+ Text(stringResource(R.string.btn_copy))
+ }
+ },
+ )
+ }
+
+ if (showTorTestDialog) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ onDismissRequest = {
+ if (!torTestState.running) {
+ showTorTestDialog = false
+ }
+ },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ TorTestSheetContent(
+ state = torTestState,
+ onDismiss = { showTorTestDialog = false }
+ )
+ }
+ }
+}
+
+@Composable
+private fun TorTestSheetContent(
+ state: TorConnectionTestState,
+ onDismiss: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "Connection Test",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(vertical = 16.dp)
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ state.steps.forEach { step ->
+ TorTestStepProgressRow(step = step)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(CoveColor.midnightBlue)
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Terminal,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.7f),
+ modifier = Modifier.size(14.dp)
+ )
+ Text(
+ text = "LIVE TEST LOGS",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = Color.White.copy(alpha = 0.7f),
+ letterSpacing = 0.5.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ val scrollState = rememberScrollState()
+ LaunchedEffect(state.logs.size) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .verticalScroll(scrollState)
+ ) {
+ state.logs.forEach { line ->
+ Text(
+ text = line,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ color = Color.White.copy(alpha = 0.9f),
+ modifier = Modifier.padding(bottom = 2.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onDismiss,
+ enabled = !state.running,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(
+ text = if (state.running) "Running Test..." else "Done",
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+}
+
+@Composable
+private fun TorTestStepProgressRow(step: TorTestStep) {
+ val statusColor = when (step.status) {
+ TorTestStepStatus.Passed -> Color(0xFF34C759)
+ TorTestStepStatus.Failed -> Color(0xFFFF3B30)
+ TorTestStepStatus.Running -> MaterialTheme.colorScheme.primary
+ TorTestStepStatus.Pending -> MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (step.status) {
+ TorTestStepStatus.Pending -> {
+ androidx.compose.foundation.Canvas(modifier = Modifier.size(12.dp)) {
+ drawCircle(color = statusColor, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
+ }
+ }
+ TorTestStepStatus.Running -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = statusColor
+ )
+ }
+ TorTestStepStatus.Passed -> {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = statusColor,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ TorTestStepStatus.Failed -> {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ tint = statusColor,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = step.title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = if (step.status == TorTestStepStatus.Pending) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurface
+ )
+ if (step.status == TorTestStepStatus.Running || step.status == TorTestStepStatus.Failed || step.status == TorTestStepStatus.Passed) {
+ Text(
+ text = step.detail,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+private fun parseTorMode(mode: String?): TorMode {
+ return when (parseCoreTorMode(mode)) {
+ CoreTorMode.ORBOT -> TorMode.Orbot
+ CoreTorMode.EXTERNAL -> TorMode.External
+ CoreTorMode.BUILT_IN -> TorMode.BuiltIn
+ }
+}
+
+private fun validateExternalConfig(host: String, port: String): String? {
+ if (host.isBlank()) return "Host is required"
+ val parsed = port.toIntOrNull() ?: return "Port must be a number"
+ if (parsed !in 1..65535) return "Port must be between 1 and 65535"
+ return null
}
@Composable
@@ -165,3 +1690,26 @@ private fun NetworkRow(
}
}
}
+
+@Composable
+private fun TorModeOption(
+ title: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = onClick)
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt
index 054cf4822..5725a14f8 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt
@@ -1,5 +1,6 @@
package org.bitcoinppl.cove.flows.SettingsFlow
+import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -57,9 +58,14 @@ import org.bitcoinppl.cove.views.MaterialDivider
import org.bitcoinppl.cove.views.MaterialSection
import org.bitcoinppl.cove.views.SectionHeader
import org.bitcoinppl.cove_core.ApiType
+import org.bitcoinppl.cove_core.Database
+import org.bitcoinppl.cove_core.GlobalFlagKey
+import org.bitcoinppl.cove_core.Node
import org.bitcoinppl.cove_core.NodeSelection
import org.bitcoinppl.cove_core.NodeSelector
import org.bitcoinppl.cove_core.NodeSelectorException
+import org.bitcoinppl.cove_core.Route
+import org.bitcoinppl.cove_core.SettingsRoute
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -67,11 +73,15 @@ fun NodeSettingsScreen(
app: org.bitcoinppl.cove.AppManager,
modifier: Modifier = Modifier,
) {
+ val logTag = "NodeSettingsScreen"
val nodeSelector = remember { NodeSelector() }
+ val database = remember { Database() }
+ val globalConfig = remember { database.globalConfig() }
+ val globalFlag = remember { database.globalFlag() }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
- val nodeList = remember { nodeSelector.nodeList() }
+ var nodeList by remember { mutableStateOf(nodeSelector.nodeList()) }
var selectedNodeSelection by remember { mutableStateOf(nodeSelector.selectedNode()) }
var selectedNodeName by remember {
mutableStateOf(selectedNodeSelection.toNode().name)
@@ -79,12 +89,22 @@ fun NodeSettingsScreen(
var customUrl by remember { mutableStateOf("") }
var customNodeName by remember { mutableStateOf("") }
+ var suppressCustomDraftActions by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var errorTitle by remember { mutableStateOf("") }
+ fun refreshNodeSelection(node: Node) {
+ nodeList = NodeSelector().nodeList()
+ selectedNodeSelection = NodeSelection.Custom(node)
+ selectedNodeName = node.name
+ suppressCustomDraftActions = true
+ customUrl = ""
+ customNodeName = ""
+ }
+
// compute all string resources at composable level
val customElectrum = stringResource(R.string.node_custom_electrum)
val customEsplora = stringResource(R.string.node_custom_esplora)
@@ -97,15 +117,71 @@ fun NodeSettingsScreen(
val errorUnknown = stringResource(R.string.node_error_unknown)
val errorUrlEmpty = stringResource(R.string.node_error_url_empty)
val errorParseTitle = stringResource(R.string.node_error_parse_title)
+ val torRedirectMessage = stringResource(R.string.node_tor_redirect_message)
+ val nodeSavedViaTorNotice = stringResource(R.string.node_saved_via_tor_notice)
val showCustomFields =
selectedNodeSelection is NodeSelection.Custom ||
selectedNodeName == customElectrum ||
selectedNodeName == customEsplora
+ // restore pending onion draft and auto-attempt save after Tor setup
+ LaunchedEffect(Unit) {
+ if (app.pendingNodeAwaitingTorSetup && app.pendingNodeUrl.isNotBlank()) {
+ customUrl = app.pendingNodeUrl
+ customNodeName = app.pendingNodeName
+ selectedNodeName = app.pendingNodeTypeName
+ Log.d(logTag, "restored pending onion draft: type=$selectedNodeName, ${redactedEndpointForLog(customUrl)}")
+
+ if (globalConfig.useTor() && app.pendingNodeTorValidated) {
+ isLoading = true
+ try {
+ val pendingTypeName = app.pendingNodeTypeName.ifBlank { customElectrum }
+ val node =
+ withContext(Dispatchers.IO) {
+ nodeSelector.parseCustomNode(
+ app.pendingNodeUrl,
+ pendingTypeName,
+ app.pendingNodeName,
+ )
+ }
+
+ withContext(Dispatchers.IO) {
+ nodeSelector.checkAndSaveNode(node)
+ }
+
+ refreshNodeSelection(node)
+
+ app.pendingNodeAwaitingTorSetup = false
+ app.pendingNodeTorValidated = false
+ app.pendingNodeUrl = ""
+ app.pendingNodeName = ""
+ app.pendingNodeTypeName = ""
+
+ scope.launch {
+ snackbarHostState.showSnackbar(successSaved)
+ }
+ app.popRoute()
+ } catch (e: NodeSelectorException.NodeAccessException) {
+ Log.e(logTag, "pending onion save failed: NodeAccess reason=${e.v1}", e)
+ errorTitle = errorConnectionFailed
+ errorMessage = errorConnectionMessage.format(e.v1)
+ showErrorDialog = true
+ } catch (e: Exception) {
+ Log.e(logTag, "pending onion save failed: unexpected reason=${e.message}", e)
+ errorTitle = errorTitleDefault
+ errorMessage = errorUnknown.format(e.message ?: "")
+ showErrorDialog = true
+ } finally {
+ isLoading = false
+ }
+ }
+ }
+ }
+
// pre-fill custom fields if a custom node was previously saved
LaunchedEffect(showCustomFields, selectedNodeSelection) {
- if (showCustomFields && customUrl.isEmpty()) {
+ if (showCustomFields && customUrl.isEmpty() && !suppressCustomDraftActions) {
val savedNode = selectedNodeSelection
if (savedNode is NodeSelection.Custom) {
val node = savedNode.toNode()
@@ -124,6 +200,8 @@ fun NodeSettingsScreen(
}
fun selectPresetNode(nodeName: String) {
+ Log.d(logTag, "selectPresetNode: nodeName=$nodeName")
+ suppressCustomDraftActions = false
selectedNodeName = nodeName
customUrl = ""
customNodeName = ""
@@ -133,11 +211,15 @@ fun NodeSettingsScreen(
try {
val node =
withContext(Dispatchers.IO) {
- nodeSelector.selectPresetNode(nodeName)
+ val selected = nodeSelector.selectPresetNode(nodeName)
+ Log.d(logTag, "selectPresetNode resolved: ${redactedNodeForLog(selected)}")
+ selected
}
withContext(Dispatchers.IO) {
+ Log.d(logTag, "checkSelectedNode start: ${redactedNodeForLog(node)}")
nodeSelector.checkSelectedNode(node)
+ Log.d(logTag, "checkSelectedNode success: ${redactedNodeForLog(node)}")
}
selectedNodeSelection = NodeSelection.Preset(node)
@@ -148,14 +230,17 @@ fun NodeSettingsScreen(
)
}
} catch (e: NodeSelectorException.NodeNotFound) {
+ Log.e(logTag, "selectPresetNode failed: NodeNotFound name=$nodeName, reason=${e.v1}", e)
errorTitle = errorTitleDefault
errorMessage = errorNotFound.format(e.v1)
showErrorDialog = true
} catch (e: NodeSelectorException.NodeAccessException) {
+ Log.e(logTag, "selectPresetNode failed: NodeAccess name=$nodeName, reason=${e.v1}", e)
errorTitle = errorConnectionFailed
errorMessage = errorConnectionMessage.format(e.v1)
showErrorDialog = true
} catch (e: Exception) {
+ Log.e(logTag, "selectPresetNode failed: unexpected name=$nodeName, reason=${e.message}", e)
errorTitle = errorTitleDefault
errorMessage = errorUnknown.format(e.message ?: "")
showErrorDialog = true
@@ -165,8 +250,20 @@ fun NodeSettingsScreen(
}
}
+ fun selectCustomNodeType(nodeName: String) {
+ Log.d(logTag, "selectCustomNodeType: nodeName=$nodeName")
+ suppressCustomDraftActions = false
+ if (selectedNodeName != nodeName) {
+ customUrl = ""
+ customNodeName = ""
+ }
+ selectedNodeName = nodeName
+ }
+
fun checkAndSaveCustomNode() {
+ Log.d(logTag, "checkAndSaveCustomNode: selectedNodeName=$selectedNodeName, ${redactedEndpointForLog(customUrl)}")
if (customUrl.isEmpty()) {
+ Log.e(logTag, "checkAndSaveCustomNode aborted: empty customUrl")
errorTitle = errorTitleDefault
errorMessage = errorUrlEmpty
showErrorDialog = true
@@ -176,34 +273,110 @@ fun NodeSettingsScreen(
scope.launch {
isLoading = true
try {
+ val customNodeTypeName =
+ when {
+ selectedNodeName == customElectrum || selectedNodeName == customEsplora ->
+ selectedNodeName
+
+ selectedNodeSelection is NodeSelection.Custom ->
+ when (selectedNodeSelection.toNode().apiType) {
+ ApiType.ELECTRUM -> customElectrum
+ ApiType.ESPLORA -> customEsplora
+ else -> selectedNodeName
+ }
+
+ else -> selectedNodeName
+ }
+ Log.d(logTag, "checkAndSaveCustomNode type inference: selectedNodeName=$selectedNodeName, customNodeTypeName=$customNodeTypeName, selectedApiType=${selectedNodeSelection.toNode().apiType}")
+
val node =
withContext(Dispatchers.IO) {
- nodeSelector.parseCustomNode(customUrl, selectedNodeName, customNodeName)
+ Log.d(logTag, "parseCustomNode start: typeName=$customNodeTypeName, ${redactedEndpointForLog(customUrl)}")
+ val parsed = nodeSelector.parseCustomNode(customUrl, customNodeTypeName, customNodeName)
+ Log.d(logTag, "parseCustomNode success: ${redactedNodeForLog(parsed)}")
+ parsed
}
// update fields with parsed values
customUrl = node.url
customNodeName = node.name
+ val isOnionNode = isOnionNodeUrl(node.url)
+ if (isOnionNode) {
+ if (globalConfig.useTor()) {
+ Log.d(logTag, "onion node detected with Tor already enabled; saving directly: ${redactedNodeForLog(node)}")
+ withContext(Dispatchers.IO) {
+ nodeSelector.checkAndSaveNode(node)
+ }
+ refreshNodeSelection(node)
+ app.pendingNodeAwaitingTorSetup = false
+ app.pendingNodeTorValidated = false
+ app.pendingNodeUrl = ""
+ app.pendingNodeName = ""
+ app.pendingNodeTypeName = ""
+ scope.launch {
+ snackbarHostState.showSnackbar(successSaved)
+ }
+ return@launch
+ }
+
+ Log.d(logTag, "onion node detected, redirecting to network settings: ${redactedNodeForLog(node)}")
+ runCatching {
+ globalFlag.set(GlobalFlagKey.TOR_SETTINGS_DISCOVERED, true)
+ globalConfig.setUseTor(true)
+ }.onFailure { error ->
+ Log.e(logTag, "failed to persist Tor setup before onion redirect: ${error.message}", error)
+ errorTitle = errorTitleDefault
+ errorMessage = errorUnknown.format(error.message ?: "")
+ showErrorDialog = true
+ return@launch
+ }
+
+ selectedNodeSelection = NodeSelection.Custom(node)
+ selectedNodeName =
+ when (node.apiType) {
+ ApiType.ELECTRUM -> customElectrum
+ ApiType.ESPLORA -> customEsplora
+ else -> node.name
+ }
+
+ app.pendingNodeUrl = node.url
+ app.pendingNodeName = node.name
+ app.pendingNodeTypeName = selectedNodeName
+ app.pendingNodeAwaitingTorSetup = true
+ app.pendingNodeTorValidated = false
+
+ scope.launch {
+ snackbarHostState.showSnackbar(torRedirectMessage)
+ }
+ app.pushRoute(Route.Settings(SettingsRoute.Network))
+ return@launch
+ }
+
withContext(Dispatchers.IO) {
+ Log.d(logTag, "checkAndSaveNode start: ${redactedNodeForLog(node)}")
nodeSelector.checkAndSaveNode(node)
+ Log.d(logTag, "checkAndSaveNode success: ${redactedNodeForLog(node)}")
}
- selectedNodeSelection = NodeSelection.Custom(node)
- selectedNodeName = node.name
+ refreshNodeSelection(node)
+ Log.d(logTag, "custom node saved: selectedNodeName=$selectedNodeName, ${redactedNodeForLog(node)}")
// launch snackbar in separate coroutine so it doesn't block finally
scope.launch {
snackbarHostState.showSnackbar(successSaved)
}
} catch (e: NodeSelectorException.ParseNodeUrlException) {
+ Log.e(logTag, "checkAndSaveCustomNode failed: ParseNodeUrl ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.v1}", e)
errorTitle = errorParseTitle
errorMessage = e.v1
showErrorDialog = true
} catch (e: NodeSelectorException.NodeAccessException) {
+ Log.e(logTag, "checkAndSaveCustomNode failed: NodeAccess ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.v1}", e)
errorTitle = errorConnectionFailed
errorMessage = errorConnectionMessage.format(e.v1)
showErrorDialog = true
} catch (e: Exception) {
+ Log.e(logTag, "checkAndSaveCustomNode failed: unexpected ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.message}", e)
errorTitle = errorTitleDefault
errorMessage = errorUnknown.format(e.message ?: "")
showErrorDialog = true
@@ -286,9 +459,7 @@ fun NodeSettingsScreen(
NodeRow(
nodeName = customElectrum,
isSelected = selectedNodeName == customElectrum,
- onClick = {
- selectedNodeName = customElectrum
- },
+ onClick = { selectCustomNodeType(customElectrum) },
)
MaterialDivider()
@@ -297,16 +468,14 @@ fun NodeSettingsScreen(
NodeRow(
nodeName = customEsplora,
isSelected = selectedNodeName == customEsplora,
- onClick = {
- selectedNodeName = customEsplora
- },
+ onClick = { selectCustomNodeType(customEsplora) },
)
}
}
// custom node input fields
- if (showCustomFields) {
- Spacer(modifier = Modifier.height(MaterialSpacing.medium))
+ if (showCustomFields) {
+ Spacer(modifier = Modifier.height(MaterialSpacing.medium))
SectionHeader("Custom node")
MaterialSection {
@@ -319,7 +488,10 @@ fun NodeSettingsScreen(
) {
OutlinedTextField(
value = customUrl,
- onValueChange = { customUrl = it },
+ onValueChange = {
+ suppressCustomDraftActions = false
+ customUrl = it
+ },
label = { Text(stringResource(R.string.node_url_label)) },
placeholder = { Text(stringResource(R.string.node_url_placeholder)) },
keyboardOptions =
@@ -333,7 +505,10 @@ fun NodeSettingsScreen(
OutlinedTextField(
value = customNodeName,
- onValueChange = { customNodeName = it },
+ onValueChange = {
+ suppressCustomDraftActions = false
+ customNodeName = it
+ },
label = { Text(stringResource(R.string.node_name_label)) },
placeholder = { Text(stringResource(R.string.node_name_placeholder)) },
keyboardOptions =
@@ -344,12 +519,20 @@ fun NodeSettingsScreen(
modifier = Modifier.fillMaxWidth(),
)
- Button(
- onClick = { checkAndSaveCustomNode() },
- enabled = customUrl.isNotEmpty() && !isLoading,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(R.string.node_save_button))
+ if (suppressCustomDraftActions) {
+ Text(
+ text = nodeSavedViaTorNotice,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Button(
+ onClick = { checkAndSaveCustomNode() },
+ enabled = customUrl.isNotEmpty() && !isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.node_save_button))
+ }
}
}
}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt
new file mode 100644
index 000000000..6a4c95cac
--- /dev/null
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt
@@ -0,0 +1,43 @@
+package org.bitcoinppl.cove.flows.SettingsFlow
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+
+private const val ORBOT_PACKAGE = "org.torproject.android"
+
+object OrbotPackageHelper {
+ fun detect(context: Context): Pair =
+ runCatching {
+ val info =
+ context.packageManager.getPackageInfo(
+ ORBOT_PACKAGE,
+ PackageManager.PackageInfoFlags.of(0),
+ )
+ OrbotStatus.Detected to info.versionName
+ }.getOrElse {
+ OrbotStatus.NotDetected to null
+ }
+
+ fun openOrbot(context: Context): Boolean {
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(ORBOT_PACKAGE) ?: return false
+ context.startActivity(launchIntent)
+ return true
+ }
+
+ fun openInstallPage(context: Context) {
+ val marketIntent =
+ Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$ORBOT_PACKAGE"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ val webIntent =
+ Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://play.google.com/store/apps/details?id=$ORBOT_PACKAGE"),
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ runCatching { context.startActivity(marketIntent) }
+ .recover { context.startActivity(webIntent) }
+ }
+}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt
new file mode 100644
index 000000000..f35e3c6be
--- /dev/null
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt
@@ -0,0 +1,51 @@
+package org.bitcoinppl.cove.flows.SettingsFlow
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.bitcoinppl.cove_core.Node
+import org.bitcoinppl.cove_core.NodeSelection
+import org.bitcoinppl.cove_core.NodeSelector
+import java.net.URI
+import java.security.MessageDigest
+
+fun isOnionNodeUrl(url: String): Boolean {
+ return try {
+ val normalized = if (url.contains("://")) url else "tcp://$url"
+ URI(normalized).host?.endsWith(".onion", ignoreCase = true) == true
+ } catch (_: Exception) {
+ false
+ }
+}
+
+fun redactedEndpointForLog(endpoint: String): String {
+ return "id=${shortLogId(endpoint)}, onion=${isOnionNodeUrl(endpoint)}"
+}
+
+fun redactedProxyForLog(host: String, port: UShort): String {
+ return "id=${shortLogId("$host:$port")}, port=$port"
+}
+
+fun redactedNodeForLog(node: Node): String {
+ return "apiType=${node.apiType}, ${redactedEndpointForLog(node.url)}"
+}
+
+private fun shortLogId(value: String): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest(value.toByteArray(Charsets.UTF_8))
+ return digest.take(4).joinToString("") { byte -> "%02x".format(byte) }
+}
+
+suspend fun switchToFirstClearnetPresetNode(nodeSelector: NodeSelector): Result =
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val fallbackNode =
+ nodeSelector
+ .nodeList()
+ .asSequence()
+ .mapNotNull { selection -> (selection as? NodeSelection.Preset)?.v1 }
+ .firstOrNull { node -> !isOnionNodeUrl(node.url) }
+ ?: throw IllegalStateException("No clearnet preset node available for fallback")
+
+ nodeSelector.selectPresetNode(fallbackNode.name)
+ }
+ }
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt
new file mode 100644
index 000000000..7bec96e4e
--- /dev/null
+++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt
@@ -0,0 +1,35 @@
+package org.bitcoinppl.cove.flows.SettingsFlow
+
+enum class TorStatus {
+ Disabled,
+ Bootstrapping,
+ Ready,
+ Error,
+}
+
+enum class TorMode {
+ BuiltIn,
+ Orbot,
+ External,
+}
+
+enum class OrbotStatus {
+ Checking,
+ Detected,
+ NotDetected,
+}
+
+data class TorUiState(
+ val enabled: Boolean = false,
+ val mode: TorMode = TorMode.BuiltIn,
+ val status: TorStatus = TorStatus.Disabled,
+ val progressPercent: Int = 0,
+ val currentStep: String = "Disabled",
+ val latestLogLine: String = "Tor is off",
+ val logLines: List = listOf("Tor is off"),
+ val externalHost: String = "127.0.0.1",
+ val externalPort: String = "9050",
+ val externalValidationError: String? = null,
+ val orbotStatus: OrbotStatus = OrbotStatus.Checking,
+ val orbotVersion: String? = null,
+)
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt
new file mode 100644
index 000000000..1d80e1acf
--- /dev/null
+++ b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt
@@ -0,0 +1,12 @@
+package org.bitcoinppl.cove.tor
+
+import org.bitcoinppl.cove_core.TorMode
+
+fun parseCoreTorMode(mode: String?): TorMode {
+ return when (mode?.replace("_", "")?.lowercase()) {
+ "orbot" -> TorMode.ORBOT
+ "external" -> TorMode.EXTERNAL
+ "builtin" -> TorMode.BUILT_IN
+ else -> TorMode.BUILT_IN
+ }
+}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt
new file mode 100644
index 000000000..9130c8c7f
--- /dev/null
+++ b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt
@@ -0,0 +1,286 @@
+package org.bitcoinppl.cove.tor
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.InetSocketAddress
+import java.net.Proxy
+import java.net.Socket
+import java.net.URL
+
+data class TorBootstrapSnapshot(
+ val percent: Int,
+ val step: String,
+ val isReady: Boolean,
+ val hasError: Boolean,
+ val lastLine: String,
+)
+
+data class TorApiSnapshot(
+ val isTor: Boolean,
+ val ip: String?,
+ val raw: String,
+)
+
+private val bootstrapPercentRegex = Regex("""\b(100|[0-9]{1,2})%\b""")
+private val artiStatusRegex = Regex("""arti_client::status]\s*(100|[0-9]{1,2})%:\s*(.+)$""")
+private val missingCountRegex = Regex("""missing\s+(\d+)""")
+private val missingFractionRegex = Regex("""missing\s+(\d+)\s*/\s*(\d+)""")
+private val torApiBooleanRegex =
+ Regex(""""is_?tor"\s*:\s*true""", RegexOption.IGNORE_CASE)
+private val torApiIpRegex =
+ Regex(""""ip"\s*:\s*"([^"]+)"""", RegexOption.IGNORE_CASE)
+
+private fun isRustTorLog(line: String): Boolean {
+ val trimmed = line.trim()
+ return trimmed.startsWith("[INFO ") ||
+ trimmed.startsWith("[WARN ") ||
+ trimmed.startsWith("[ERROR ") ||
+ trimmed.startsWith("[DEBUG ")
+}
+
+fun deriveBuiltInBootstrapSnapshot(logLines: List): TorBootstrapSnapshot {
+ if (logLines.isEmpty()) {
+ return TorBootstrapSnapshot(
+ percent = 0,
+ step = "Waiting for Tor runtime",
+ isReady = false,
+ hasError = false,
+ lastLine = "No Tor logs yet",
+ )
+ }
+
+ val rustLogs = logLines.filter(::isRustTorLog)
+ if (rustLogs.isEmpty()) {
+ return TorBootstrapSnapshot(
+ percent = 0,
+ step = "Waiting for Tor runtime",
+ isReady = false,
+ hasError = false,
+ lastLine = logLines.last(),
+ )
+ }
+
+ val restartMarkers =
+ listOf(
+ "built-in tor endpoint requested without cache; launching proxy",
+ "built-in tor launch initiated",
+ "starting built-in tor runtime thread",
+ )
+ val restartIndex =
+ rustLogs.indexOfLast { line ->
+ restartMarkers.any { marker -> line.contains(marker, ignoreCase = true) }
+ }
+ val scopedLogs = if (restartIndex >= 0) rustLogs.drop(restartIndex) else rustLogs
+
+ var percent = 0
+ var step = "Starting Tor"
+ var ready = false
+ var hasError = false
+ var initialMissingMicrodescriptors: Int? = null
+
+ scopedLogs.forEach { line ->
+ val lowered = line.lowercase()
+ artiStatusRegex.find(line)?.let { match ->
+ val found = match.groupValues[1].toInt().coerceIn(0, 100)
+ val message = match.groupValues[2]
+ percent = found
+ step = "$found%: $message"
+ if (found >= 100) {
+ ready = true
+ }
+ return@forEach
+ }
+
+ bootstrapPercentRegex.find(line)?.groupValues?.get(1)?.toIntOrNull()?.let { found ->
+ if (found > percent) {
+ percent = found
+ }
+ }
+
+ when {
+ "built-in tor launch initiated" in lowered ||
+ "starting built-in tor runtime thread" in lowered -> {
+ percent = maxOf(percent, 3)
+ step = "Launching runtime"
+ }
+ "built-in tor runtime created" in lowered ||
+ "launching arti socks proxy task" in lowered -> {
+ percent = maxOf(percent, 8)
+ step = "Starting SOCKS proxy"
+ }
+ "listening on" in lowered &&
+ ("127.0.0.1:" in lowered || "[::1]:" in lowered) -> {
+ percent = maxOf(percent, 15)
+ step = "SOCKS listener ready"
+ }
+ "looking for a consensus" in lowered -> {
+ percent = maxOf(percent, 22)
+ step = "Looking for consensus"
+ }
+ "downloading certificates for consensus" in lowered -> {
+ percent = maxOf(percent, 35)
+ step = "Downloading consensus certificates"
+ }
+ "downloading microdescriptors" in lowered -> {
+ step = "Downloading microdescriptors"
+ val missingFraction =
+ missingFractionRegex.find(lowered)
+ ?.groupValues
+ ?.drop(1)
+ ?.mapNotNull { value -> value.toIntOrNull() }
+ val missing =
+ (missingFraction?.getOrNull(0))
+ ?: missingCountRegex.find(lowered)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toIntOrNull()
+ if (missing != null) {
+ val baselineMissing = initialMissingMicrodescriptors
+ val baseline =
+ if (missingFraction?.getOrNull(1) != null && missingFraction[1] > 0) {
+ val total = missingFraction[1]
+ if (baselineMissing == null || total > baselineMissing) {
+ initialMissingMicrodescriptors = total
+ total
+ } else {
+ baselineMissing
+ }
+ } else if (baselineMissing == null || missing > baselineMissing) {
+ initialMissingMicrodescriptors = missing
+ missing
+ } else {
+ baselineMissing
+ }.coerceAtLeast(1)
+ val completedRatio =
+ ((baseline - missing).coerceAtLeast(0)).toDouble() / baseline.toDouble()
+ val dynamicPercent = (45 + (completedRatio * 46.0).toInt()).coerceIn(45, 91)
+ percent = maxOf(percent, dynamicPercent)
+ } else {
+ percent = maxOf(percent, 45)
+ }
+ }
+ "marked consensus usable" in lowered -> {
+ percent = maxOf(percent, 93)
+ step = "Building circuits"
+ }
+ "enough information to build circuits" in lowered -> {
+ percent = maxOf(percent, 96)
+ step = "Building circuits"
+ }
+ "directory is complete" in lowered -> {
+ percent = 100
+ step = "Tor ready"
+ ready = true
+ }
+ "sufficiently bootstrapped; proxy now functional" in lowered -> {
+ percent = maxOf(percent, 97)
+ step = "Circuits available, finishing directory"
+ }
+ }
+
+ val benignReloadXdgWarning =
+ "arti::reload_cfg" in lowered &&
+ ("xdg project directories" in lowered ||
+ "unable to determine home directory" in lowered ||
+ "cache_dir" in lowered)
+
+ val fatalBootstrapSignals =
+ listOf(
+ "built-in tor bootstrap failed",
+ "built-in tor proxy exited",
+ "failed to initialize built-in tor runtime",
+ "failed to create built-in tor runtime",
+ "built-in tor socks listener not ready",
+ "can't find path for port_info_file",
+ "operation not supported because arti feature disabled",
+ )
+
+ if (!benignReloadXdgWarning &&
+ fatalBootstrapSignals.any { signal -> signal in lowered }
+ ) {
+ hasError = true
+ }
+ }
+
+ if (ready) {
+ hasError = false
+ }
+
+ if (!ready && percent >= 100) {
+ percent = 99
+ }
+
+ val lastLine = scopedLogs.lastOrNull() ?: rustLogs.last()
+ return TorBootstrapSnapshot(
+ percent = percent.coerceIn(0, 100),
+ step = step,
+ isReady = ready,
+ hasError = hasError,
+ lastLine = lastLine,
+ )
+}
+
+suspend fun testSocksEndpoint(
+ host: String,
+ port: Int,
+ timeoutMs: Int = 3000,
+): Result =
+ try {
+ withContext(Dispatchers.IO) {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress(host, port), timeoutMs)
+ }
+ }
+ Result.success(Unit)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Result.failure(error)
+ }
+
+suspend fun testTorApiThroughSocks(
+ host: String,
+ port: Int,
+ timeoutMs: Int = 15000,
+): Result =
+ try {
+ val snapshot =
+ withContext(Dispatchers.IO) {
+ val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port))
+ val connection = URL("https://check.torproject.org/api/ip").openConnection(proxy)
+ connection.connectTimeout = timeoutMs
+ connection.readTimeout = timeoutMs
+
+ val raw =
+ connection.getInputStream().bufferedReader().use { reader ->
+ reader.readText()
+ }
+ val json = runCatching { JSONObject(raw) }.getOrNull()
+ val isTor =
+ json?.caseInsensitiveBoolean("IsTor")
+ ?: json?.caseInsensitiveBoolean("is_tor")
+ ?: torApiBooleanRegex.containsMatchIn(raw)
+ val ip =
+ json?.caseInsensitiveString("IP")
+ ?: torApiIpRegex.find(raw)?.groupValues?.getOrNull(1)
+
+ TorApiSnapshot(isTor = isTor, ip = ip, raw = raw)
+ }
+ Result.success(snapshot)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Result.failure(error)
+ }
+
+private fun JSONObject.caseInsensitiveBoolean(key: String): Boolean? {
+ val actualKey = keys().asSequence().firstOrNull { it.equals(key, ignoreCase = true) }
+ return actualKey?.let { optBoolean(it) }
+}
+
+private fun JSONObject.caseInsensitiveString(key: String): String? {
+ val actualKey = keys().asSequence().firstOrNull { it.equals(key, ignoreCase = true) }
+ return actualKey?.let { optString(it).takeIf(String::isNotEmpty) }
+}
diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt
index d19062a47..20b551745 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt
@@ -57,11 +57,13 @@ fun MaterialSettingsItem(
isSwitch: Boolean = false,
switchCheckedState: Boolean = false,
onCheckChanged: ((Boolean) -> Unit)? = null,
+ modifier: Modifier = Modifier,
) {
MaterialSettingsItem(
title = title,
onClick = onClick,
subtitle = subtitle,
+ modifier = modifier,
leadingContent = {
Icon(
imageVector = icon,
@@ -99,6 +101,7 @@ fun MaterialSettingsItem(
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
titleColor: Color? = null,
+ modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
@@ -121,7 +124,7 @@ fun MaterialSettingsItem(
leadingContent = leadingContent,
trailingContent = trailingContent,
modifier =
- Modifier
+ modifier
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
index 3c576663b..e07aa25a0 100644
--- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
+++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
@@ -1032,8 +1032,16 @@ internal object IntegrityCheckingUniffiLib {
uniffiCheckContractApiVersion(this)
uniffiCheckApiChecksums(this)
}
+ external fun uniffi_cove_checksum_func_built_in_tor_bootstrap_status(
+ ): Short
+ external fun uniffi_cove_checksum_func_clear_tor_connection_logs(
+ ): Short
+ external fun uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap(
+ ): Short
external fun uniffi_cove_checksum_func_set_root_data_dir(
): Short
+ external fun uniffi_cove_checksum_func_tor_connection_logs(
+ ): Short
external fun uniffi_cove_checksum_func_initialize_app(
): Short
external fun uniffi_cove_checksum_func_bootstrap(
@@ -1276,6 +1284,14 @@ internal object IntegrityCheckingUniffiLib {
): Short
external fun uniffi_cove_checksum_method_globalconfigtable_set_selected_node(
): Short
+ external fun uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port(
+ ): Short
+ external fun uniffi_cove_checksum_method_globalconfigtable_set_use_tor(
+ ): Short
+ external fun uniffi_cove_checksum_method_globalconfigtable_tor_external_port(
+ ): Short
+ external fun uniffi_cove_checksum_method_globalconfigtable_use_tor(
+ ): Short
external fun uniffi_cove_checksum_method_globalconfigtable_wallet_mode(
): Short
external fun uniffi_cove_checksum_method_globalflagtable_get(
@@ -2232,6 +2248,14 @@ internal object UniffiLib {
): Unit
external fun uniffi_cove_fn_method_globalconfigtable_set_selected_node(`ptr`: Long,`node`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): Unit
+ external fun uniffi_cove_fn_method_globalconfigtable_set_tor_external_port(`ptr`: Long,`port`: Short,uniffi_out_err: UniffiRustCallStatus,
+ ): Unit
+ external fun uniffi_cove_fn_method_globalconfigtable_set_use_tor(`ptr`: Long,`useTor`: Byte,uniffi_out_err: UniffiRustCallStatus,
+ ): Unit
+ external fun uniffi_cove_fn_method_globalconfigtable_tor_external_port(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus,
+ ): Short
+ external fun uniffi_cove_fn_method_globalconfigtable_use_tor(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus,
+ ): Byte
external fun uniffi_cove_fn_method_globalconfigtable_wallet_mode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_cove_fn_clone_globalflagtable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus,
@@ -3300,8 +3324,16 @@ internal object UniffiLib {
): Byte
external fun uniffi_cove_fn_method_walletmetadata_uniffi_trait_hash(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): Long
+ external fun uniffi_cove_fn_func_built_in_tor_bootstrap_status(uniffi_out_err: UniffiRustCallStatus,
+ ): RustBuffer.ByValue
+ external fun uniffi_cove_fn_func_clear_tor_connection_logs(uniffi_out_err: UniffiRustCallStatus,
+ ): Unit
+ external fun uniffi_cove_fn_func_ensure_built_in_tor_bootstrap(
+ ): Long
external fun uniffi_cove_fn_func_set_root_data_dir(`path`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): Unit
+ external fun uniffi_cove_fn_func_tor_connection_logs(uniffi_out_err: UniffiRustCallStatus,
+ ): RustBuffer.ByValue
external fun uniffi_cove_fn_func_initialize_app(uniffi_out_err: UniffiRustCallStatus,
): Unit
external fun uniffi_cove_fn_func_bootstrap(
@@ -3515,9 +3547,21 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) {
}
@Suppress("UNUSED_PARAMETER")
private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
+ if (lib.uniffi_cove_checksum_func_built_in_tor_bootstrap_status() != 39940.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
+ if (lib.uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
+ if (lib.uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap() != 8592.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
if (lib.uniffi_cove_checksum_func_set_root_data_dir() != 56109.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
+ if (lib.uniffi_cove_checksum_func_tor_connection_logs() != 59010.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
if (lib.uniffi_cove_checksum_func_initialize_app() != 18498.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -3881,6 +3925,18 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
if (lib.uniffi_cove_checksum_method_globalconfigtable_set_selected_node() != 44882.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
+ if (lib.uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port() != 2190.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
+ if (lib.uniffi_cove_checksum_method_globalconfigtable_set_use_tor() != 3178.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
+ if (lib.uniffi_cove_checksum_method_globalconfigtable_tor_external_port() != 48622.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
+ if (lib.uniffi_cove_checksum_method_globalconfigtable_use_tor() != 22520.toShort()) {
+ throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
+ }
if (lib.uniffi_cove_checksum_method_globalconfigtable_wallet_mode() != 27720.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -12019,6 +12075,14 @@ public interface GlobalConfigTableInterface {
fun `setSelectedNode`(`node`: Node)
+ fun `setTorExternalPort`(`port`: kotlin.UShort)
+
+ fun `setUseTor`(`useTor`: kotlin.Boolean)
+
+ fun `torExternalPort`(): kotlin.UShort
+
+ fun `useTor`(): kotlin.Boolean
+
fun `walletMode`(): WalletMode
companion object
@@ -12374,6 +12438,58 @@ open class GlobalConfigTable: Disposable, AutoCloseable, GlobalConfigTableInterf
+
+ @Throws(DatabaseException::class)override fun `setTorExternalPort`(`port`: kotlin.UShort)
+ =
+ callWithHandle {
+ uniffiRustCallWithError(DatabaseException) { _status ->
+ UniffiLib.uniffi_cove_fn_method_globalconfigtable_set_tor_external_port(
+ it,
+ FfiConverterUShort.lower(`port`),_status)
+}
+ }
+
+
+
+
+ @Throws(DatabaseException::class)override fun `setUseTor`(`useTor`: kotlin.Boolean)
+ =
+ callWithHandle {
+ uniffiRustCallWithError(DatabaseException) { _status ->
+ UniffiLib.uniffi_cove_fn_method_globalconfigtable_set_use_tor(
+ it,
+ FfiConverterBoolean.lower(`useTor`),_status)
+}
+ }
+
+
+
+ override fun `torExternalPort`(): kotlin.UShort {
+ return FfiConverterUShort.lift(
+ callWithHandle {
+ uniffiRustCall() { _status ->
+ UniffiLib.uniffi_cove_fn_method_globalconfigtable_tor_external_port(
+ it,
+ _status)
+}
+ }
+ )
+ }
+
+
+ override fun `useTor`(): kotlin.Boolean {
+ return FfiConverterBoolean.lift(
+ callWithHandle {
+ uniffiRustCall() { _status ->
+ UniffiLib.uniffi_cove_fn_method_globalconfigtable_use_tor(
+ it,
+ _status)
+}
+ }
+ )
+ }
+
+
override fun `walletMode`(): WalletMode {
return FfiConverterTypeWalletMode.lift(
callWithHandle {
@@ -27739,6 +27855,64 @@ public object FfiConverterTypeBackupWalletSummary: FfiConverterRustBuffer {
+ override fun read(buf: ByteBuffer): BuiltInTorBootstrapStatus {
+ return BuiltInTorBootstrapStatus(
+ FfiConverterUInt.read(buf),
+ FfiConverterBoolean.read(buf),
+ FfiConverterOptionalString.read(buf),
+ FfiConverterString.read(buf),
+ FfiConverterBoolean.read(buf),
+ FfiConverterOptionalString.read(buf),
+ )
+ }
+
+ override fun allocationSize(value: BuiltInTorBootstrapStatus) = (
+ FfiConverterUInt.allocationSize(value.`percent`) +
+ FfiConverterBoolean.allocationSize(value.`ready`) +
+ FfiConverterOptionalString.allocationSize(value.`blocked`) +
+ FfiConverterString.allocationSize(value.`message`) +
+ FfiConverterBoolean.allocationSize(value.`launched`) +
+ FfiConverterOptionalString.allocationSize(value.`lastError`)
+ )
+
+ override fun write(value: BuiltInTorBootstrapStatus, buf: ByteBuffer) {
+ FfiConverterUInt.write(value.`percent`, buf)
+ FfiConverterBoolean.write(value.`ready`, buf)
+ FfiConverterOptionalString.write(value.`blocked`, buf)
+ FfiConverterString.write(value.`message`, buf)
+ FfiConverterBoolean.write(value.`launched`, buf)
+ FfiConverterOptionalString.write(value.`lastError`, buf)
+ }
+}
+
+
+
data class CloudBackupDetail (
var `lastSync`: kotlin.ULong?
,
@@ -37897,6 +38071,18 @@ sealed class GlobalConfigKey {
companion object
}
+ object UseTor : GlobalConfigKey()
+
+
+ object TorMode : GlobalConfigKey()
+
+
+ object TorExternalHost : GlobalConfigKey()
+
+
+ object TorExternalPort : GlobalConfigKey()
+
+
object ColorScheme : GlobalConfigKey()
@@ -37949,16 +38135,20 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer GlobalConfigKey.SelectedNode(
FfiConverterTypeNetwork.read(buf),
)
- 5 -> GlobalConfigKey.ColorScheme
- 6 -> GlobalConfigKey.AuthType
- 7 -> GlobalConfigKey.HashedPinCode
- 8 -> GlobalConfigKey.WipeDataPin
- 9 -> GlobalConfigKey.DecoyPin
- 10 -> GlobalConfigKey.InDecoyMode
- 11 -> GlobalConfigKey.MainSelectedWalletId
- 12 -> GlobalConfigKey.DecoySelectedWalletId
- 13 -> GlobalConfigKey.LockedAt
- 14 -> GlobalConfigKey.OnboardingProgress
+ 5 -> GlobalConfigKey.UseTor
+ 6 -> GlobalConfigKey.TorMode
+ 7 -> GlobalConfigKey.TorExternalHost
+ 8 -> GlobalConfigKey.TorExternalPort
+ 9 -> GlobalConfigKey.ColorScheme
+ 10 -> GlobalConfigKey.AuthType
+ 11 -> GlobalConfigKey.HashedPinCode
+ 12 -> GlobalConfigKey.WipeDataPin
+ 13 -> GlobalConfigKey.DecoyPin
+ 14 -> GlobalConfigKey.InDecoyMode
+ 15 -> GlobalConfigKey.MainSelectedWalletId
+ 16 -> GlobalConfigKey.DecoySelectedWalletId
+ 17 -> GlobalConfigKey.LockedAt
+ 18 -> GlobalConfigKey.OnboardingProgress
else -> throw RuntimeException("invalid enum value, something is very wrong!!")
}
}
@@ -37989,6 +38179,30 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer {
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ (
+ 4UL
+ )
+ }
+ is GlobalConfigKey.TorMode -> {
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ (
+ 4UL
+ )
+ }
+ is GlobalConfigKey.TorExternalHost -> {
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ (
+ 4UL
+ )
+ }
+ is GlobalConfigKey.TorExternalPort -> {
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ (
+ 4UL
+ )
+ }
is GlobalConfigKey.ColorScheme -> {
// Add the size for the Int that specifies the variant plus the size needed for all fields
(
@@ -38070,46 +38284,62 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer {
+ is GlobalConfigKey.UseTor -> {
buf.putInt(5)
Unit
}
- is GlobalConfigKey.AuthType -> {
+ is GlobalConfigKey.TorMode -> {
buf.putInt(6)
Unit
}
- is GlobalConfigKey.HashedPinCode -> {
+ is GlobalConfigKey.TorExternalHost -> {
buf.putInt(7)
Unit
}
- is GlobalConfigKey.WipeDataPin -> {
+ is GlobalConfigKey.TorExternalPort -> {
buf.putInt(8)
Unit
}
- is GlobalConfigKey.DecoyPin -> {
+ is GlobalConfigKey.ColorScheme -> {
buf.putInt(9)
Unit
}
- is GlobalConfigKey.InDecoyMode -> {
+ is GlobalConfigKey.AuthType -> {
buf.putInt(10)
Unit
}
- is GlobalConfigKey.MainSelectedWalletId -> {
+ is GlobalConfigKey.HashedPinCode -> {
buf.putInt(11)
Unit
}
- is GlobalConfigKey.DecoySelectedWalletId -> {
+ is GlobalConfigKey.WipeDataPin -> {
buf.putInt(12)
Unit
}
- is GlobalConfigKey.LockedAt -> {
+ is GlobalConfigKey.DecoyPin -> {
buf.putInt(13)
Unit
}
- is GlobalConfigKey.OnboardingProgress -> {
+ is GlobalConfigKey.InDecoyMode -> {
buf.putInt(14)
Unit
}
+ is GlobalConfigKey.MainSelectedWalletId -> {
+ buf.putInt(15)
+ Unit
+ }
+ is GlobalConfigKey.DecoySelectedWalletId -> {
+ buf.putInt(16)
+ Unit
+ }
+ is GlobalConfigKey.LockedAt -> {
+ buf.putInt(17)
+ Unit
+ }
+ is GlobalConfigKey.OnboardingProgress -> {
+ buf.putInt(18)
+ Unit
+ }
}.let { /* this makes the `when` an expression, which ensures it is exhaustive */ }
}
}
@@ -38144,6 +38374,14 @@ sealed class GlobalConfigTableException: kotlin.Exception() {
get() = ""
}
+ class BuiltInTorStop(
+
+ val v1: kotlin.String
+ ) : GlobalConfigTableException() {
+ override val message
+ get() = "v1=${ v1 }"
+ }
+
@@ -38179,6 +38417,9 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer GlobalConfigTableException.PinCodeMustBeHashed()
+ 4 -> GlobalConfigTableException.BuiltInTorStop(
+ FfiConverterString.read(buf),
+ )
else -> throw RuntimeException("invalid error enum value, something is very wrong!!")
}
}
@@ -38199,6 +38440,11 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer (
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ 4UL
+ + FfiConverterString.allocationSize(value.v1)
+ )
}
}
@@ -38218,6 +38464,11 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer {
+ buf.putInt(4)
+ FfiConverterString.write(value.v1, buf)
+ Unit
+ }
}.let { /* this makes the `when` an expression, which ensures it is exhaustive */ }
}
@@ -38231,7 +38482,8 @@ enum class GlobalFlagKey {
COMPLETED_ONBOARDING,
ACCEPTED_TERMS,
BETA_FEATURES_ENABLED,
- BETA_IMPORT_EXPORT_ENABLED;
+ BETA_IMPORT_EXPORT_ENABLED,
+ TOR_SETTINGS_DISCOVERED;
@@ -47628,6 +47880,103 @@ public object FfiConverterTypeTapSignerRoute : FfiConverterRustBuffer {
+ override fun lift(error_buf: RustBuffer.ByValue): TorBootstrapException = FfiConverterTypeTorBootstrapError.lift(error_buf)
+ }
+
+
+}
+
+/**
+ * @suppress
+ */
+public object FfiConverterTypeTorBootstrapError : FfiConverterRustBuffer {
+ override fun read(buf: ByteBuffer): TorBootstrapException {
+
+
+ return when(buf.getInt()) {
+ 1 -> TorBootstrapException.BuiltInTor(
+ FfiConverterString.read(buf),
+ )
+ else -> throw RuntimeException("invalid error enum value, something is very wrong!!")
+ }
+ }
+
+ override fun allocationSize(value: TorBootstrapException): ULong {
+ return when(value) {
+ is TorBootstrapException.BuiltInTor -> (
+ // Add the size for the Int that specifies the variant plus the size needed for all fields
+ 4UL
+ + FfiConverterString.allocationSize(value.v1)
+ )
+ }
+ }
+
+ override fun write(value: TorBootstrapException, buf: ByteBuffer) {
+ when(value) {
+ is TorBootstrapException.BuiltInTor -> {
+ buf.putInt(1)
+ FfiConverterString.write(value.v1, buf)
+ Unit
+ }
+ }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ }
+ }
+
+}
+
+
+
+
+enum class TorMode {
+
+ BUILT_IN,
+ ORBOT,
+ EXTERNAL;
+
+
+
+
+ companion object
+}
+
+
+/**
+ * @suppress
+ */
+public object FfiConverterTypeTorMode: FfiConverterRustBuffer {
+ override fun read(buf: ByteBuffer) = try {
+ TorMode.values()[buf.getInt() - 1]
+ } catch (e: IndexOutOfBoundsException) {
+ throw RuntimeException("invalid enum value, something is very wrong!!", e)
+ }
+
+ override fun allocationSize(value: TorMode) = 4UL
+
+ override fun write(value: TorMode, buf: ByteBuffer) {
+ buf.putInt(value.ordinal + 1)
+ }
+}
+
+
+
+
+
sealed class Transaction: Disposable {
data class Confirmed(
@@ -55007,6 +55356,40 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler
+ UniffiLib.uniffi_cove_fn_func_built_in_tor_bootstrap_status(
+
+ _status)
+}
+ )
+ }
+
+ fun `clearTorConnectionLogs`()
+ =
+ uniffiRustCall() { _status ->
+ UniffiLib.uniffi_cove_fn_func_clear_tor_connection_logs(
+
+ _status)
+}
+
+
+
+ @Throws(TorBootstrapException::class)
+ @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
+ suspend fun `ensureBuiltInTorBootstrap`() : kotlin.String {
+ return uniffiRustCallAsync(
+ UniffiLib.uniffi_cove_fn_func_ensure_built_in_tor_bootstrap(),
+ { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) },
+ { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) },
+ { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) },
+ // lift function
+ { FfiConverterString.lift(it) },
+ // Error FFI converter
+ TorBootstrapException.ErrorHandler,
+ )
+ }
/**
* set root data directory before any database access
@@ -55021,6 +55404,16 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler {
+ return FfiConverterSequenceString.lift(
+ uniffiRustCall() { _status ->
+ UniffiLib.uniffi_cove_fn_func_tor_connection_logs(
+
+ _status)
+}
+ )
+ }
+
/**
* Initialize the global App instance (Updater, router, state)
diff --git a/android/app/src/main/res/drawable/icon_tor_onion.xml b/android/app/src/main/res/drawable/icon_tor_onion.xml
new file mode 100644
index 000000000..22a24433d
--- /dev/null
+++ b/android/app/src/main/res/drawable/icon_tor_onion.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index ddd2440fe..33ed38357 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -174,6 +174,8 @@
Unknown error: %1$s
URL cannot be empty
Unable to parse URL
+ Onion node detected. Configure Tor in Network settings, then save again.
+ Node already saved through Tor validation. Edit URL or name to save changes.
Back
Selected
@@ -188,4 +190,100 @@
Manage UTXOs
Wallet Settings
Address unavailable
+ Menu
+ Tor
+ Tor status
+ QR Code
+ More
+
+
+ Tor Network
+ Connection Status
+ Custom Proxy Settings
+ Use Tor
+ Route traffic through the Tor network for enhanced privacy
+ Connection Mode
+ Built-in
+ Custom SOCKS5 Proxy
+ Orbot (External App)
+ Tor Status
+ Disabled
+ Connecting…
+ Connected
+ Connection Error
+ Not configured
+ Needs testing
+ Configured
+ Install Orbot first
+ Action required
+ Bootstrap Progress
+ Status:
+ Log:
+ Connection Logs
+ View detailed Tor bootstrap logs
+ Tor Logs
+ SOCKS Host
+ SOCKS Port
+ Configure your custom Tor proxy or SOCKS5 bridge
+ Orbot Integration
+ Checking Orbot…
+ Orbot Detected
+ Orbot Detected (v%1$s)
+ Orbot Not Found
+ Refresh Status
+ Check if Orbot is installed
+ Open Orbot
+ Manage Tor via Orbot app
+ Get Orbot
+ Install Orbot from the Play Store
+ Save config
+ Test connection
+ Use this configuration
+ Refresh Orbot status
+ Open Orbot
+ Install Orbot
+ External proxy config is invalid
+ No pending onion node found. Add one from Node settings first.
+ Node test failed: %1$s
+ Tor proxy unavailable: %1$s
+ Tor proxy configuration saved
+ Tor connection test passed
+ Onion node saved successfully
+ Disable Tor?
+ Your active or pending node uses an onion address. Disable Tor only if Cove first switches to a clearnet node.
+ Disable and switch node
+ Cancel
+ Clearnet fallback failed
+ Switched to %1$s and disabled Tor
+ Initializing Tor runtime
+ Loading network directory
+ Building circuit
+ Preparing local proxy
+ Tor is ready
+ Custom proxy configured
+ Tor is currently disabled
+ Routing via custom proxy
+ Tor disabled
+ Built-in Tor ready
+ Built-in Tor error: %1$s
+ Built-in Tor bootstrapping (%1$d%%)
+ Orbot SOCKS endpoint reachable
+ Orbot SOCKS endpoint unavailable
+ External SOCKS endpoint reachable
+ External SOCKS endpoint unavailable
+ Node reachable
+ Node connection failed
+ Checking node connection
+ Node status unavailable
+ Node synced
+ Node syncing
+ Node sync failed
+ Sync status unavailable
+ Tor Network Status
+ Recent Logs
+ Network Settings
+ Tor Connection
+ Node Reachable
+ Node Synced
+ TOR
diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift
index ef43990db..ced1ccdc8 100644
--- a/ios/Cove/AppManager.swift
+++ b/ios/Cove/AppManager.swift
@@ -34,6 +34,12 @@ private let sidebarNavigationDelayMs = 250
var selectedNode = Database().globalConfig().selectedNode()
var selectedFiatCurrency = Database().globalConfig().selectedFiatCurrency()
+ var pendingNodeUrl = ""
+ var pendingNodeName = ""
+ var pendingNodeTypeName = ""
+ var pendingNodeAwaitingTorSetup = false
+ var pendingNodeTorValidated = false
+
var nfcReader = NFCReader()
var nfcWriter = NFCWriter()
var tapSignerNfc: TapSignerNFC?
@@ -96,6 +102,7 @@ private let sidebarNavigationDelayMs = 250
fees = try? rust.fees()
self.rust.listenForUpdates(updater: self)
+ warmupTorIfConfigured()
}
public func getWalletManager(id: WalletId) throws -> WalletManager {
@@ -138,6 +145,29 @@ private let sidebarNavigationDelayMs = 250
walletManager = vm
}
+ public func clearPendingNodeTorDraft() {
+ pendingNodeUrl = ""
+ pendingNodeName = ""
+ pendingNodeTypeName = ""
+ pendingNodeAwaitingTorSetup = false
+ pendingNodeTorValidated = false
+ }
+
+ private func warmupTorIfConfigured() {
+ guard database.globalConfig().useTor() else { return }
+ let mode = TorMode.fromConfig(try? database.globalConfig().get(key: .torMode))
+ guard mode == .builtIn else { return }
+
+ Task.detached {
+ do {
+ let endpoint = try await ensureBuiltInTorBootstrap()
+ Log.debug("Built-in Tor warmup started at \(endpoint)")
+ } catch {
+ Log.warn("Built-in Tor warmup failed: \(error.localizedDescription)")
+ }
+ }
+ }
+
public func findTapSignerWallet(_ ts: TapSigner) -> WalletMetadata? {
rust.findTapSignerWallet(tapSigner: ts)
}
@@ -161,6 +191,8 @@ private let sidebarNavigationDelayMs = 250
let state = rust.state()
router = state.router
+ self.rust.listenForUpdates(updater: self)
+ warmupTorIfConfigured()
}
/// Reload wallets from database (e.g. after cloud restore)
diff --git a/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json
new file mode 100644
index 000000000..e291434a2
--- /dev/null
+++ b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "onion.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg
new file mode 100644
index 000000000..1f3ff08c7
--- /dev/null
+++ b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift
index 51371c86b..7e70d0d26 100644
--- a/ios/Cove/CoveMainView.swift
+++ b/ios/Cove/CoveMainView.swift
@@ -7,6 +7,64 @@
import SwiftUI
+private struct StartupTorUnavailable: Equatable {
+ let mode: TorMode
+ let endpoint: String
+ let error: String
+
+ var title: String {
+ switch mode {
+ case .orbot:
+ "Orbot is not active"
+ case .external:
+ "Tor proxy unavailable"
+ case .builtIn:
+ "Built-in Tor unavailable"
+ }
+ }
+
+ var message: String {
+ switch mode {
+ case .orbot:
+ "Cove is configured to use Orbot, but the SOCKS endpoint at \(endpoint) is not reachable. Start Orbot, fix Tor settings, or switch to a clearnet node."
+ case .external:
+ "Cove is configured to use a custom SOCKS5 proxy at \(endpoint), but it is not reachable. Fix the proxy or switch to a clearnet node."
+ case .builtIn:
+ "Built-in Tor is configured, but startup failed: \(error)"
+ }
+ }
+}
+
+private struct StartupTorWarningModifier: ViewModifier {
+ @Binding var warning: StartupTorUnavailable?
+ let openOrbot: () -> Void
+ let openNetworkSettings: () -> Void
+ let useClearnetNode: () -> Void
+
+ func body(content: Content) -> some View {
+ content.alert(
+ warning?.title ?? "Tor unavailable",
+ isPresented: Binding(
+ get: { warning != nil },
+ set: { if !$0 { warning = nil } }
+ ),
+ presenting: warning
+ ) { warning in
+ if warning.mode == .orbot {
+ Button("Open Orbot", action: openOrbot)
+ }
+
+ Button("Network Settings", action: openNetworkSettings)
+ Button("Use clearnet node", role: .destructive, action: useClearnetNode)
+ Button("Ignore", role: .cancel) {
+ self.warning = nil
+ }
+ } message: { warning in
+ Text(warning.message)
+ }
+ }
+}
+
struct CoveMainView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var phase
@@ -18,6 +76,8 @@ struct CoveMainView: View {
@State var showCover: Bool = true
@State var scannedCode: TaggedItem? = .none
@State var coverClearTask: Task?
+ @State private var startupTorUnavailable: StartupTorUnavailable?
+ @State private var startupTorCheckSignature: String?
@ViewBuilder
private func alertMessage(alert: TaggedItem) -> some View {
@@ -331,6 +391,10 @@ struct CoveMainView: View {
}
.onChange(of: auth.lockState) { old, new in
Log.warn("AUTH LOCK STATE CHANGED: \(old) --> \(new)")
+
+ if new == .unlocked {
+ Task { await checkStartupTorAvailabilityIfNeeded() }
+ }
}
.environment(app)
.environment(auth)
@@ -376,6 +440,7 @@ struct CoveMainView: View {
guard app.asyncRuntimeReady else { return }
app.dispatch(action: AppAction.updateFees)
app.dispatch(action: AppAction.updateFiatPrices)
+ Task { await checkStartupTorAvailabilityIfNeeded() }
}
// PIN auth active, no biometrics, leaving app
@@ -462,6 +527,95 @@ struct CoveMainView: View {
}
}
+ @MainActor
+ private func checkStartupTorAvailabilityIfNeeded() async {
+ guard phase == .active else { return }
+ guard app.isTermsAccepted else { return }
+ guard auth.lockState == .unlocked || !auth.isAuthEnabled else { return }
+
+ let config = Database().globalConfig()
+ guard config.useTor() else {
+ startupTorCheckSignature = nil
+ return
+ }
+
+ let mode = TorMode.fromConfig(try? config.get(key: .torMode))
+ guard mode != .builtIn else { return }
+
+ let endpoint: (host: String, port: Int)
+ switch mode {
+ case .builtIn:
+ return
+ case .orbot:
+ endpoint = ("127.0.0.1", 9050)
+ case .external:
+ let host = (try? config.get(key: .torExternalHost))?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ endpoint = (host?.isEmpty == false ? host! : "127.0.0.1", Int(config.torExternalPort()))
+ }
+
+ let endpointDescription = "\(endpoint.host):\(endpoint.port)"
+ let signature = "\(mode.persistedValue)|\(endpointDescription)|\(app.selectedNode.url)"
+ guard startupTorCheckSignature != signature || startupTorUnavailable != nil else { return }
+ startupTorCheckSignature = signature
+
+ let result = await testSocksEndpoint(host: endpoint.host, port: endpoint.port, timeout: 2)
+ if result.isSuccess() {
+ startupTorUnavailable = nil
+ return
+ }
+
+ guard let error = result.failureValue else { return }
+
+ startupTorUnavailable = StartupTorUnavailable(
+ mode: mode,
+ endpoint: endpointDescription,
+ error: error.localizedDescription
+ )
+ }
+
+ @MainActor
+ private func useClearnetAfterStartupTorFailure() async {
+ let config = Database().globalConfig()
+
+ do {
+ var message = "Tor has been disabled."
+ if isOnionNodeUrl(app.selectedNode.url) {
+ let fallback = try await switchToFirstClearnetPresetNode(NodeSelector())
+ app.selectedNode = fallback
+ message = "Switched to \(fallback.name) and disabled Tor."
+ }
+
+ app.clearPendingNodeTorDraft()
+ try? config.setUseTor(useTor: false)
+ startupTorUnavailable = nil
+ startupTorCheckSignature = nil
+ app.alertState = .init(.general(title: "Tor disabled", message: message))
+ } catch {
+ app.alertState = .init(
+ .general(
+ title: "Unable to switch node",
+ message: "Cove could not switch to a clearnet node: \(error.localizedDescription)"
+ )
+ )
+ }
+ }
+
+ private func openOrbotFromStartupWarning() {
+ startupTorUnavailable = nil
+ guard let url = URL(string: "orbot://") else { return }
+ UIApplication.shared.open(url, options: [:]) { success in
+ if !success {
+ app.alertState = .init(
+ .general(
+ title: "Unable to open Orbot",
+ message: "Open Orbot manually, then return to Cove and retry Tor."
+ )
+ )
+ }
+ }
+ }
+
var body: some View {
CloudBackupPresentationHost(app: app, auth: auth, isCoverPresented: showCover) {
BodyView
@@ -486,5 +640,16 @@ struct CoveMainView: View {
.onOpenURL(perform: ScanManager.shared.handleFileOpen)
.onChange(of: phase, initial: true, handleScenePhaseChange)
}
+ .modifier(StartupTorWarningModifier(
+ warning: $startupTorUnavailable,
+ openOrbot: openOrbotFromStartupWarning,
+ openNetworkSettings: {
+ startupTorUnavailable = nil
+ app.pushRoute(.settings(.network))
+ },
+ useClearnetNode: {
+ Task { await useClearnetAfterStartupTorFailure() }
+ }
+ ))
}
}
diff --git a/ios/Cove/Extention/Result+Ext.swift b/ios/Cove/Extention/Result+Ext.swift
index 826adc96d..c904c9c5b 100644
--- a/ios/Cove/Extention/Result+Ext.swift
+++ b/ios/Cove/Extention/Result+Ext.swift
@@ -18,6 +18,16 @@ public extension Result where Failure == Swift.Error {
}
public extension Result {
+ var successValue: Success? {
+ guard case let .success(value) = self else { return nil }
+ return value
+ }
+
+ var failureValue: Failure? {
+ guard case let .failure(error) = self else { return nil }
+ return error
+ }
+
func isSuccess() -> Bool {
switch self {
case .success: true
diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift
index 73db614fb..07156a970 100644
--- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift
+++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift
@@ -53,6 +53,8 @@ struct SelectedWalletScreen: View {
/// private
@State private var runPostRefresh = false
+ @State private var torQuickStatus = TorQuickStatus()
+ @State private var showTorQuickStatus = false
var metadata: WalletMetadata {
manager.walletMetadata
@@ -221,6 +223,192 @@ struct SelectedWalletScreen: View {
return .white
}
+ @MainActor
+ private func pollTorQuickStatus() async {
+ while !Task.isCancelled {
+ await refreshTorQuickStatus()
+ try? await Task.sleep(for: .seconds(3))
+ }
+ }
+
+ @MainActor
+ private func refreshTorQuickStatus() async {
+ let config = Database().globalConfig()
+ guard config.useTor() else {
+ torQuickStatus = TorQuickStatus()
+ return
+ }
+
+ let mode = TorMode.fromConfig(try? config.get(key: .torMode))
+ var quick = TorQuickStatus(enabled: true)
+
+ switch mode {
+ case .builtIn:
+ await refreshBuiltInQuickStatus(quick: &quick)
+ case .orbot:
+ await refreshProxyQuickStatus(
+ host: "127.0.0.1",
+ port: 9050,
+ modeTitle: "Orbot",
+ quick: &quick
+ )
+ case .external:
+ let host = (try? config.get(key: .torExternalHost))?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ await refreshProxyQuickStatus(
+ host: host?.isEmpty == false ? host! : "127.0.0.1",
+ port: Int(config.torExternalPort()),
+ modeTitle: "Custom SOCKS5",
+ quick: &quick
+ )
+ }
+
+ applyWalletQuickStatus(to: &quick)
+ quick.overall = overallTorQuickDot(quick)
+ torQuickStatus = quick
+ }
+
+ @MainActor
+ private func refreshBuiltInQuickStatus(quick: inout TorQuickStatus) async {
+ do {
+ _ = try await ensureBuiltInTorBootstrap()
+ } catch {
+ quick.torConnection = .red
+ quick.torMessage = "Built-in Tor failed: \(error.localizedDescription)"
+ quick.logs = recentTorLogs(torConnectionLogs())
+ return
+ }
+
+ let logs = torConnectionLogs()
+ let snapshot = deriveBuiltInBootstrapSnapshot(logs)
+ let structuredStatus = builtInTorBootstrapStatus()
+ let hasStructuredStatus = structuredStatus.launched
+ quick.logs = recentTorLogs(logs)
+
+ if structuredStatus.ready || (!hasStructuredStatus && snapshot.isReady) {
+ quick.torConnection = .green
+ quick.torMessage = "Built-in Tor ready"
+ } else if let lastError = structuredStatus.lastError {
+ quick.torConnection = .red
+ quick.torMessage = lastError
+ } else if !hasStructuredStatus, snapshot.hasError {
+ quick.torConnection = .red
+ quick.torMessage = snapshot.step
+ } else {
+ let message =
+ structuredStatus.blocked.map { "Blocked: \($0)" }
+ ?? (leadingPercent(snapshot.step) != nil ? snapshot.step : nil)
+ ?? (hasStructuredStatus && !structuredStatus.message.isEmpty ? structuredStatus.message : snapshot.step)
+ let percent = leadingPercent(message) ?? (hasStructuredStatus ? Int(structuredStatus.percent) : snapshot.percent)
+ quick.torConnection = .yellow
+ quick.torMessage = "Built-in Tor bootstrapping (\(percent)%)"
+ }
+ }
+
+ @MainActor
+ private func refreshProxyQuickStatus(
+ host: String,
+ port: Int,
+ modeTitle: String,
+ quick: inout TorQuickStatus
+ ) async {
+ let result = await testSocksEndpoint(host: host, port: port, timeout: 1.5)
+ switch result {
+ case .success:
+ quick.torConnection = .green
+ quick.torMessage = "\(modeTitle) proxy reachable at \(host):\(port)"
+ case let .failure(error):
+ quick.torConnection = .red
+ quick.torMessage = "\(modeTitle) proxy unavailable: \(error.localizedDescription)"
+ }
+ }
+
+ private func applyWalletQuickStatus(to quick: inout TorQuickStatus) {
+ if quick.torConnection == .yellow {
+ quick.nodeReachable = .yellow
+ quick.nodeMessage = "Waiting for Tor"
+ quick.nodeSynced = .yellow
+ quick.syncMessage = "Waiting for Tor"
+ return
+ }
+
+ if quick.torConnection == .red {
+ quick.nodeReachable = .red
+ quick.nodeMessage = "Tor unavailable"
+ quick.nodeSynced = .red
+ quick.syncMessage = "Tor unavailable"
+ return
+ }
+
+ if case .nodeConnectionFailed = manager.errorAlert {
+ quick.nodeReachable = .red
+ quick.nodeMessage = "Node connection failed"
+ } else {
+ quick.nodeReachable = .green
+ quick.nodeMessage = "Node reachable"
+ }
+
+ switch manager.loadState {
+ case .loading:
+ quick.nodeSynced = .yellow
+ quick.syncMessage = "Wallet loading"
+ case .scanning:
+ quick.nodeSynced = .yellow
+ quick.syncMessage = "Wallet syncing"
+ case .loaded:
+ quick.nodeSynced = .green
+ quick.syncMessage = "Wallet synced"
+ }
+ }
+
+ private func overallTorQuickDot(_ status: TorQuickStatus) -> TorStatusDot {
+ let dots = [status.torConnection, status.nodeReachable, status.nodeSynced]
+ if dots.allSatisfy({ $0 == .green }) { return .green }
+ if dots.contains(.red) { return .red }
+ if dots.contains(.yellow) { return .yellow }
+ return .gray
+ }
+
+ private func recentTorLogs(_ logs: [String]) -> [String] {
+ let usefulMarkers = [
+ "arti_client::status",
+ "tor_dirmgr",
+ "tor_guardmgr",
+ "tor_runtime",
+ "bootstrapped",
+ "bootstrap",
+ "directory",
+ "consensus",
+ "microdescriptors",
+ "failed",
+ "error",
+ "warn",
+ ]
+ let usefulLogs = logs
+ .filter { line in
+ usefulMarkers.contains { marker in
+ line.range(of: marker, options: .caseInsensitive) != nil
+ }
+ }
+ .map { line in
+ line.replacingOccurrences(
+ of: #"^\[(INFO|WARN|ERROR|DEBUG) [^\]]+]\s*"#,
+ with: "",
+ options: .regularExpression
+ )
+ }
+ .filter { !$0.isEmpty }
+
+ return Array(NSOrderedSet(array: usefulLogs).array.compactMap { $0 as? String }.suffix(6))
+ }
+
+ private func leadingPercent(_ message: String) -> Int? {
+ guard let match = message.range(of: #"^\d{1,3}(?=%:)"#, options: .regularExpression) else {
+ return nil
+ }
+ return Int(message[match]).map { min(max($0, 0), 100) }
+ }
+
var titleContent: some View {
HStack(spacing: 10) {
if case .cold = metadata.walletType {
@@ -252,6 +440,31 @@ struct SelectedWalletScreen: View {
var MainToolBar: some ToolbarContent {
ToolbarItemGroup(placement: .navigationBarTrailing) {
HStack(spacing: 5) {
+ if torQuickStatus.enabled {
+ Button(action: { showTorQuickStatus.toggle() }) {
+ HStack(spacing: 2) {
+ Image("iconTorOnion")
+ .renderingMode(.template)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 26, height: 26)
+
+ BlinkingTorStatusDot(dot: torQuickStatus.overall, size: 10)
+ }
+ .adaptiveToolbarItemStyle(isPastHeader: shouldShowNavBar)
+ }
+ .popover(isPresented: $showTorQuickStatus) {
+ TorQuickStatusPopover(
+ status: torQuickStatus,
+ openNetworkSettings: {
+ showTorQuickStatus = false
+ app.pushRoute(.settings(.network))
+ }
+ )
+ .presentationCompactAdaptation(.popover)
+ }
+ }
+
Button(action: {
app.sheetState = .init(.qr)
}) {
@@ -399,6 +612,9 @@ struct SelectedWalletScreen: View {
.padding(.top, 10)
}
.onChange(of: scannedLabels, initial: false, onChangeOfScannedLabels)
+ .task(id: manager.id) {
+ await pollTorQuickStatus()
+ }
}
func handleScrollToTransaction(proxy: ScrollViewProxy) {
@@ -567,6 +783,128 @@ struct VerifyReminder: View {
}
}
+private struct TorQuickStatusPopover: View {
+ let status: TorQuickStatus
+ let openNetworkSettings: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Tor Network Status")
+ .font(.headline.weight(.bold))
+
+ VStack(spacing: 12) {
+ TorQuickStatusRow(
+ title: "Tor connection",
+ detail: status.torMessage,
+ dot: status.torConnection
+ )
+ TorQuickStatusRow(
+ title: "Node reachable",
+ detail: status.nodeMessage,
+ dot: status.nodeReachable
+ )
+ TorQuickStatusRow(
+ title: "Node synced",
+ detail: status.syncMessage,
+ dot: status.nodeSynced
+ )
+ }
+
+ if !status.logs.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Recent logs")
+ .font(.caption.weight(.bold))
+ .foregroundStyle(.blue)
+
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(Array(status.logs.enumerated()), id: \.offset) { _, line in
+ Text(line)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.midnightBlue.opacity(0.05))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ }
+
+ Button(action: openNetworkSettings) {
+ Text("Network Settings")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 4)
+ }
+ }
+ .padding(18)
+ .frame(width: 280)
+ }
+}
+
+private struct TorQuickStatusRow: View {
+ let title: String
+ let detail: String
+ let dot: TorStatusDot
+
+ var body: some View {
+ HStack(alignment: .center) {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(title.uppercased())
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(.secondary.opacity(0.8))
+
+ Text(detail)
+ .font(.system(size: 13, weight: .semibold))
+ }
+
+ Spacer()
+
+ if dot == .green {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(dot.color)
+ .font(.system(size: 16))
+ } else {
+ Circle()
+ .fill(dot.color)
+ .frame(width: 12, height: 12)
+ }
+ }
+ }
+}
+
+private struct BlinkingTorStatusDot: View {
+ let dot: TorStatusDot
+ let size: CGFloat
+
+ @State private var pulse = false
+
+ var body: some View {
+ Circle()
+ .fill(dot.color)
+ .frame(width: size, height: size)
+ .opacity(dot == .yellow ? (pulse ? 1.0 : 0.28) : 1.0)
+ .onAppear(perform: startPulseIfNeeded)
+ .onChange(of: dot) { _, _ in
+ startPulseIfNeeded()
+ }
+ }
+
+ private func startPulseIfNeeded() {
+ guard dot == .yellow else {
+ pulse = false
+ return
+ }
+
+ pulse = false
+ withAnimation(.easeInOut(duration: 0.95).repeatForever(autoreverses: true)) {
+ pulse = true
+ }
+ }
+}
+
#Preview {
AsyncPreview {
NavigationStack {
diff --git a/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift
new file mode 100644
index 000000000..c48a48e20
--- /dev/null
+++ b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift
@@ -0,0 +1,1130 @@
+import SwiftUI
+import UIKit
+
+private enum TorTestStepStatus: Equatable {
+ case pending
+ case running
+ case passed
+ case failed
+}
+
+private struct TorTestStep: Equatable, Identifiable {
+ var id: String {
+ key
+ }
+
+ let key: String
+ var title: String
+ var detail: String
+ var status: TorTestStepStatus = .pending
+}
+
+private struct TorConnectionTestState: Equatable {
+ var running = false
+ var finished = false
+ var steps: [TorTestStep] = []
+ var logs: [String] = []
+}
+
+struct NetworkSettingsView: View {
+ @Binding var selection: Network
+
+ @Environment(AppManager.self) private var app
+
+ private let db = Database()
+ private let nodeSelector = NodeSelector()
+
+ @State private var pendingNetwork: Network?
+ @State private var torSettingsDiscovered = false
+ @State private var uiState = TorUiState()
+ @State private var showModeDialog = false
+ @State private var showFullLogSheet = false
+ @State private var showTorTestSheet = false
+ @State private var showDisableTorOnionAlert = false
+ @State private var rustTorLogCount = 0
+ @State private var builtInWarmupRequested = false
+ @State private var torTestState = TorConnectionTestState()
+ @State private var autoPendingTestKey: String?
+ @State private var noticeMessage: String?
+
+ private var globalConfig: GlobalConfigTable {
+ db.globalConfig()
+ }
+
+ private var globalFlag: GlobalFlagTable {
+ db.globalFlag()
+ }
+
+ var body: some View {
+ Form {
+ networkSection
+
+ if torSettingsDiscovered {
+ torMainSection
+
+ if uiState.enabled {
+ switch uiState.mode {
+ case .builtIn:
+ builtInSection
+ case .external:
+ externalSection
+ case .orbot:
+ orbotSection
+ }
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .navigationTitle("Network")
+ .onAppear(perform: loadPersistedTorState)
+ .task {
+ await refreshInitialTorState()
+ }
+ .task(id: "\(uiState.enabled)-\(uiState.mode.persistedValue)") {
+ await pollBuiltInTorLogsIfNeeded()
+ }
+ .onChange(of: uiState.status) { _, _ in
+ Task { await maybeRunPendingOnionValidation() }
+ }
+ .alert("Change Network?", isPresented: Binding(
+ get: { pendingNetwork != nil },
+ set: { if !$0 { pendingNetwork = nil } }
+ )) {
+ Button("Yes, Change Network") {
+ if let network = pendingNetwork {
+ app.dispatch(action: .changeNetwork(network: network))
+ app.rust.selectLatestOrNewWallet()
+ selection = network
+ app.popRoute()
+ }
+ pendingNetwork = nil
+ }
+ Button("Cancel", role: .cancel) {
+ pendingNetwork = nil
+ }
+ } message: {
+ if let network = pendingNetwork {
+ Text("Switching to \(network.displayName) will take you to a wallet on that network.")
+ }
+ }
+ .alert("Disable Tor?", isPresented: $showDisableTorOnionAlert) {
+ Button("Disable and switch node") {
+ Task { await disableTorWithClearnetFallback() }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Your active or pending node uses an onion address. Disable Tor only if Cove first switches to a clearnet node.")
+ }
+ .alert("Network", isPresented: Binding(
+ get: { noticeMessage != nil },
+ set: { if !$0 { noticeMessage = nil } }
+ )) {
+ Button("OK") { noticeMessage = nil }
+ } message: {
+ Text(noticeMessage ?? "")
+ }
+ .confirmationDialog("Connection Mode", isPresented: $showModeDialog, titleVisibility: .visible) {
+ ForEach(TorMode.uiCases, id: \.self) { mode in
+ Button(mode.title) {
+ setTorMode(mode)
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ }
+ .sheet(isPresented: $showFullLogSheet) {
+ TorFullLogSheet(logLines: uiState.logLines)
+ }
+ .sheet(isPresented: $showTorTestSheet) {
+ TorTestSheetContent(state: torTestState) {
+ if !torTestState.running {
+ showTorTestSheet = false
+ }
+ }
+ .interactiveDismissDisabled(torTestState.running)
+ .presentationDetents([.height(560), .large])
+ }
+ }
+
+ private var networkSection: some View {
+ Section {
+ ForEach(Network.allCases, id: \.self) { item in
+ HStack {
+ if !item.symbol.isEmpty {
+ Image(systemName: item.symbol)
+ }
+
+ Text(item.displayName)
+ .font(.subheadline)
+
+ Spacer()
+
+ if selection == item {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ .font(.footnote)
+ .fontWeight(.semibold)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if item != selection {
+ pendingNetwork = item
+ }
+ }
+ }
+ }
+ }
+
+ private var torMainSection: some View {
+ Section("Tor Network") {
+ Toggle(isOn: Binding(
+ get: { uiState.enabled },
+ set: handleUseTorToggle
+ )) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Use Tor")
+ Text("Route traffic through the Tor network for enhanced privacy")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Menu {
+ ForEach(TorMode.uiCases, id: \.self) { mode in
+ Button(mode.title) {
+ setTorMode(mode)
+ }
+ }
+ } label: {
+ HStack {
+ Label("Connection Mode", systemImage: "network")
+ Spacer()
+ Text(uiState.mode.shortTitle)
+ .foregroundStyle(.blue)
+ Image(systemName: "chevron.up.chevron.down")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .disabled(!uiState.enabled)
+ .opacity(uiState.enabled ? 1 : 0.5)
+
+ HStack {
+ Label("Tor Status", systemImage: "info.circle")
+ Spacer()
+ Text(statusLabel)
+ .foregroundStyle(.secondary)
+ if uiState.enabled, uiState.status == .ready {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ .opacity(uiState.enabled ? 1 : 0.5)
+ }
+ }
+
+ private var builtInSection: some View {
+ Group {
+ Section("Connection Status") {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Text("Bootstrap Progress")
+ Spacer()
+ Text("\(uiState.progressPercent)%")
+ .foregroundStyle(.blue)
+ }
+
+ ProgressView(value: Double(uiState.progressPercent), total: 100)
+
+ Text("Status: \(uiState.currentStep)")
+ .font(.subheadline)
+
+ Button("Test connection") {
+ Task { await runProgressiveTorTest() }
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.vertical, 4)
+ }
+
+ Section {
+ Button {
+ appendTorLog("Opened Tor connection logs")
+ syncRustTorLogs()
+ showFullLogSheet = true
+ } label: {
+ Label("Connection Logs", systemImage: "terminal")
+ }
+ } footer: {
+ Text("View detailed Tor bootstrap logs")
+ }
+ }
+ }
+
+ private var externalSection: some View {
+ Section("Custom Proxy Settings") {
+ Text("Configure your custom Tor proxy or SOCKS5 bridge")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ TextField("SOCKS Host", text: Binding(
+ get: { uiState.externalHost },
+ set: { value in
+ uiState.externalHost = value
+ uiState.externalValidationError = validateTorExternalConfig(
+ host: value,
+ port: uiState.externalPort
+ )
+ }
+ ))
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ TextField("SOCKS Port", text: Binding(
+ get: { uiState.externalPort },
+ set: { value in
+ uiState.externalPort = value
+ uiState.externalValidationError = validateTorExternalConfig(
+ host: uiState.externalHost,
+ port: value
+ )
+ }
+ ))
+ .keyboardType(.numberPad)
+
+ if let error = uiState.externalValidationError {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ HStack {
+ Button("Save config") {
+ saveExternalConfig()
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+
+ Button("Test connection") {
+ Task { await runProgressiveTorTest() }
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+
+ private var orbotSection: some View {
+ Section {
+ HStack {
+ Label(orbotTitle, systemImage: "gearshape")
+ Spacer()
+ if uiState.orbotStatus == .checking {
+ ProgressView()
+ }
+ }
+
+ Button {
+ Task { await refreshOrbotStatus() }
+ } label: {
+ Label("Refresh Status", systemImage: "arrow.clockwise")
+ }
+
+ HStack {
+ Button("Open Orbot") {
+ openOrbotBestEffort()
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+
+ Button("Test connection") {
+ guard uiState.orbotStatus == .detected else {
+ noticeMessage = "Orbot SOCKS endpoint is not reachable at 127.0.0.1:9050."
+ return
+ }
+ Task { await runProgressiveTorTest() }
+ }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.blue)
+ .frame(maxWidth: .infinity)
+ }
+ } header: {
+ Text("Orbot Integration")
+ } footer: {
+ Text("On iOS, Orbot mode is validated by checking the local SOCKS endpoint at 127.0.0.1:9050.")
+ }
+ }
+
+ private var statusLabel: String {
+ switch uiState.status {
+ case .disabled:
+ "Disabled"
+ case .bootstrapping:
+ "Needs testing"
+ case .ready:
+ "Configured"
+ case .error:
+ "Action required"
+ }
+ }
+
+ private var orbotTitle: String {
+ switch uiState.orbotStatus {
+ case .checking:
+ "Checking Orbot..."
+ case .detected:
+ "Orbot SOCKS Endpoint Reachable"
+ case .notDetected:
+ "Orbot SOCKS Endpoint Not Reachable"
+ }
+ }
+
+ private func loadPersistedTorState() {
+ let persistedUseTor = globalConfig.useTor()
+ let persistedMode = TorMode.fromConfig(try? globalConfig.get(key: .torMode))
+ let host = (try? globalConfig.get(key: .torExternalHost))?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let externalHost = (host?.isEmpty == false) ? host! : "127.0.0.1"
+ let externalPort = String(globalConfig.torExternalPort())
+
+ torSettingsDiscovered = globalFlag.getBoolConfig(key: .torSettingsDiscovered) || persistedUseTor
+ uiState = TorUiState(
+ enabled: persistedUseTor,
+ mode: persistedMode,
+ status: persistedUseTor ? .bootstrapping : .disabled,
+ progressPercent: persistedUseTor ? 1 : 0,
+ currentStep: persistedUseTor ? "Loading Tor status" : "Disabled",
+ latestLogLine: persistedUseTor ? "Loading Tor status" : "Tor is off",
+ logLines: persistedUseTor ? ["[\(torTimestamp())] Loading Tor status"] : ["Tor is off"],
+ externalHost: externalHost,
+ externalPort: externalPort,
+ externalValidationError: validateTorExternalConfig(host: externalHost, port: externalPort),
+ orbotStatus: .checking
+ )
+ }
+
+ private func refreshInitialTorState() async {
+ appendTorLog("Opened Tor connection logs")
+ if uiState.mode == .orbot {
+ await refreshOrbotStatus()
+ }
+
+ if uiState.enabled, uiState.mode == .builtIn {
+ await ensureBuiltInWarmup()
+ syncRustTorLogs()
+ }
+
+ await updateNonBuiltInStatus()
+ await maybeRunPendingOnionValidation()
+ }
+
+ private func handleUseTorToggle(_ enabled: Bool) {
+ if !enabled {
+ let activeNodeIsOnion = isOnionNodeUrl(app.selectedNode.url)
+ let pendingOnionExists = app.pendingNodeAwaitingTorSetup
+ && !app.pendingNodeUrl.isEmpty
+ && isOnionNodeUrl(app.pendingNodeUrl)
+
+ if activeNodeIsOnion || pendingOnionExists {
+ showDisableTorOnionAlert = true
+ return
+ }
+
+ clearPendingOnionDraft()
+ disableTorState()
+ appendTorLog("Tor disabled")
+ return
+ }
+
+ do {
+ try globalConfig.setUseTor(useTor: true)
+ } catch {
+ noticeMessage = "Could not enable Tor: \(error.localizedDescription)"
+ appendTorLog("Failed to enable Tor: \(error.localizedDescription)")
+ return
+ }
+
+ uiState.enabled = true
+ uiState.status = .bootstrapping
+ uiState.currentStep = "Preparing Tor"
+ appendTorLog("Tor enabled")
+
+ if uiState.mode == .builtIn {
+ Task { await ensureBuiltInWarmup() }
+ }
+ }
+
+ private func setTorMode(_ mode: TorMode) {
+ do {
+ try globalConfig.set(key: .torMode, value: mode.persistedValue)
+ } catch {
+ noticeMessage = "Could not save Tor mode: \(error.localizedDescription)"
+ appendTorLog("Failed to save Tor mode: \(error.localizedDescription)")
+ return
+ }
+
+ app.pendingNodeTorValidated = false
+ autoPendingTestKey = nil
+ uiState.mode = mode
+ uiState.status = mode == .builtIn ? .bootstrapping : .bootstrapping
+ uiState.progressPercent = mode == .builtIn ? 1 : 50
+ appendTorLog("Switched Tor mode to \(mode.shortTitle)")
+
+ if mode == .builtIn, uiState.enabled {
+ builtInWarmupRequested = false
+ Task { await ensureBuiltInWarmup() }
+ } else if mode == .orbot {
+ Task { await refreshOrbotStatus() }
+ }
+ }
+
+ private func saveExternalConfig() {
+ let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort)
+ if let validationError {
+ uiState.externalValidationError = validationError
+ noticeMessage = validationError
+ return
+ }
+
+ do {
+ try globalConfig.set(key: .torExternalHost, value: uiState.externalHost)
+ if let port = UInt16(uiState.externalPort) {
+ try globalConfig.setTorExternalPort(port: port)
+ }
+ } catch {
+ noticeMessage = "Could not save Tor proxy configuration: \(error.localizedDescription)"
+ appendTorLog("Failed to save external Tor config: \(error.localizedDescription)")
+ return
+ }
+
+ app.pendingNodeTorValidated = false
+ let proxyLog = redactedProxyForLog(host: uiState.externalHost, port: Int(uiState.externalPort) ?? 0)
+ appendTorLog("Saved external Tor config: \(proxyLog)")
+ noticeMessage = "Tor proxy configuration saved."
+ }
+
+ private func disableTorState() {
+ do {
+ try globalConfig.setUseTor(useTor: false)
+ } catch {
+ noticeMessage = "Could not disable Tor: \(error.localizedDescription)"
+ appendTorLog("Failed to disable Tor: \(error.localizedDescription)")
+ return
+ }
+
+ uiState.enabled = false
+ uiState.status = .disabled
+ uiState.progressPercent = 0
+ uiState.currentStep = "Disabled"
+ uiState.latestLogLine = "Tor is off"
+ builtInWarmupRequested = false
+ }
+
+ private func disableTorWithClearnetFallback() async {
+ if isOnionNodeUrl(app.selectedNode.url) {
+ do {
+ let fallback = try await switchToFirstClearnetPresetNode(nodeSelector)
+ appendTorLog("Switched active node to \(redactedNodeForLog(fallback)) before disabling Tor")
+ noticeMessage = "Switched to \(fallback.name) and disabled Tor."
+ } catch {
+ appendTorLog("Unable to disable Tor: clearnet fallback failed (\(error.localizedDescription))")
+ noticeMessage = "Could not switch to a clearnet node: \(error.localizedDescription)"
+ return
+ }
+ } else if app.pendingNodeAwaitingTorSetup,
+ !app.pendingNodeUrl.isEmpty,
+ isOnionNodeUrl(app.pendingNodeUrl)
+ {
+ appendTorLog("Discarded pending onion node draft before disabling Tor")
+ }
+
+ clearPendingOnionDraft()
+ disableTorState()
+ appendTorLog("Tor disabled")
+ }
+
+ private func clearPendingOnionDraft() {
+ app.clearPendingNodeTorDraft()
+ autoPendingTestKey = nil
+ }
+
+ private func appendTorLog(_ message: String) {
+ let entry = "[\(torTimestamp())] \(message)"
+ let merged = (uiState.logLines + [entry]).suffix(300)
+ uiState.logLines = Array(merged)
+ uiState.latestLogLine = message
+ }
+
+ private func appendTorTestLog(_ message: String) {
+ torTestState.logs = Array((torTestState.logs + [message]).suffix(150))
+ }
+
+ private func leadingPercent(_ message: String) -> Int? {
+ guard let match = message.range(of: #"^\d{1,3}(?=%:)"#, options: .regularExpression) else {
+ return nil
+ }
+ return Int(message[match]).map { min(max($0, 0), 100) }
+ }
+
+ private func syncRustTorLogs() {
+ guard uiState.enabled, uiState.mode == .builtIn else { return }
+
+ let rustLogs = torConnectionLogs()
+ if rustLogs.count < rustTorLogCount {
+ rustTorLogCount = 0
+ }
+
+ let newLogs = rustLogs.dropFirst(rustTorLogCount)
+ rustTorLogCount = rustLogs.count
+ if !newLogs.isEmpty {
+ uiState.logLines = Array((uiState.logLines + Array(newLogs)).suffix(300))
+ }
+
+ let snapshot = deriveBuiltInBootstrapSnapshot(rustLogs)
+ let structuredStatus = builtInTorBootstrapStatus()
+ let hasStructuredStatus = structuredStatus.launched
+ let nextStatus: TorStatus = if structuredStatus.ready {
+ .ready
+ } else if structuredStatus.lastError != nil {
+ .error
+ } else if hasStructuredStatus {
+ .bootstrapping
+ } else if snapshot.isReady {
+ .ready
+ } else if snapshot.hasError {
+ .error
+ } else {
+ .bootstrapping
+ }
+ let currentStep =
+ structuredStatus.lastError
+ ?? structuredStatus.blocked.map { "Blocked: \($0)" }
+ ?? (leadingPercent(snapshot.step) != nil ? snapshot.step : nil)
+ ?? (hasStructuredStatus && !structuredStatus.message.isEmpty ? structuredStatus.message : snapshot.step)
+ let messagePercent = leadingPercent(currentStep)
+ let snapshotPercent = messagePercent ?? (hasStructuredStatus ? Int(structuredStatus.percent) : snapshot.percent)
+ uiState.progressPercent = nextStatus == .ready ? 100 : snapshotPercent
+ uiState.currentStep = currentStep
+ uiState.latestLogLine = snapshot.lastLine
+ uiState.status = nextStatus
+ }
+
+ private func ensureBuiltInWarmup() async {
+ guard !builtInWarmupRequested else { return }
+ builtInWarmupRequested = true
+
+ do {
+ let endpoint = try await ensureBuiltInTorBootstrap()
+ appendTorLog("Built-in Tor bootstrap started at \(endpoint)")
+ syncRustTorLogs()
+ } catch {
+ uiState.status = .error
+ appendTorLog("Built-in Tor bootstrap failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func pollBuiltInTorLogsIfNeeded() async {
+ guard uiState.enabled, uiState.mode == .builtIn else { return }
+
+ await ensureBuiltInWarmup()
+ while !Task.isCancelled, uiState.enabled, uiState.mode == .builtIn {
+ syncRustTorLogs()
+ await maybeRunPendingOnionValidation()
+ try? await Task.sleep(for: .seconds(1))
+ }
+ }
+
+ private func refreshOrbotStatus() async {
+ uiState.orbotStatus = .checking
+ let reachable = await (testSocksEndpoint(host: "127.0.0.1", port: 9050, timeout: 1.5)).isSuccess()
+ uiState.orbotStatus = reachable ? .detected : .notDetected
+ appendTorLog(reachable ? "Orbot SOCKS endpoint reachable" : "Orbot SOCKS endpoint not reachable")
+ await updateNonBuiltInStatus()
+ }
+
+ private func updateNonBuiltInStatus() async {
+ guard uiState.enabled else {
+ uiState.status = .disabled
+ uiState.progressPercent = 0
+ uiState.currentStep = "Disabled"
+ uiState.latestLogLine = "Tor is off"
+ return
+ }
+
+ switch uiState.mode {
+ case .builtIn:
+ syncRustTorLogs()
+ case .external:
+ let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort)
+ uiState.externalValidationError = validationError
+ uiState.status = validationError == nil ? .bootstrapping : .error
+ uiState.progressPercent = validationError == nil ? 50 : 0
+ uiState.currentStep = validationError == nil ? "Save and test custom proxy" : "External proxy config invalid"
+ uiState.latestLogLine = validationError
+ ?? redactedProxyForLog(host: uiState.externalHost, port: Int(uiState.externalPort) ?? 0)
+ case .orbot:
+ uiState.status = uiState.orbotStatus == .detected ? .bootstrapping : .error
+ uiState.progressPercent = uiState.orbotStatus == .detected ? 50 : 0
+ uiState.currentStep = uiState.orbotStatus == .detected ? "Test Orbot connection" : "Start Orbot first"
+ uiState.latestLogLine = uiState.orbotStatus == .detected
+ ? "Orbot endpoint reachable"
+ : "Orbot endpoint unavailable"
+ }
+ }
+
+ private func testTorProxy(host: String, port: Int) async -> Result {
+ syncRustTorLogs()
+ let proxyLog = redactedProxyForLog(host: host, port: port)
+ appendTorLog("Testing SOCKS endpoint \(proxyLog)")
+ let result = await testSocksEndpoint(host: host, port: port)
+ switch result {
+ case .success:
+ appendTorLog("SOCKS endpoint reachable: \(proxyLog)")
+ case let .failure(error):
+ appendTorLog("SOCKS endpoint failed: \(proxyLog) (\(error.localizedDescription))")
+ }
+ syncRustTorLogs()
+ return result
+ }
+
+ private func resolveNodeForTorTest() async throws -> Node {
+ if app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty {
+ let typeName = app.pendingNodeTypeName.isEmpty ? "Custom Electrum" : app.pendingNodeTypeName
+ let node = try nodeSelector.parseCustomNode(
+ url: app.pendingNodeUrl,
+ name: typeName,
+ enteredName: app.pendingNodeName
+ )
+ appendTorLog("Using pending node for Tor test: \(redactedNodeForLog(node))")
+ return node
+ }
+
+ appendTorLog("Using selected node for Tor test: \(redactedNodeForLog(app.selectedNode))")
+ return app.selectedNode
+ }
+
+ private func runNodeTorTest(_ node: Node) async -> Result {
+ syncRustTorLogs()
+ let nodeLog = redactedNodeForLog(node)
+ appendTorLog("Checking node via Tor: \(nodeLog)")
+ do {
+ try await nodeSelector.checkSelectedNode(node: node)
+ appendTorLog("Node check passed: \(nodeLog)")
+ syncRustTorLogs()
+ return .success(())
+ } catch {
+ appendTorLog("Node check failed: \(nodeLog) (\(error.localizedDescription))")
+ syncRustTorLogs()
+ return .failure(error)
+ }
+ }
+
+ private func updateTorTestStep(_ key: String, status: TorTestStepStatus, detail: String? = nil) {
+ torTestState.steps = torTestState.steps.map { step in
+ guard step.key == key else { return step }
+ var updated = step
+ updated.status = status
+ if let detail {
+ updated.detail = detail
+ }
+ return updated
+ }
+ }
+
+ private func runTorApiTestWithRetries(host: String, port: Int) async -> Result {
+ let maxAttempts = 5
+ var timeout: TimeInterval = 15
+ var lastError: Error?
+
+ for attempt in 1 ... maxAttempts {
+ appendTorTestLog("Tor API check attempt \(attempt)/\(maxAttempts) (timeout=\(Int(timeout * 1000))ms)")
+ let result = await testTorApiThroughSocks(host: host, port: port, timeout: timeout)
+ if result.isSuccess() {
+ return result
+ }
+
+ let error = result.failureValue
+ lastError = error
+ let lowerMessage = error?.localizedDescription.lowercased() ?? ""
+ let timeoutLike = lowerMessage.contains("timeout") || lowerMessage.contains("timed out")
+ if !timeoutLike || attempt == maxAttempts {
+ return .failure(error ?? TorSupportError.timeout)
+ }
+
+ let snapshot = deriveBuiltInBootstrapSnapshot(torConnectionLogs())
+ appendTorTestLog("Tor API timed out; retrying while Tor bootstraps (\(snapshot.percent)% \(snapshot.step.lowercased()))")
+ syncRustTorLogs()
+ try? await Task.sleep(for: .milliseconds(min(attempt * 1200, 6000)))
+ timeout = min(timeout + 4, 30)
+ }
+
+ return .failure(lastError ?? TorSupportError.timeout)
+ }
+
+ private func persistTorTestConfiguration(host: String, port: Int) throws {
+ try globalConfig.set(key: .torMode, value: uiState.mode.persistedValue)
+ if uiState.mode == .external {
+ guard let port = UInt16(exactly: port) else {
+ throw TorSupportError.invalidEndpoint
+ }
+ try globalConfig.set(key: .torExternalHost, value: host)
+ try globalConfig.setTorExternalPort(port: port)
+ }
+ try globalConfig.setUseTor(useTor: true)
+ }
+
+ private func runProgressiveTorTest() async {
+ let endpoint: (String, Int)
+ do {
+ switch uiState.mode {
+ case .builtIn:
+ let endpointValue = try await ensureBuiltInTorBootstrap()
+ endpoint = parseEndpointHostPort(endpointValue) ?? ("127.0.0.1", 39050)
+ case .orbot:
+ endpoint = ("127.0.0.1", 9050)
+ case .external:
+ if let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) {
+ uiState.externalValidationError = validationError
+ noticeMessage = validationError
+ return
+ }
+ endpoint = (uiState.externalHost, Int(uiState.externalPort) ?? 9050)
+ }
+ } catch {
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ appendTorLog("Failed to prepare Tor endpoint: \(error.localizedDescription)")
+ noticeMessage = "Failed to prepare Tor endpoint: \(error.localizedDescription)"
+ return
+ }
+
+ let (host, port) = endpoint
+ let proxyLog = redactedProxyForLog(host: host, port: port)
+
+ do {
+ try persistTorTestConfiguration(host: host, port: port)
+ } catch {
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ appendTorLog("Failed to save Tor settings before test: \(error.localizedDescription)")
+ noticeMessage = "Could not save Tor settings: \(error.localizedDescription)"
+ return
+ }
+
+ showTorTestSheet = true
+ torTestState = TorConnectionTestState(
+ running: true,
+ finished: false,
+ steps: [
+ TorTestStep(key: "proxy", title: "Tor proxy reachable", detail: "Checking SOCKS endpoint \(proxyLog)"),
+ TorTestStep(key: "api", title: "Tor API reports Tor exit", detail: "Checking torproject API over SOCKS"),
+ TorTestStep(key: "node", title: "Node reachable via Tor", detail: "Checking selected node over Tor"),
+ ],
+ logs: ["Starting Tor connection test (\(uiState.mode.shortTitle))"]
+ )
+
+ updateTorTestStep("proxy", status: .running)
+ let proxyResult = await testTorProxy(host: host, port: port)
+ if let error = proxyResult.failureValue {
+ updateTorTestStep("proxy", status: .failed, detail: "SOCKS check failed: \(error.localizedDescription)")
+ appendTorTestLog("SOCKS check failed: \(error.localizedDescription)")
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Tor proxy unavailable: \(error.localizedDescription)"
+ return
+ }
+ updateTorTestStep("proxy", status: .passed, detail: "SOCKS endpoint reachable")
+ appendTorTestLog("SOCKS endpoint reachable")
+
+ updateTorTestStep("api", status: .running)
+ let torApiResult = await runTorApiTestWithRetries(host: host, port: port)
+ if let error = torApiResult.failureValue {
+ updateTorTestStep("api", status: .failed, detail: "Tor API request failed: \(error.localizedDescription)")
+ appendTorTestLog("Tor API request failed: \(error.localizedDescription)")
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Tor API check failed: \(error.localizedDescription)"
+ return
+ }
+
+ guard let apiSnapshot = torApiResult.successValue, apiSnapshot.isTor else {
+ let raw = torApiResult.successValue?.raw ?? "missing response"
+ updateTorTestStep("api", status: .failed, detail: "Tor API response did not confirm Tor routing")
+ appendTorTestLog("Tor API did not confirm Tor routing: \(raw)")
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Tor API check failed: traffic is not exiting through Tor."
+ return
+ }
+ let apiDetail = "Tor API confirmed Tor routing\(apiSnapshot.ip.map { " (\($0))" } ?? "")"
+ updateTorTestStep("api", status: .passed, detail: apiDetail)
+ appendTorTestLog(apiDetail)
+
+ updateTorTestStep("node", status: .running)
+ do {
+ let node = try await resolveNodeForTorTest()
+ let nodeResult = await runNodeTorTest(node)
+ if let error = nodeResult.failureValue {
+ updateTorTestStep("node", status: .failed, detail: "Node check failed: \(error.localizedDescription)")
+ appendTorTestLog("Node check failed: \(error.localizedDescription)")
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Node test failed: \(error.localizedDescription)"
+ return
+ }
+ } catch {
+ updateTorTestStep("node", status: .failed, detail: "Node parse failed: \(error.localizedDescription)")
+ appendTorTestLog("Node parse failed: \(error.localizedDescription)")
+ app.pendingNodeTorValidated = false
+ uiState.status = .error
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Node test failed: \(error.localizedDescription)"
+ return
+ }
+
+ updateTorTestStep("node", status: .passed, detail: "Node reachable via Tor")
+ appendTorTestLog("Node reachable via Tor")
+ app.pendingNodeTorValidated = true
+ if uiState.mode == .builtIn {
+ syncRustTorLogs()
+ } else {
+ uiState.status = .ready
+ uiState.progressPercent = 100
+ }
+ appendTorLog("\(uiState.mode.shortTitle) validation passed")
+
+ if app.pendingNodeAwaitingTorSetup {
+ appendTorTestLog("Pending onion node validated; applying configuration")
+ appendTorLog("Pending onion node validated; applying configuration")
+ app.popRoute()
+ }
+
+ torTestState.running = false
+ torTestState.finished = true
+ noticeMessage = "Tor connection test passed."
+ }
+
+ private func maybeRunPendingOnionValidation() async {
+ guard app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty else {
+ autoPendingTestKey = nil
+ return
+ }
+ guard uiState.enabled, !app.pendingNodeTorValidated, !torTestState.running else { return }
+
+ let flowKey = "\(uiState.mode.persistedValue)|\(app.pendingNodeUrl)|\(uiState.externalHost):\(uiState.externalPort)"
+ guard autoPendingTestKey != flowKey else { return }
+
+ let readyForAutoTest: Bool
+ switch uiState.mode {
+ case .builtIn:
+ readyForAutoTest = uiState.status == .ready
+ case .orbot:
+ let socksReady = await (testSocksEndpoint(host: "127.0.0.1", port: 9050, timeout: 1.2)).isSuccess()
+ readyForAutoTest = socksReady
+ case .external:
+ let valid = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) == nil
+ let socksReady = await (testSocksEndpoint(
+ host: uiState.externalHost,
+ port: Int(uiState.externalPort) ?? 0,
+ timeout: 1.2
+ )).isSuccess()
+ readyForAutoTest = valid && socksReady
+ }
+
+ guard readyForAutoTest else { return }
+ autoPendingTestKey = flowKey
+ appendTorLog("Pending onion node detected; starting automatic Tor validation")
+ await runProgressiveTorTest()
+ }
+
+ private func openOrbotBestEffort() {
+ if let url = URL(string: "orbot://") {
+ UIApplication.shared.open(url) { success in
+ if !success {
+ appendTorLog("Open Orbot requested; iOS could not open orbot://")
+ noticeMessage = "Open Orbot manually, then return to Cove and refresh status."
+ }
+ }
+ return
+ }
+
+ appendTorLog("Open Orbot requested; no iOS URL scheme available")
+ noticeMessage = "Open Orbot manually, then return to Cove and refresh status."
+ }
+}
+
+private struct TorFullLogSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let logLines: [String]
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(logLines.enumerated()), id: \.offset) { _, line in
+ Text("> \(line)")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .background(Color(.secondarySystemBackground))
+ .navigationTitle("Tor Logs")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button("Copy") {
+ UIPasteboard.general.string = logLines.joined(separator: "\n")
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct TorTestSheetContent: View {
+ let state: TorConnectionTestState
+ let onDismiss: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Text("Connection Test")
+ .font(.title2.weight(.bold))
+ .padding(.top, 20)
+ .padding(.bottom, 18)
+
+ VStack(spacing: 16) {
+ ForEach(state.steps) { step in
+ TorTestStepProgressRow(step: step)
+ }
+ }
+ .padding(.horizontal, 24)
+
+ Spacer().frame(height: 28)
+
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 8) {
+ Image(systemName: "terminal")
+ Text("LIVE TEST LOGS")
+ .font(.caption.weight(.bold))
+ .tracking(0.5)
+ }
+ .foregroundStyle(.white.opacity(0.7))
+
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(Array(state.logs.enumerated()), id: \.offset) { index, line in
+ Text(line)
+ .id(index)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.9))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ .frame(height: 120)
+ .onChange(of: state.logs.count, initial: true) {
+ if let last = state.logs.indices.last {
+ proxy.scrollTo(last, anchor: .bottom)
+ }
+ }
+ }
+ }
+ .padding(16)
+ .background(Color.midnightBlue)
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .padding(.horizontal, 24)
+ .padding(.top, 8)
+
+ Spacer().frame(height: 28)
+
+ Button(state.running ? "Running Test..." : "Done", action: onDismiss)
+ .disabled(state.running)
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(state.running ? Color.gray.opacity(0.35) : Color.midnightBtn)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .padding(.horizontal, 24)
+ .padding(.bottom, 28)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+private struct TorTestStepProgressRow: View {
+ let step: TorTestStep
+
+ private var statusColor: Color {
+ switch step.status {
+ case .passed:
+ TorStatusDot.green.color
+ case .failed:
+ TorStatusDot.red.color
+ case .running:
+ .blue
+ case .pending:
+ .gray.opacity(0.45)
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 16) {
+ Group {
+ switch step.status {
+ case .pending:
+ Circle()
+ .stroke(statusColor, lineWidth: 2)
+ .frame(width: 14, height: 14)
+ .padding(5)
+ case .running:
+ ProgressView()
+ .frame(width: 24, height: 24)
+ case .passed:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(statusColor)
+ .font(.title3)
+ case .failed:
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(statusColor)
+ .font(.title3)
+ }
+ }
+ .frame(width: 24)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(step.title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(step.status == .pending ? .secondary : .primary)
+
+ if step.status != .pending {
+ Text(step.detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ }
+ }
+}
diff --git a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift
index 6895f5fd0..4b685aaff 100644
--- a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift
+++ b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift
@@ -52,7 +52,7 @@ struct SettingsContainer: View {
case .main:
MainSettingsScreen()
case .network:
- SettingsPicker(selection: selectedNetwork)
+ NetworkSettingsView(selection: selectedNetwork)
case .appearance:
AppearancePicker
case .node:
diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift
index dccd4371b..e13e1ff22 100644
--- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift
+++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift
@@ -11,13 +11,17 @@ import SwiftUI
struct NodeSelectionView: View {
/// private
private let nodeSelector = NodeSelector()
+ private let db = Database()
+ @Environment(AppManager.self) private var app
+
+ @State private var selectedNodeSelection: NodeSelection
@State private var selectedNodeName: String
private var nodeList: [NodeSelection]
- @State private var nodeIsChecking = false
- @State private var customNodeName: String = ""
- @State private var customUrl: String = ""
+ @State private var customNodeName = ""
+ @State private var customUrl = ""
+ @State private var suppressCustomDraftActions = false
@State private var showParseUrlAlert = false
@State private var parseUrlMessage = ""
@@ -25,18 +29,32 @@ struct NodeSelectionView: View {
@State private var checkUrlTask: Task?
init() {
- selectedNodeName = nodeSelector.selectedNode().name
+ let selected = nodeSelector.selectedNode()
+ selectedNodeSelection = selected
+ selectedNodeName = selected.name
nodeList = nodeSelector.nodeList()
}
+ private var customElectrum: String {
+ "Custom Electrum"
+ }
+
+ private var customEsplora: String {
+ "Custom Esplora"
+ }
+
var showCustomUrlField: Bool {
- selectedNodeName.hasPrefix("Custom")
+ if selectedNodeName == customElectrum || selectedNodeName == customEsplora {
+ return true
+ }
+ if case .custom = selectedNodeSelection {
+ return true
+ }
+ return false
}
func cancelCheckUrlTask() {
- if let checkUrlTask {
- checkUrlTask.cancel()
- }
+ checkUrlTask?.cancel()
}
private func showLoadingPopup() {
@@ -59,7 +77,8 @@ struct NodeSelectionView: View {
7
case .success:
2
- default: 0
+ default:
+ 0
}
try? await Task.sleep(for: .seconds(1))
@@ -72,14 +91,21 @@ struct NodeSelectionView: View {
@ViewBuilder
var CustomFields: some View {
if showCustomUrlField {
- Section(selectedNodeName) {
+ Section("Custom node") {
HStack {
Text("URL")
.frame(width: 60, alignment: .leading)
- TextField("Enter URL", text: $customUrl)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
+ TextField("Enter URL", text: Binding(
+ get: { customUrl },
+ set: { value in
+ suppressCustomDraftActions = false
+ customUrl = value
+ }
+ ))
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
}
.font(.subheadline)
@@ -87,24 +113,93 @@ struct NodeSelectionView: View {
Text("Name")
.frame(width: 60, alignment: .leading)
- TextField("Node Name (optional)", text: $customNodeName)
- .textInputAutocapitalization(.never)
+ TextField("Node Name (optional)", text: Binding(
+ get: { customNodeName },
+ set: { value in
+ suppressCustomDraftActions = false
+ customNodeName = value
+ }
+ ))
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
}
.font(.subheadline)
- Button("Save Custom Node", action: checkAndSaveNode)
- .disabled(customUrl.isEmpty)
+ if suppressCustomDraftActions {
+ Text("Node already saved through Tor validation. Edit URL or name to save changes.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ Button("Save Custom Node", action: checkAndSaveCustomNode)
+ .disabled(customUrl.isEmpty)
+ }
}
}
}
- func checkAndSaveNode() {
- var node: Node? = nil
+ private func customTypeName() -> String {
+ if selectedNodeName == customElectrum || selectedNodeName == customEsplora {
+ return selectedNodeName
+ }
+
+ if case let .custom(node) = selectedNodeSelection {
+ return node.apiType == .electrum ? customElectrum : customEsplora
+ }
+
+ return selectedNodeName
+ }
+
+ private func setCustomSelection(for node: Node) {
+ selectedNodeSelection = .custom(node)
+ selectedNodeName = node.apiType == .electrum ? customElectrum : customEsplora
+ }
+
+ private func clearPendingNodeDraft() {
+ app.clearPendingNodeTorDraft()
+ }
+
+ private func restorePendingNodeIfNeeded() {
+ guard app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty else { return }
+
+ customUrl = app.pendingNodeUrl
+ customNodeName = app.pendingNodeName
+ selectedNodeName = app.pendingNodeTypeName.isEmpty ? customElectrum : app.pendingNodeTypeName
+
+ guard db.globalConfig().useTor(), app.pendingNodeTorValidated else { return }
+
+ let task = Task {
+ showLoadingPopup()
+ do {
+ let node = try nodeSelector.parseCustomNode(
+ url: app.pendingNodeUrl,
+ name: selectedNodeName,
+ enteredName: app.pendingNodeName
+ )
+ try await nodeSelector.checkAndSaveNode(node: node)
+ setCustomSelection(for: node)
+ clearPendingNodeDraft()
+ suppressCustomDraftActions = true
+ customUrl = ""
+ customNodeName = ""
+ completeLoading(.success("Connected to node successfully"))
+ app.popRoute()
+ } catch {
+ completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)"))
+ }
+ }
+ checkUrlTask = task
+ }
+ private func parseCustomNodeOrShowError() -> Node? {
do {
- node = try nodeSelector.parseCustomNode(url: customUrl, name: selectedNodeName, enteredName: customNodeName)
- customUrl = node?.url ?? customUrl
- customNodeName = node?.name ?? customNodeName
+ let node = try nodeSelector.parseCustomNode(
+ url: customUrl,
+ name: customTypeName(),
+ enteredName: customNodeName
+ )
+ customUrl = node.url
+ customNodeName = node.name
+ return node
} catch {
showParseUrlAlert = true
switch error {
@@ -113,118 +208,158 @@ struct NodeSelectionView: View {
default:
parseUrlMessage = "Unknown error \(error.localizedDescription)"
}
+ return nil
}
+ }
+
+ func checkAndSaveCustomNode() {
+ guard !customUrl.isEmpty else { return }
+ guard let node = parseCustomNodeOrShowError() else { return }
+
+ if isOnionNodeUrl(node.url) {
+ if db.globalConfig().useTor() {
+ let task = Task {
+ showLoadingPopup()
+ do {
+ try await nodeSelector.checkAndSaveNode(node: node)
+ setCustomSelection(for: node)
+ clearPendingNodeDraft()
+ suppressCustomDraftActions = false
+ completeLoading(.success("Connected to node successfully"))
+ } catch {
+ completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)"))
+ }
+ }
+ checkUrlTask = task
+ return
+ }
- if let node {
- Task {
- showLoadingPopup()
- let result = await Result { try await nodeSelector.checkAndSaveNode(node: node) }
+ do {
+ try db.globalFlag().set(key: .torSettingsDiscovered, value: true)
+ try db.globalConfig().setUseTor(useTor: true)
+ } catch {
+ showParseUrlAlert = true
+ parseUrlMessage = "Failed to enable Tor for this onion node: \(error.localizedDescription)"
+ return
+ }
- switch result {
- case .success: completeLoading(.success("Connected to node successfully"))
- case let .failure(error):
- let errorMessage = "Failed to connect to node\n \(error.localizedDescription)"
- let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n")
+ setCustomSelection(for: node)
+ app.pendingNodeUrl = node.url
+ app.pendingNodeName = node.name
+ app.pendingNodeTypeName = node.apiType == .electrum ? customElectrum : customEsplora
+ app.pendingNodeAwaitingTorSetup = true
+ app.pendingNodeTorValidated = false
+ app.pushRoute(.settings(.network))
+ return
+ }
- completeLoading(.failure(formattedMessage))
- }
+ let task = Task {
+ showLoadingPopup()
+ do {
+ try await nodeSelector.checkAndSaveNode(node: node)
+ setCustomSelection(for: node)
+ completeLoading(.success("Connected to node successfully"))
+ } catch {
+ completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)"))
+ }
+ }
+ checkUrlTask = task
+ }
+
+ private func selectPresetNode(_ nodeSelection: NodeSelection) {
+ let node = nodeSelection.toNode()
+ selectedNodeSelection = nodeSelection
+ selectedNodeName = node.name
+ suppressCustomDraftActions = false
+
+ if case .custom = nodeSelection {
+ customUrl = node.url
+ customNodeName = node.name
+ selectedNodeName = node.apiType == .electrum ? customElectrum : customEsplora
+ return
+ }
+
+ customUrl = ""
+ customNodeName = ""
+ showLoadingPopup()
+ let task = Task {
+ do {
+ let selected = try nodeSelector.selectPresetNode(name: node.name)
+ try await nodeSelector.checkSelectedNode(node: selected)
+ selectedNodeSelection = .preset(selected)
+ selectedNodeName = selected.name
+ completeLoading(.success("Successfully connected to \(selected.url)"))
+ } catch {
+ completeLoading(.failure("Failed to connect to \(node.url), reason: \(error.localizedDescription)"))
}
}
+ checkUrlTask = task
}
var body: some View {
Form {
Section {
- ForEach(nodeList, id: \.name) { (node: NodeSelection) in
- HStack {
- Text(node.name)
- .font(.subheadline)
-
- Spacer()
-
- if selectedNodeName == node.name {
- Image(systemName: "checkmark")
- .foregroundStyle(.blue)
- .font(.footnote)
- .fontWeight(.semibold)
- }
- }
- .contentShape(Rectangle())
- .onTapGesture { selectedNodeName = node.name }
+ ForEach(nodeList, id: \.name) { nodeSelection in
+ NodeRow(
+ nodeName: nodeSelection.name,
+ isSelected: selectedNodeSelection == nodeSelection || selectedNodeName == nodeSelection.name,
+ onTap: { selectPresetNode(nodeSelection) }
+ )
}
- HStack {
- Text("Custom Electrum")
- .font(.subheadline)
-
- Spacer()
-
- if selectedNodeName == "Custom Electrum" {
- Image(systemName: "checkmark")
- .foregroundStyle(.blue)
- .font(.footnote)
- .fontWeight(.semibold)
+ NodeRow(
+ nodeName: customElectrum,
+ isSelected: selectedNodeName == customElectrum,
+ onTap: {
+ suppressCustomDraftActions = false
+ selectedNodeName = customElectrum
+ if case let .custom(node) = selectedNodeSelection, node.apiType == .electrum {
+ customUrl = node.url
+ customNodeName = node.name
+ } else {
+ customUrl = ""
+ customNodeName = ""
+ }
}
- }
- .contentShape(Rectangle())
- .onTapGesture { selectedNodeName = "Custom Electrum" }
-
- HStack {
- Text("Custom Esplora")
- .font(.subheadline)
-
- Spacer()
-
- if selectedNodeName == "Custom Esplora" {
- Image(systemName: "checkmark")
- .foregroundStyle(.blue)
- .font(.footnote)
- .fontWeight(.semibold)
+ )
+
+ NodeRow(
+ nodeName: customEsplora,
+ isSelected: selectedNodeName == customEsplora,
+ onTap: {
+ suppressCustomDraftActions = false
+ selectedNodeName = customEsplora
+ if case let .custom(node) = selectedNodeSelection, node.apiType == .esplora {
+ customUrl = node.url
+ customNodeName = node.name
+ } else {
+ customUrl = ""
+ customNodeName = ""
+ }
}
- }
- .contentShape(Rectangle())
- .onTapGesture { selectedNodeName = "Custom Esplora" }
+ )
}
CustomFields
}
.scrollContentBackground(.hidden)
- .onChange(of: selectedNodeName) { _, newSelectedNodeName in
- if selectedNodeName.hasPrefix("Custom") {
- if case let .custom(savedSelectedNode) = nodeSelector.selectedNode() {
- if savedSelectedNode.apiType == .electrum, selectedNodeName.contains("Electrum") {
- customUrl = savedSelectedNode.url
- customNodeName = savedSelectedNode.name
- }
-
- if savedSelectedNode.apiType == .esplora, selectedNodeName.contains("Esplora") {
- customUrl = savedSelectedNode.url
- customNodeName = savedSelectedNode.name
- }
+ .onAppear(perform: restorePendingNodeIfNeeded)
+ .onChange(of: selectedNodeName) { _, _ in
+ guard showCustomUrlField, customUrl.isEmpty, !suppressCustomDraftActions else { return }
+ if case let .custom(savedSelectedNode) = nodeSelector.selectedNode() {
+ if savedSelectedNode.apiType == .electrum, selectedNodeName == customElectrum {
+ customUrl = savedSelectedNode.url
+ customNodeName = savedSelectedNode.name
}
- return
- }
-
- guard let node = try? nodeSelector.selectPresetNode(name: newSelectedNodeName) else { return }
-
- showLoadingPopup()
- let task = Task {
- do {
- try await nodeSelector.checkSelectedNode(node: node)
- completeLoading(.success("Succesfully connected to \(node.url)"))
- } catch {
- completeLoading(.failure("Failed to connect to \(node.url), reason: \(error.localizedDescription)"))
+ if savedSelectedNode.apiType == .esplora, selectedNodeName == customEsplora {
+ customUrl = savedSelectedNode.url
+ customNodeName = savedSelectedNode.name
}
}
- checkUrlTask = task
- }
- .onChange(of: nodeList) { _, _ in
- selectedNodeName = nodeSelector.selectedNode().name
}
.onDisappear {
- // custom esplora or electrum is selected
- if showCustomUrlField { checkAndSaveNode() }
+ cancelCheckUrlTask()
}
.alert(isPresented: $showParseUrlAlert) {
Alert(
@@ -240,6 +375,30 @@ struct NodeSelectionView: View {
}
}
+private struct NodeRow: View {
+ let nodeName: String
+ let isSelected: Bool
+ let onTap: () -> Void
+
+ var body: some View {
+ HStack {
+ Text(nodeName)
+ .font(.subheadline)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ .font(.footnote)
+ .fontWeight(.semibold)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onTap)
+ }
+}
+
#Preview {
SettingsContainer(route: .node)
.environment(AppManager.shared)
diff --git a/ios/Cove/Info.plist b/ios/Cove/Info.plist
index fe08e0b0c..4e7ef14df 100644
--- a/ios/Cove/Info.plist
+++ b/ios/Cove/Info.plist
@@ -2,6 +2,10 @@
+ LSApplicationQueriesSchemes
+
+ orbot
+
UILaunchScreen
UIColorName
diff --git a/ios/Cove/TorSupport.swift b/ios/Cove/TorSupport.swift
new file mode 100644
index 000000000..b1e74132d
--- /dev/null
+++ b/ios/Cove/TorSupport.swift
@@ -0,0 +1,511 @@
+import CryptoKit
+import Foundation
+import Network
+import SwiftUI
+
+enum TorStatus {
+ case disabled
+ case bootstrapping
+ case ready
+ case error
+}
+
+enum OrbotStatus {
+ case checking
+ case detected
+ case notDetected
+}
+
+struct TorUiState: Equatable {
+ var enabled = false
+ var mode: TorMode = .builtIn
+ var status: TorStatus = .disabled
+ var progressPercent = 0
+ var currentStep = "Disabled"
+ var latestLogLine = "Tor is off"
+ var logLines: [String] = ["Tor is off"]
+ var externalHost = "127.0.0.1"
+ var externalPort = "9050"
+ var externalValidationError: String?
+ var orbotStatus: OrbotStatus = .checking
+ var orbotVersion: String?
+}
+
+struct TorBootstrapSnapshot: Equatable {
+ var percent: Int
+ var step: String
+ var isReady: Bool
+ var hasError: Bool
+ var lastLine: String
+}
+
+struct TorApiSnapshot: Equatable {
+ var isTor: Bool
+ var ip: String?
+ var raw: String
+}
+
+enum TorStatusDot: Equatable {
+ case green
+ case yellow
+ case red
+ case gray
+
+ var color: Color {
+ switch self {
+ case .green: Color(red: 0.20, green: 0.78, blue: 0.35)
+ case .yellow: Color(red: 1.00, green: 0.76, blue: 0.03)
+ case .red: Color(red: 1.00, green: 0.23, blue: 0.19)
+ case .gray: Color(red: 0.62, green: 0.62, blue: 0.62)
+ }
+ }
+}
+
+struct TorQuickStatus: Equatable {
+ var enabled = false
+ var overall: TorStatusDot = .gray
+ var torConnection: TorStatusDot = .gray
+ var nodeReachable: TorStatusDot = .gray
+ var nodeSynced: TorStatusDot = .gray
+ var torMessage = "Tor disabled"
+ var nodeMessage = "Node status unavailable"
+ var syncMessage = "Sync status unavailable"
+ var logs: [String] = []
+}
+
+enum TorSupportError: LocalizedError {
+ case invalidEndpoint
+ case timeout
+ case invalidResponse
+ case notHTTP
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidEndpoint:
+ "Invalid SOCKS endpoint"
+ case .timeout:
+ "Connection timed out"
+ case .invalidResponse:
+ "Invalid Tor API response"
+ case .notHTTP:
+ "Tor API did not return an HTTP response"
+ }
+ }
+}
+
+extension TorMode {
+ static let uiCases: [TorMode] = [.builtIn, .orbot, .external]
+
+ var title: String {
+ switch self {
+ case .builtIn: "Built-in"
+ case .orbot: "Orbot (External App)"
+ case .external: "Custom SOCKS5 Proxy"
+ }
+ }
+
+ var shortTitle: String {
+ switch self {
+ case .builtIn: "Built-in"
+ case .orbot: "Orbot"
+ case .external: "Custom SOCKS5 Proxy"
+ }
+ }
+
+ var persistedValue: String {
+ switch self {
+ case .builtIn: "BUILT_IN"
+ case .orbot: "ORBOT"
+ case .external: "EXTERNAL"
+ }
+ }
+
+ static func fromConfig(_ value: String?) -> TorMode {
+ switch value {
+ case "Orbot", "ORBOT":
+ .orbot
+ case "External", "EXTERNAL":
+ .external
+ default:
+ .builtIn
+ }
+ }
+}
+
+func torTimestamp() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm:ss"
+ return formatter.string(from: Date())
+}
+
+func redactedEndpointForLog(_ endpoint: String) -> String {
+ "id=\(shortLogId(endpoint)), onion=\(isOnionNodeUrl(endpoint))"
+}
+
+func redactedProxyForLog(host: String, port: Int) -> String {
+ "id=\(shortLogId("\(host):\(port)")), port=\(port)"
+}
+
+func redactedNodeForLog(_ node: Node) -> String {
+ "apiType=\(node.apiType), \(redactedEndpointForLog(node.url))"
+}
+
+private func shortLogId(_ value: String) -> String {
+ let digest = SHA256.hash(data: Data(value.utf8))
+ return digest.prefix(4).map { String(format: "%02x", $0) }.joined()
+}
+
+func validateTorExternalConfig(host: String, port: String) -> String? {
+ if host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return "Host is required"
+ }
+
+ guard let parsed = Int(port) else {
+ return "Port must be a number"
+ }
+
+ guard (1 ... 65535).contains(parsed) else {
+ return "Port must be between 1 and 65535"
+ }
+
+ return nil
+}
+
+func parseEndpointHostPort(_ endpoint: String) -> (String, Int)? {
+ let parts = endpoint.split(separator: ":", maxSplits: 1).map(String.init)
+ guard parts.count == 2, let port = Int(parts[1]), (1 ... 65535).contains(port) else {
+ return nil
+ }
+
+ let host = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !host.isEmpty else { return nil }
+ return (host, port)
+}
+
+func isOnionNodeUrl(_ url: String) -> Bool {
+ func host(from value: String) -> String? {
+ URLComponents(string: value)?.host
+ }
+
+ let rawHost = host(from: url) ?? host(from: "tcp://\(url)")
+ return rawHost?.lowercased().hasSuffix(".onion") == true
+}
+
+func switchToFirstClearnetPresetNode(_ nodeSelector: NodeSelector) async throws -> Node {
+ let fallbackNode = nodeSelector.nodeList().compactMap { selection -> Node? in
+ guard case let .preset(node) = selection else { return nil }
+ return isOnionNodeUrl(node.url) ? nil : node
+ }.first
+
+ guard let fallbackNode else {
+ throw TorSupportError.invalidEndpoint
+ }
+
+ try await nodeSelector.checkAndSaveNode(node: fallbackNode)
+ return fallbackNode
+}
+
+private func isRustTorLog(_ line: String) -> Bool {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("[INFO ")
+ || trimmed.hasPrefix("[WARN ")
+ || trimmed.hasPrefix("[ERROR ")
+ || trimmed.hasPrefix("[DEBUG ")
+}
+
+private func firstRegexInt(_ pattern: String, in string: String, group: Int = 1) -> Int? {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(string.startIndex..., in: string)
+ guard let match = regex.firstMatch(in: string, range: range),
+ match.numberOfRanges > group,
+ let valueRange = Range(match.range(at: group), in: string)
+ else {
+ return nil
+ }
+
+ return Int(string[valueRange])
+}
+
+private func firstRegexInts(_ pattern: String, in string: String) -> [Int]? {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(string.startIndex..., in: string)
+ guard let match = regex.firstMatch(in: string, range: range), match.numberOfRanges > 1 else {
+ return nil
+ }
+
+ return (1 ..< match.numberOfRanges).compactMap { index in
+ guard let valueRange = Range(match.range(at: index), in: string) else { return nil }
+ return Int(string[valueRange])
+ }
+}
+
+private func artiStatus(_ line: String) -> (percent: Int, message: String)? {
+ let pattern = #"arti_client::status]\s*(100|[0-9]{1,2})%:\s*(.+)$"#
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(line.startIndex..., in: line)
+ guard let match = regex.firstMatch(in: line, range: range),
+ match.numberOfRanges == 3,
+ let percentRange = Range(match.range(at: 1), in: line),
+ let messageRange = Range(match.range(at: 2), in: line),
+ let percent = Int(line[percentRange])
+ else {
+ return nil
+ }
+
+ return (min(max(percent, 0), 100), String(line[messageRange]))
+}
+
+func deriveBuiltInBootstrapSnapshot(_ logLines: [String]) -> TorBootstrapSnapshot {
+ guard !logLines.isEmpty else {
+ return TorBootstrapSnapshot(
+ percent: 0,
+ step: "Waiting for Tor runtime",
+ isReady: false,
+ hasError: false,
+ lastLine: "No Tor logs yet"
+ )
+ }
+
+ let rustLogs = logLines.filter(isRustTorLog)
+ guard !rustLogs.isEmpty else {
+ return TorBootstrapSnapshot(
+ percent: 0,
+ step: "Waiting for Tor runtime",
+ isReady: false,
+ hasError: false,
+ lastLine: logLines.last ?? "No Tor logs yet"
+ )
+ }
+
+ let restartMarkers = [
+ "built-in tor endpoint requested without cache; launching proxy",
+ "built-in tor launch initiated",
+ "starting built-in tor runtime thread",
+ ]
+ let restartIndex = rustLogs.indices.last { index in
+ let line = rustLogs[index].lowercased()
+ return restartMarkers.contains { line.contains($0) }
+ }
+ let scopedLogs = restartIndex.map { Array(rustLogs[$0...]) } ?? rustLogs
+
+ var percent = 0
+ var step = "Starting Tor"
+ var ready = false
+ var hasError = false
+ var initialMissingMicrodescriptors: Int?
+
+ for line in scopedLogs {
+ let lowered = line.lowercased()
+ if let status = artiStatus(line) {
+ percent = status.percent
+ step = "\(status.percent)%: \(status.message)"
+ if status.percent >= 100 {
+ ready = true
+ }
+ continue
+ }
+
+ if let found = firstRegexInt(#"\b(100|[0-9]{1,2})%\b"#, in: line), found > percent {
+ percent = found
+ }
+
+ if lowered.contains("built-in tor launch initiated")
+ || lowered.contains("starting built-in tor runtime thread")
+ {
+ percent = max(percent, 3)
+ step = "Launching runtime"
+ } else if lowered.contains("built-in tor runtime created")
+ || lowered.contains("launching arti socks proxy task")
+ {
+ percent = max(percent, 8)
+ step = "Starting SOCKS proxy"
+ } else if lowered.contains("listening on"),
+ lowered.contains("127.0.0.1:") || lowered.contains("[::1]:")
+ {
+ percent = max(percent, 15)
+ step = "SOCKS listener ready"
+ } else if lowered.contains("looking for a consensus") {
+ percent = max(percent, 22)
+ step = "Looking for consensus"
+ } else if lowered.contains("downloading certificates for consensus") {
+ percent = max(percent, 35)
+ step = "Downloading consensus certificates"
+ } else if lowered.contains("downloading microdescriptors") {
+ step = "Downloading microdescriptors"
+ let missingFraction = firstRegexInts(#"missing\s+(\d+)\s*/\s*(\d+)"#, in: lowered)
+ let missing = missingFraction?.first ?? firstRegexInt(#"missing\s+(\d+)"#, in: lowered)
+
+ if let missing {
+ let baseline: Int
+ if let total = missingFraction?.dropFirst().first, total > 0 {
+ if initialMissingMicrodescriptors == nil || total > initialMissingMicrodescriptors! {
+ initialMissingMicrodescriptors = total
+ }
+ baseline = max(initialMissingMicrodescriptors ?? total, 1)
+ } else {
+ if initialMissingMicrodescriptors == nil || missing > initialMissingMicrodescriptors! {
+ initialMissingMicrodescriptors = missing
+ }
+ baseline = max(initialMissingMicrodescriptors ?? missing, 1)
+ }
+
+ let completedRatio = Double(max(baseline - missing, 0)) / Double(baseline)
+ let dynamicPercent = min(max(45 + Int(completedRatio * 46.0), 45), 91)
+ percent = max(percent, dynamicPercent)
+ } else {
+ percent = max(percent, 45)
+ }
+ } else if lowered.contains("marked consensus usable") {
+ percent = max(percent, 93)
+ step = "Building circuits"
+ } else if lowered.contains("enough information to build circuits") {
+ percent = max(percent, 96)
+ step = "Building circuits"
+ } else if lowered.contains("directory is complete") {
+ percent = 100
+ step = "Tor ready"
+ ready = true
+ } else if lowered.contains("sufficiently bootstrapped; proxy now functional") {
+ percent = max(percent, 97)
+ step = "Circuits available, finishing directory"
+ }
+
+ let benignReloadXdgWarning = lowered.contains("arti::reload_cfg")
+ && (lowered.contains("xdg project directories")
+ || lowered.contains("unable to determine home directory")
+ || lowered.contains("cache_dir"))
+ let fatalSignals = [
+ "built-in tor bootstrap failed",
+ "built-in tor proxy exited",
+ "failed to initialize built-in tor runtime",
+ "failed to create built-in tor runtime",
+ "built-in tor socks listener not ready",
+ "can't find path for port_info_file",
+ "operation not supported because arti feature disabled",
+ ]
+
+ if !benignReloadXdgWarning, fatalSignals.contains(where: { lowered.contains($0) }) {
+ hasError = true
+ }
+ }
+
+ if ready {
+ hasError = false
+ }
+
+ if !ready, percent >= 100 {
+ percent = 99
+ }
+
+ return TorBootstrapSnapshot(
+ percent: min(max(percent, 0), 100),
+ step: step,
+ isReady: ready,
+ hasError: hasError,
+ lastLine: scopedLogs.last ?? rustLogs.last ?? logLines.last ?? "No Tor logs yet"
+ )
+}
+
+func testSocksEndpoint(host: String, port: Int, timeout: TimeInterval = 3) async -> Result {
+ guard (1 ... 65535).contains(port),
+ let endpointPort = NWEndpoint.Port(rawValue: UInt16(port))
+ else {
+ return .failure(TorSupportError.invalidEndpoint)
+ }
+
+ return await withCheckedContinuation { continuation in
+ let queue = DispatchQueue(label: "org.bitcoinppl.cove.tor.socks-test")
+ let connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp)
+ var finished = false
+
+ func finish(_ result: Result) {
+ guard !finished else { return }
+ finished = true
+ connection.cancel()
+ continuation.resume(returning: result)
+ }
+
+ connection.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ finish(.success(()))
+ case let .failed(error):
+ finish(.failure(error))
+ case let .waiting(error):
+ if case .posix(.ECONNREFUSED) = error {
+ finish(.failure(error))
+ }
+ default:
+ break
+ }
+ }
+
+ queue.asyncAfter(deadline: .now() + timeout) {
+ finish(.failure(TorSupportError.timeout))
+ }
+ connection.start(queue: queue)
+ }
+}
+
+func testTorApiThroughSocks(host: String, port: Int, timeout: TimeInterval = 15) async -> Result {
+ guard (1 ... 65535).contains(port) else {
+ return .failure(TorSupportError.invalidEndpoint)
+ }
+
+ let httpsResult = await testTorApiThroughUrlSessionSocks(host: host, port: port, timeout: timeout)
+ if httpsResult.isSuccess() {
+ return httpsResult
+ }
+
+ return httpsResult
+}
+
+private func testTorApiThroughUrlSessionSocks(
+ host: String,
+ port: Int,
+ timeout: TimeInterval
+) async -> Result {
+ do {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = timeout
+ configuration.timeoutIntervalForResource = timeout
+ configuration.connectionProxyDictionary = [
+ "SOCKSEnable": NSNumber(value: true),
+ "SOCKSProxy": host,
+ "SOCKSPort": NSNumber(value: port),
+ ]
+
+ let session = URLSession(configuration: configuration)
+ defer { session.invalidateAndCancel() }
+
+ guard let url = URL(string: "https://check.torproject.org/api/ip") else {
+ return .failure(TorSupportError.invalidEndpoint)
+ }
+
+ let (data, response) = try await session.data(from: url)
+ guard let http = response as? HTTPURLResponse else {
+ return .failure(TorSupportError.notHTTP)
+ }
+ guard (200 ..< 300).contains(http.statusCode) else {
+ return .failure(TorSupportError.invalidResponse)
+ }
+
+ return parseTorApiJson(data)
+ } catch {
+ return .failure(error)
+ }
+}
+
+private func parseTorApiJson(_ data: Data) -> Result {
+ let raw = String(data: data, encoding: .utf8) ?? ""
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let isTor = (json?["IsTor"] as? Bool)
+ ?? (json?["is_tor"] as? Bool)
+ ?? (raw.range(
+ of: #""istor"\s*:\s*true"#,
+ options: [.caseInsensitive, .regularExpression]
+ ) != nil)
+ let ip = json?["IP"] as? String
+
+ return .success(TorApiSnapshot(isTor: isTor, ip: ip, raw: raw))
+}
diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift
index 2a251db6b..e47392185 100644
--- a/ios/Cove/WalletManager.swift
+++ b/ios/Cove/WalletManager.swift
@@ -171,6 +171,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana
self.loadState = .scanning(txns)
case let .availableTransactions(txns):
+ errorAlert = nil
switch self.loadState {
case .loading:
self.loadState = .scanning(txns)
@@ -185,6 +186,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana
}
case let .updatedTransactions(txns):
+ errorAlert = nil
switch self.loadState {
case .scanning, .loading:
self.loadState = .scanning(txns)
@@ -193,9 +195,11 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana
}
case let .scanComplete(txns):
+ errorAlert = nil
self.loadState = .loaded(txns)
case let .walletBalanceChanged(balance):
+ errorAlert = nil
withAnimation { self.balance = balance }
case .unsignedTransactionsChanged:
diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift
index a5a86b8f9..ad148398e 100644
--- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift
+++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift
@@ -4001,6 +4001,14 @@ public protocol GlobalConfigTableProtocol: AnyObject, Sendable {
func setSelectedNode(node: Node) throws
+ func setTorExternalPort(port: UInt16) throws
+
+ func setUseTor(useTor: Bool) throws
+
+ func torExternalPort() -> UInt16
+
+ func useTor() -> Bool
+
func walletMode() -> WalletMode
}
@@ -4209,6 +4217,38 @@ open func setSelectedNode(node: Node)throws {try rustCallWithError(FfiConverte
}
}
+open func setTorExternalPort(port: UInt16)throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) {
+ uniffi_cove_fn_method_globalconfigtable_set_tor_external_port(
+ self.uniffiCloneHandle(),
+ FfiConverterUInt16.lower(port),$0
+ )
+}
+}
+
+open func setUseTor(useTor: Bool)throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) {
+ uniffi_cove_fn_method_globalconfigtable_set_use_tor(
+ self.uniffiCloneHandle(),
+ FfiConverterBool.lower(useTor),$0
+ )
+}
+}
+
+open func torExternalPort() -> UInt16 {
+ return try! FfiConverterUInt16.lift(try! rustCall() {
+ uniffi_cove_fn_method_globalconfigtable_tor_external_port(
+ self.uniffiCloneHandle(),$0
+ )
+})
+}
+
+open func useTor() -> Bool {
+ return try! FfiConverterBool.lift(try! rustCall() {
+ uniffi_cove_fn_method_globalconfigtable_use_tor(
+ self.uniffiCloneHandle(),$0
+ )
+})
+}
+
open func walletMode() -> WalletMode {
return try! FfiConverterTypeWalletMode_lift(try! rustCall() {
uniffi_cove_fn_method_globalconfigtable_wallet_mode(
@@ -13024,6 +13064,76 @@ public func FfiConverterTypeBackupWalletSummary_lower(_ value: BackupWalletSumma
}
+public struct BuiltInTorBootstrapStatus: Equatable, Hashable {
+ public var percent: UInt32
+ public var ready: Bool
+ public var blocked: String?
+ public var message: String
+ public var launched: Bool
+ public var lastError: String?
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(percent: UInt32, ready: Bool, blocked: String?, message: String, launched: Bool, lastError: String?) {
+ self.percent = percent
+ self.ready = ready
+ self.blocked = blocked
+ self.message = message
+ self.launched = launched
+ self.lastError = lastError
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension BuiltInTorBootstrapStatus: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeBuiltInTorBootstrapStatus: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BuiltInTorBootstrapStatus {
+ return
+ try BuiltInTorBootstrapStatus(
+ percent: FfiConverterUInt32.read(from: &buf),
+ ready: FfiConverterBool.read(from: &buf),
+ blocked: FfiConverterOptionString.read(from: &buf),
+ message: FfiConverterString.read(from: &buf),
+ launched: FfiConverterBool.read(from: &buf),
+ lastError: FfiConverterOptionString.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: BuiltInTorBootstrapStatus, into buf: inout [UInt8]) {
+ FfiConverterUInt32.write(value.percent, into: &buf)
+ FfiConverterBool.write(value.ready, into: &buf)
+ FfiConverterOptionString.write(value.blocked, into: &buf)
+ FfiConverterString.write(value.message, into: &buf)
+ FfiConverterBool.write(value.launched, into: &buf)
+ FfiConverterOptionString.write(value.lastError, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeBuiltInTorBootstrapStatus_lift(_ buf: RustBuffer) throws -> BuiltInTorBootstrapStatus {
+ return try FfiConverterTypeBuiltInTorBootstrapStatus.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeBuiltInTorBootstrapStatus_lower(_ value: BuiltInTorBootstrapStatus) -> RustBuffer {
+ return FfiConverterTypeBuiltInTorBootstrapStatus.lower(value)
+}
+
+
public struct CloudBackupDetail: Equatable, Hashable {
public var lastSync: UInt64?
public var upToDate: [CloudBackupWalletItem]
@@ -21614,6 +21724,10 @@ public enum GlobalConfigKey: Equatable, Hashable {
case selectedFiatCurrency
case selectedNode(Network
)
+ case useTor
+ case torMode
+ case torExternalHost
+ case torExternalPort
case colorScheme
case authType
case hashedPinCode
@@ -21654,25 +21768,33 @@ public struct FfiConverterTypeGlobalConfigKey: FfiConverterRustBuffer {
case 4: return .selectedNode(try FfiConverterTypeNetwork.read(from: &buf)
)
- case 5: return .colorScheme
+ case 5: return .useTor
+
+ case 6: return .torMode
+
+ case 7: return .torExternalHost
- case 6: return .authType
+ case 8: return .torExternalPort
- case 7: return .hashedPinCode
+ case 9: return .colorScheme
- case 8: return .wipeDataPin
+ case 10: return .authType
- case 9: return .decoyPin
+ case 11: return .hashedPinCode
- case 10: return .inDecoyMode
+ case 12: return .wipeDataPin
- case 11: return .mainSelectedWalletId
+ case 13: return .decoyPin
- case 12: return .decoySelectedWalletId
+ case 14: return .inDecoyMode
- case 13: return .lockedAt
+ case 15: return .mainSelectedWalletId
- case 14: return .onboardingProgress
+ case 16: return .decoySelectedWalletId
+
+ case 17: return .lockedAt
+
+ case 18: return .onboardingProgress
default: throw UniffiInternalError.unexpectedEnumCase
}
@@ -21699,45 +21821,61 @@ public struct FfiConverterTypeGlobalConfigKey: FfiConverterRustBuffer {
FfiConverterTypeNetwork.write(v1, into: &buf)
- case .colorScheme:
+ case .useTor:
writeInt(&buf, Int32(5))
- case .authType:
+ case .torMode:
writeInt(&buf, Int32(6))
- case .hashedPinCode:
+ case .torExternalHost:
writeInt(&buf, Int32(7))
- case .wipeDataPin:
+ case .torExternalPort:
writeInt(&buf, Int32(8))
- case .decoyPin:
+ case .colorScheme:
writeInt(&buf, Int32(9))
- case .inDecoyMode:
+ case .authType:
writeInt(&buf, Int32(10))
- case .mainSelectedWalletId:
+ case .hashedPinCode:
writeInt(&buf, Int32(11))
- case .decoySelectedWalletId:
+ case .wipeDataPin:
writeInt(&buf, Int32(12))
- case .lockedAt:
+ case .decoyPin:
writeInt(&buf, Int32(13))
- case .onboardingProgress:
+ case .inDecoyMode:
writeInt(&buf, Int32(14))
+
+ case .mainSelectedWalletId:
+ writeInt(&buf, Int32(15))
+
+
+ case .decoySelectedWalletId:
+ writeInt(&buf, Int32(16))
+
+
+ case .lockedAt:
+ writeInt(&buf, Int32(17))
+
+
+ case .onboardingProgress:
+ writeInt(&buf, Int32(18))
+
}
}
}
@@ -21769,6 +21907,8 @@ enum GlobalConfigTableError: Swift.Error, Equatable, Hashable, Foundation.Locali
case Read(String
)
case PinCodeMustBeHashed
+ case BuiltInTorStop(String
+ )
@@ -21815,6 +21955,9 @@ public struct FfiConverterTypeGlobalConfigTableError: FfiConverterRustBuffer {
try FfiConverterString.read(from: &buf)
)
case 3: return .PinCodeMustBeHashed
+ case 4: return .BuiltInTorStop(
+ try FfiConverterString.read(from: &buf)
+ )
default: throw UniffiInternalError.unexpectedEnumCase
}
@@ -21840,6 +21983,11 @@ public struct FfiConverterTypeGlobalConfigTableError: FfiConverterRustBuffer {
case .PinCodeMustBeHashed:
writeInt(&buf, Int32(3))
+
+ case let .BuiltInTorStop(v1):
+ writeInt(&buf, Int32(4))
+ FfiConverterString.write(v1, into: &buf)
+
}
}
}
@@ -21867,6 +22015,7 @@ public enum GlobalFlagKey: Equatable, Hashable {
case acceptedTerms
case betaFeaturesEnabled
case betaImportExportEnabled
+ case torSettingsDiscovered
@@ -21896,6 +22045,8 @@ public struct FfiConverterTypeGlobalFlagKey: FfiConverterRustBuffer {
case 4: return .betaImportExportEnabled
+ case 5: return .torSettingsDiscovered
+
default: throw UniffiInternalError.unexpectedEnumCase
}
}
@@ -21919,6 +22070,10 @@ public struct FfiConverterTypeGlobalFlagKey: FfiConverterRustBuffer {
case .betaImportExportEnabled:
writeInt(&buf, Int32(4))
+
+ case .torSettingsDiscovered:
+ writeInt(&buf, Int32(5))
+
}
}
}
@@ -29240,6 +29395,154 @@ public func FfiConverterTypeTapSignerRoute_lower(_ value: TapSignerRoute) -> Rus
+public
+enum TorBootstrapError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
+
+
+
+ case BuiltInTor(String
+ )
+
+
+
+
+
+
+ public var errorDescription: String? {
+ String(reflecting: self)
+ }
+
+}
+
+#if compiler(>=6)
+extension TorBootstrapError: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeTorBootstrapError: FfiConverterRustBuffer {
+ typealias SwiftType = TorBootstrapError
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TorBootstrapError {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+
+
+
+ case 1: return .BuiltInTor(
+ try FfiConverterString.read(from: &buf)
+ )
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: TorBootstrapError, into buf: inout [UInt8]) {
+ switch value {
+
+
+
+
+
+ case let .BuiltInTor(v1):
+ writeInt(&buf, Int32(1))
+ FfiConverterString.write(v1, into: &buf)
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeTorBootstrapError_lift(_ buf: RustBuffer) throws -> TorBootstrapError {
+ return try FfiConverterTypeTorBootstrapError.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeTorBootstrapError_lower(_ value: TorBootstrapError) -> RustBuffer {
+ return FfiConverterTypeTorBootstrapError.lower(value)
+}
+
+
+
+public enum TorMode: Equatable, Hashable {
+
+ case builtIn
+ case orbot
+ case external
+
+
+
+
+
+}
+
+#if compiler(>=6)
+extension TorMode: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeTorMode: FfiConverterRustBuffer {
+ typealias SwiftType = TorMode
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TorMode {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+ case 1: return .builtIn
+
+ case 2: return .orbot
+
+ case 3: return .external
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: TorMode, into buf: inout [UInt8]) {
+ switch value {
+
+
+ case .builtIn:
+ writeInt(&buf, Int32(1))
+
+
+ case .orbot:
+ writeInt(&buf, Int32(2))
+
+
+ case .external:
+ writeInt(&buf, Int32(3))
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeTorMode_lift(_ buf: RustBuffer) throws -> TorMode {
+ return try FfiConverterTypeTorMode.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeTorMode_lower(_ value: TorMode) -> RustBuffer {
+ return FfiConverterTypeTorMode.lower(value)
+}
+
+
+
public enum Transaction {
@@ -35829,6 +36132,31 @@ private func uniffiForeignFutureDroppedCallback(handle: UInt64) {
public func uniffiForeignFutureHandleCountCove() -> Int {
UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.count
}
+public func builtInTorBootstrapStatus() -> BuiltInTorBootstrapStatus {
+ return try! FfiConverterTypeBuiltInTorBootstrapStatus_lift(try! rustCall() {
+ uniffi_cove_fn_func_built_in_tor_bootstrap_status($0
+ )
+})
+}
+public func clearTorConnectionLogs() {try! rustCall() {
+ uniffi_cove_fn_func_clear_tor_connection_logs($0
+ )
+}
+}
+public func ensureBuiltInTorBootstrap()async throws -> String {
+ return
+ try await uniffiRustCallAsync(
+ rustFutureFunc: {
+ uniffi_cove_fn_func_ensure_built_in_tor_bootstrap(
+ )
+ },
+ pollFunc: ffi_cove_rust_future_poll_rust_buffer,
+ completeFunc: ffi_cove_rust_future_complete_rust_buffer,
+ freeFunc: ffi_cove_rust_future_free_rust_buffer,
+ liftFunc: FfiConverterString.lift,
+ errorHandler: FfiConverterTypeTorBootstrapError_lift
+ )
+}
/**
* set root data directory before any database access
* required for Android to specify app-specific storage path
@@ -35839,6 +36167,12 @@ public func setRootDataDir(path: String)throws {try rustCallWithError(FfiConve
)
}
}
+public func torConnectionLogs() -> [String] {
+ return try! FfiConverterSequenceString.lift(try! rustCall() {
+ uniffi_cove_fn_func_tor_connection_logs($0
+ )
+})
+}
/**
* Initialize the global App instance (Updater, router, state)
* Must be called after storage bootstrap completes
@@ -36244,9 +36578,21 @@ private let initializationResult: InitializationResult = {
if bindings_contract_version != scaffolding_contract_version {
return InitializationResult.contractVersionMismatch
}
+ if (uniffi_cove_checksum_func_built_in_tor_bootstrap_status() != 39940) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap() != 8592) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_cove_checksum_func_set_root_data_dir() != 56109) {
return InitializationResult.apiChecksumMismatch
}
+ if (uniffi_cove_checksum_func_tor_connection_logs() != 59010) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_cove_checksum_func_initialize_app() != 18498) {
return InitializationResult.apiChecksumMismatch
}
@@ -36610,6 +36956,18 @@ private let initializationResult: InitializationResult = {
if (uniffi_cove_checksum_method_globalconfigtable_set_selected_node() != 44882) {
return InitializationResult.apiChecksumMismatch
}
+ if (uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port() != 2190) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_cove_checksum_method_globalconfigtable_set_use_tor() != 3178) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_cove_checksum_method_globalconfigtable_tor_external_port() != 48622) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_cove_checksum_method_globalconfigtable_use_tor() != 22520) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_cove_checksum_method_globalconfigtable_wallet_mode() != 27720) {
return InitializationResult.apiChecksumMismatch
}
diff --git a/justfile b/justfile
index 76571da4a..14dfc09dc 100644
--- a/justfile
+++ b/justfile
@@ -12,6 +12,34 @@ list:
xtask *args:
cd rust && test -f target/debug/xtask || cargo build --package xtask -q && ./target/debug/xtask {{args}}
+# Setup repository after fresh clone
+[group('utils')]
+[script('bash')]
+setup:
+ set -euo pipefail
+ repo_root="$(git rev-parse --show-toplevel)"
+ bdk_submodule="$repo_root/rust/external/bdk"
+ bdk_patch_dir="$repo_root/rust/patches/bdk"
+ git submodule sync --recursive
+ if ! git submodule update --init --recursive --depth 1 --recommend-shallow; then
+ echo "Shallow submodule checkout failed, retrying with full history..."
+ git submodule update --init --recursive
+ fi
+ for patch in "$bdk_patch_dir"/*.patch; do
+ [ -e "$patch" ] || continue
+ if git -C "$bdk_submodule" apply --check "$patch"; then
+ git -C "$bdk_submodule" apply "$patch"
+ echo "Applied $(basename "$patch")"
+ elif git -C "$bdk_submodule" apply --reverse --check "$patch"; then
+ echo "Already applied $(basename "$patch")"
+ else
+ echo "Failed to apply $(basename "$patch") cleanly" >&2
+ exit 1
+ fi
+ done
+ git submodule status --recursive
+ echo "Repository setup complete."
+
# Sign a PSBT and output all formats (base64, hex, binary, bbqr-gif, ur-gif)
# Requires MNEMONIC env var (set in .envrc or pass directly)
[group('utils')]
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 2adbe6c65..0dfc067dc 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -23,7 +23,7 @@ dependencies = [
"act-zero",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -51,6 +51,18 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+ "zeroize",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -91,6 +103,61 @@ dependencies = [
"equator",
]
+[[package]]
+name = "amplify"
+version = "4.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31"
+dependencies = [
+ "amplify_derive",
+ "amplify_num",
+ "ascii",
+ "getrandom 0.2.17",
+ "getrandom 0.3.4",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "amplify_derive"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428"
+dependencies = [
+ "amplify_syn",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "amplify_num"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99bcb75a2982047f733547042fc3968c0f460dfcf7d90b90dea3b2744580e9ad"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "amplify_syn"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "anstream"
version = "1.0.0"
@@ -170,7 +237,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -191,6 +258,114 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "arti"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f603df52eff853e311527f9292ce378df1e0b87876cff3ef76eb4f495b7d7013"
+dependencies = [
+ "anyhow",
+ "arti-client",
+ "cfg-if",
+ "clap",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "fs-mistrust",
+ "futures",
+ "futures-copy",
+ "hickory-proto",
+ "humantime",
+ "humantime-serde",
+ "itertools 0.14.0",
+ "libc",
+ "notify",
+ "paste",
+ "pin-project",
+ "postage",
+ "rlimit",
+ "rustls",
+ "safelog",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "time",
+ "tokio",
+ "tokio-util",
+ "toml 1.1.0+spec-1.1.0",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-config",
+ "tor-config-path",
+ "tor-error",
+ "tor-general-addr",
+ "tor-key-forge",
+ "tor-keymgr",
+ "tor-log-ratelim",
+ "tor-proto",
+ "tor-rtcompat",
+ "tor-socksproto",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "visibility",
+ "web-time-compat",
+ "winapi",
+]
+
+[[package]]
+name = "arti-client"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e15d2051582670d5c003deda168da03f0d3475f6375bca57d6b9852a9d32eed"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "cfg-if",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "educe",
+ "fs-mistrust",
+ "futures",
+ "hostname-validator",
+ "humantime",
+ "humantime-serde",
+ "libc",
+ "once_cell",
+ "postage",
+ "rand 0.9.2",
+ "safelog",
+ "serde",
+ "tempfile",
+ "thiserror 2.0.18",
+ "time",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-chanmgr",
+ "tor-circmgr",
+ "tor-config",
+ "tor-config-path",
+ "tor-dircommon",
+ "tor-dirmgr",
+ "tor-error",
+ "tor-guardmgr",
+ "tor-hsclient",
+ "tor-hscrypto",
+ "tor-keymgr",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-netdir",
+ "tor-netdoc",
+ "tor-persist",
+ "tor-proto",
+ "tor-protover",
+ "tor-rtcompat",
+ "tracing",
+ "void",
+ "web-time-compat",
+]
+
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -200,6 +375,12 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
[[package]]
name = "askama"
version = "0.13.1"
@@ -240,7 +421,7 @@ dependencies = [
"rustc-hash",
"serde",
"serde_derive",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -257,7 +438,7 @@ dependencies = [
"rustc-hash",
"serde",
"serde_derive",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -294,6 +475,50 @@ dependencies = [
"winnow 1.0.1",
]
+[[package]]
+name = "asn1-rs"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "assert_matches"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
+
[[package]]
name = "async-compat"
version = "0.2.5"
@@ -307,6 +532,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "async-compression"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "futures-io",
+ "pin-project-lite",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -315,7 +552,50 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async_executors"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5"
+dependencies = [
+ "blanket",
+ "futures-core",
+ "futures-task",
+ "futures-util",
+ "pin-project",
+ "rustc_version",
+ "tokio",
+]
+
+[[package]]
+name = "asynchronous-codec"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233"
+dependencies = [
+ "bytes",
+ "futures-sink",
+ "futures-util",
+ "memchr",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "atomic"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
+
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
]
[[package]]
@@ -399,6 +679,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
[[package]]
name = "base58ck"
version = "0.1.0"
@@ -452,8 +738,6 @@ dependencies = [
[[package]]
name = "bdk_chain"
version = "0.23.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c290eff038799a8ac0c5a82b6160a9ca456baa299a6f22b262c771342d2846c0"
dependencies = [
"bdk_core",
"bitcoin",
@@ -465,8 +749,6 @@ dependencies = [
[[package]]
name = "bdk_core"
version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb3028782f6bf14a6df987244333d34e6b272b5a40a53e4879ec2dfd82275a3a"
dependencies = [
"bitcoin",
"hashbrown 0.14.5",
@@ -502,7 +784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446f02da319941ea78454a479fca72707f3355a62db3bb1c2c13b94e7f963ce0"
dependencies = [
"bdk_core",
- "bincode",
+ "bincode 1.3.3",
"serde",
]
@@ -537,6 +819,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bincode"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
+dependencies = [
+ "serde",
+ "unty",
+]
+
[[package]]
name = "bip329"
version = "0.4.0"
@@ -641,6 +933,12 @@ dependencies = [
"percent-encoding-rfc3986",
]
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -677,6 +975,17 @@ dependencies = [
"digest",
]
+[[package]]
+name = "blanket"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -686,6 +995,17 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
[[package]]
name = "built"
version = "0.8.0"
@@ -698,6 +1018,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -731,6 +1057,12 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "caret"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101"
+
[[package]]
name = "cargo-platform"
version = "0.1.9"
@@ -852,6 +1184,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -909,7 +1253,8 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
- "strsim",
+ "strsim 0.11.1",
+ "terminal_size",
]
[[package]]
@@ -921,7 +1266,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -930,6 +1275,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "coarsetime"
+version = "0.1.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2"
+dependencies = [
+ "libc",
+ "wasix",
+ "wasm-bindgen",
+]
+
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -989,6 +1345,40 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "compression-codecs"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "liblzma",
+ "zstd",
+ "zstd-safe",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -998,6 +1388,15 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "cookie-factory"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
+dependencies = [
+ "futures",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1032,6 +1431,8 @@ dependencies = [
"ahash",
"arc-swap",
"argon2",
+ "arti",
+ "arti-client",
"async-trait",
"backon",
"base64 0.22.1",
@@ -1043,7 +1444,7 @@ dependencies = [
"bip329",
"bip39",
"bitcoin",
- "bitflags",
+ "bitflags 2.11.0",
"bitvec",
"cbor4ii",
"cfg-if",
@@ -1095,12 +1496,15 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sha2",
- "strsim",
- "strum",
+ "strsim 0.11.1",
+ "strum 0.28.0",
"tap",
"tempfile",
"thiserror 2.0.18",
"tokio",
+ "tor-config",
+ "tor-config-path",
+ "tor-rtcompat",
"tracing",
"tracing-log",
"tracing-subscriber",
@@ -1267,7 +1671,7 @@ dependencies = [
"redb",
"serde",
"serde_json",
- "strum",
+ "strum 0.28.0",
"tap",
"thiserror 2.0.18",
"tracing",
@@ -1351,6 +1755,21 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1371,8 +1790,17 @@ dependencies = [
]
[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
@@ -1382,6 +1810,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1414,12 +1854,245 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
+dependencies = [
+ "darling_core 0.14.4",
+ "darling_macro 0.14.4",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim 0.10.0",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim 0.11.1",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
+dependencies = [
+ "darling_core 0.14.4",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "cookie-factory",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive-deftly"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3"
+dependencies = [
+ "derive-deftly-macros",
+ "heck",
+]
+
+[[package]]
+name = "derive-deftly-macros"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41"
+dependencies = [
+ "heck",
+ "indexmap 2.13.0",
+ "itertools 0.14.0",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "sha3",
+ "strum 0.27.2",
+ "syn 2.0.117",
+ "void",
+]
+
+[[package]]
+name = "derive_builder_core_fork_arti"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d"
+dependencies = [
+ "darling 0.14.4",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "derive_builder_fork_arti"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228"
+dependencies = [
+ "derive_builder_macro_fork_arti",
+]
+
+[[package]]
+name = "derive_builder_macro_fork_arti"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3"
+dependencies = [
+ "derive_builder_core_fork_arti",
+ "syn 1.0.109",
+]
+
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1439,7 +2112,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
- "syn",
+ "syn 2.0.117",
"unicode-xid",
]
@@ -1456,10 +2129,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
"subtle",
]
+[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys",
+]
+
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1489,15 +2172,79 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
+[[package]]
+name = "downcast-rs"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
+
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "merlin",
+ "rand_core 0.6.4",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "educe"
+version = "0.4.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f"
+dependencies = [
+ "enum-ordinalize",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -1521,6 +2268,83 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enum-ordinalize"
+version = "3.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enumset"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
+dependencies = [
+ "darling 0.21.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "equator"
version = "0.4.2"
@@ -1538,7 +2362,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1572,6 +2396,17 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "exr"
version = "1.74.0"
@@ -1635,7 +2470,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1648,12 +2483,52 @@ dependencies = [
]
[[package]]
-name = "find-msvc-tools"
-version = "0.1.9"
+name = "ff"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
-
-[[package]]
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "figment"
+version = "0.10.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
+dependencies = [
+ "atomic 0.6.1",
+ "serde",
+ "toml 0.8.23",
+ "uncased",
+ "version_check",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
name = "fixedbitset"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1670,6 +2545,12 @@ dependencies = [
"zlib-rs",
]
+[[package]]
+name = "fluid-let"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a"
+
[[package]]
name = "flume"
version = "0.12.0"
@@ -1682,6 +2563,12 @@ dependencies = [
"spin",
]
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1708,7 +2595,7 @@ dependencies = [
"heapless",
"itertools 0.10.5",
"minicbor 0.24.4",
- "phf",
+ "phf 0.11.3",
"rand_xoshiro",
]
@@ -1730,6 +2617,31 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "fs-mistrust"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2"
+dependencies = [
+ "derive_builder_fork_arti",
+ "dirs",
+ "libc",
+ "pwd-grp",
+ "serde",
+ "thiserror 2.0.18",
+ "walkdir",
+]
+
+[[package]]
+name = "fslock"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
[[package]]
name = "funty"
version = "2.0.0"
@@ -1761,6 +2673,18 @@ dependencies = [
"futures-sink",
]
+[[package]]
+name = "futures-copy"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24f320278d16b5617859cc2234ca5775a8d9b24faee8c14e54e79540caab053a"
+dependencies = [
+ "futures",
+ "libc",
+ "pin-project",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -1792,7 +2716,18 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-rustls"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
+dependencies = [
+ "futures-io",
+ "rustls",
+ "rustls-pki-types",
]
[[package]]
@@ -1832,6 +2767,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -1868,11 +2804,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getset"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -1897,6 +2847,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "glob-match"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d"
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1920,6 +2876,17 @@ dependencies = [
"scroll",
]
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -1940,6 +2907,12 @@ dependencies = [
"byteorder",
]
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1967,11 +2940,11 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
-version = "0.9.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
- "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
]
[[package]]
@@ -2026,6 +2999,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+[[package]]
+name = "hickory-proto"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "once_cell",
+ "rand 0.9.2",
+ "ring",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -2044,6 +3042,12 @@ dependencies = [
"digest",
]
+[[package]]
+name = "hostname-validator"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2"
+
[[package]]
name = "http"
version = "1.4.0"
@@ -2083,6 +3087,28 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humantime"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+
+[[package]]
+name = "humantime-serde"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c"
+dependencies = [
+ "humantime",
+ "serde",
+]
+
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2144,6 +3170,30 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -2231,6 +3281,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -2286,6 +3342,16 @@ dependencies = [
"quick-error",
]
+[[package]]
+name = "imara-diff"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c"
+dependencies = [
+ "hashbrown 0.15.5",
+ "memchr",
+]
+
[[package]]
name = "imgref"
version = "1.12.0"
@@ -2298,6 +3364,17 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -2310,6 +3387,26 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inotify"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
+dependencies = [
+ "bitflags 2.11.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -2327,7 +3424,16 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "inventory"
+version = "0.3.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
+dependencies = [
+ "rustversion",
]
[[package]]
@@ -2399,7 +3505,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2458,7 +3564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2483,11 +3589,43 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
[[package]]
name = "leb128fmt"
@@ -2517,20 +3655,49 @@ dependencies = [
"cc",
]
+[[package]]
+name = "liblzma"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899"
+dependencies = [
+ "liblzma-sys",
+]
+
+[[package]]
+name = "liblzma-sys"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libredox"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
+ "bitflags 2.11.0",
"libc",
+ "plain",
+ "redox_syscall 0.7.4",
]
[[package]]
name = "libsqlite3-sys"
-version = "0.28.0"
+version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
+checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15"
dependencies = [
"cc",
"openssl-sys",
@@ -2606,23 +3773,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
-name = "minicbor"
-version = "0.24.4"
+name = "memmap2"
+version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29be4f60e41fde478b36998b88821946aafac540e53591e76db53921a0cc225b"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
dependencies = [
- "minicbor-derive 0.15.3",
+ "libc",
]
[[package]]
-name = "minicbor"
-version = "2.2.1"
+name = "merlin"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e70eae6d4f18f7d76877fe7b13f0bc21f7c2b7239d2041c338335f7b388d0dd7"
+checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
dependencies = [
- "minicbor-derive 0.19.3",
-]
-
+ "byteorder",
+ "keccak",
+ "rand_core 0.6.4",
+ "zeroize",
+]
+
+[[package]]
+name = "minicbor"
+version = "0.24.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29be4f60e41fde478b36998b88821946aafac540e53591e76db53921a0cc225b"
+dependencies = [
+ "minicbor-derive 0.15.3",
+]
+
+[[package]]
+name = "minicbor"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e70eae6d4f18f7d76877fe7b13f0bc21f7c2b7239d2041c338335f7b388d0dd7"
+dependencies = [
+ "minicbor-derive 0.19.3",
+]
+
[[package]]
name = "minicbor-derive"
version = "0.15.3"
@@ -2631,7 +3819,7 @@ checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2642,7 +3830,7 @@ checksum = "294f0a0c161c510e9746adf546b8b044fbb0b00677d7dfc9a2452f9fdf63439b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2690,6 +3878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2739,12 +3928,53 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "nonany"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0"
+
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.11.0",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2764,6 +3994,28 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.5",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -2772,7 +4024,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2784,6 +4036,17 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -2802,6 +4065,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -2813,6 +4099,25 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
[[package]]
name = "object"
version = "0.37.3"
@@ -2827,6 +4132,10 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
[[package]]
name = "once_cell_polyfill"
@@ -2834,6 +4143,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "oneshot-fused-workaround"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82"
+dependencies = [
+ "futures",
+]
+
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -2874,12 +4192,74 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "os_str_bytes"
+version = "6.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p521"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "rand_core 0.6.4",
+ "sha2",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2900,7 +4280,7 @@ dependencies = [
"cfg-if",
"libc",
"petgraph",
- "redox_syscall",
+ "redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -2944,6 +4324,15 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2963,7 +4352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
- "indexmap",
+ "indexmap 2.13.0",
]
[[package]]
@@ -2972,8 +4361,19 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
- "phf_macros",
- "phf_shared",
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros 0.13.1",
+ "phf_shared 0.13.1",
+ "serde",
]
[[package]]
@@ -2982,21 +4382,44 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
- "phf_shared",
+ "phf_shared 0.11.3",
"rand 0.8.5",
]
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.13.1",
+]
+
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -3008,6 +4431,35 @@ dependencies = [
"siphasher 1.0.2",
]
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher 1.0.2",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -3020,6 +4472,27 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3038,7 +4511,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -3071,6 +4544,21 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postage"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1"
+dependencies = [
+ "atomic 0.5.3",
+ "crossbeam-queue",
+ "futures",
+ "parking_lot",
+ "pin-project",
+ "static_assertions",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3080,6 +4568,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -3106,56 +4600,119 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.117",
]
[[package]]
-name = "proc-macro2"
-version = "1.0.106"
+name = "primeorder"
+version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
- "unicode-ident",
+ "elliptic-curve",
]
[[package]]
-name = "profiling"
-version = "1.0.17"
+name = "priority-queue"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96"
dependencies = [
- "profiling-procmacros",
+ "equivalent",
+ "indexmap 2.13.0",
+ "serde",
]
[[package]]
-name = "profiling-procmacros"
-version = "1.0.17"
+name = "proc-macro-crate"
+version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "quote",
- "syn",
+ "toml_edit 0.25.8+spec-1.1.0",
]
[[package]]
-name = "pubport"
-version = "0.5.0"
+name = "proc-macro-error-attr2"
+version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8cf693e3f6aa628ab34d81cb2ed0b75578b36154f61aeadbbea934ef32e603"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
- "bitcoin",
- "derive_more",
- "log",
- "memchr",
- "miniscript 13.0.0",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
+ "proc-macro2",
+ "quote",
]
[[package]]
-name = "pxfm"
-version = "0.1.28"
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+dependencies = [
+ "profiling-procmacros",
+]
+
+[[package]]
+name = "profiling-procmacros"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pubport"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8cf693e3f6aa628ab34d81cb2ed0b75578b36154f61aeadbbea934ef32e603"
+dependencies = [
+ "bitcoin",
+ "derive_more",
+ "log",
+ "memchr",
+ "miniscript 13.0.0",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "pwd-grp"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c"
+dependencies = [
+ "derive-deftly",
+ "libc",
+ "paste",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
@@ -3347,6 +4904,17 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+[[package]]
+name = "rand_jitter"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0"
+dependencies = [
+ "libc",
+ "rand_core 0.9.5",
+ "winapi",
+]
+
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
@@ -3426,6 +4994,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "rdrand"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "redb"
version = "2.6.3"
@@ -3441,7 +5018,16 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+dependencies = [
+ "bitflags 2.11.0",
]
[[package]]
@@ -3455,6 +5041,38 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -3546,6 +5164,26 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "retry-error"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0"
+dependencies = [
+ "humantime",
+ "web-time",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -3566,18 +5204,49 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rlimit"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sha2",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "rusqlite"
-version = "0.31.0"
+version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
+checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
+ "time",
]
[[package]]
@@ -3616,13 +5285,22 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom 7.1.3",
+]
+
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
@@ -3725,6 +5403,19 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "safelog"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee9f10dd250956c65d58a19507dd06ff976f898560fe843580d05134541f0898"
+dependencies = [
+ "derive_more",
+ "educe",
+ "either",
+ "fluid-let",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3734,6 +5425,21 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "sanitize-filename"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
+dependencies = [
+ "regex",
+]
+
+[[package]]
+name = "saturating-time"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230"
+
[[package]]
name = "schannel"
version = "0.1.29"
@@ -3743,6 +5449,30 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3766,7 +5496,21 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
]
[[package]]
@@ -3796,7 +5540,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -3833,6 +5577,16 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
[[package]]
name = "serde_bytes"
version = "0.11.19"
@@ -3860,7 +5614,17 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_ignored"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
+dependencies = [
+ "serde",
+ "serde_core",
]
[[package]]
@@ -3876,6 +5640,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_spanned"
version = "1.1.0"
@@ -3897,6 +5670,48 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_with"
+version = "3.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.13.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
+dependencies = [
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3908,6 +5723,16 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sha3"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -3917,6 +5742,17 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shellexpand"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
+dependencies = [
+ "bstr",
+ "dirs",
+ "os_str_bytes",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3924,16 +5760,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
-name = "simd-adler32"
-version = "0.3.9"
+name = "signal-hook-registry"
+version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
[[package]]
-name = "simd_helpers"
-version = "0.1.0"
+name = "signature"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_helpers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
@@ -3956,6 +5812,29 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "slotmap-careful"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64"
+dependencies = [
+ "paste",
+ "serde",
+ "slotmap",
+ "thiserror 2.0.18",
+ "void",
+]
+
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -3987,6 +5866,58 @@ dependencies = [
"lock_api",
]
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "ssh-cipher-fork-arti"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d"
+dependencies = [
+ "cipher",
+ "ssh-encoding-fork-arti",
+]
+
+[[package]]
+name = "ssh-encoding-fork-arti"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c"
+dependencies = [
+ "base64ct",
+ "pem-rfc7468",
+ "sha2",
+]
+
+[[package]]
+name = "ssh-key-fork-arti"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388"
+dependencies = [
+ "num-bigint-dig",
+ "p256",
+ "p384",
+ "p521",
+ "rand_core 0.6.4",
+ "rsa",
+ "sec1",
+ "sha2",
+ "signature",
+ "ssh-cipher-fork-arti",
+ "ssh-encoding-fork-arti",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3999,19 +5930,46 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+dependencies = [
+ "strum_macros 0.27.2",
+]
+
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
- "strum_macros",
+ "strum_macros 0.28.0",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -4023,7 +5981,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4032,6 +5990,23 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "syn"
version = "2.0.117"
@@ -4060,7 +6035,21 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows",
]
[[package]]
@@ -4082,6 +6071,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "textwrap"
version = "0.16.2"
@@ -4117,7 +6116,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4128,7 +6127,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4154,6 +6153,37 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -4161,6 +6191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -4180,88 +6211,1197 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
-name = "tokio"
-version = "1.50.0"
+name = "tokio"
+version = "1.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde_core",
+ "serde_spanned 1.1.0",
+ "toml_datetime 1.1.0+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.1",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
+dependencies = [
+ "indexmap 2.13.0",
+ "toml_datetime 1.1.0+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.1",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
+dependencies = [
+ "winnow 1.0.1",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "toml_writer"
+version = "1.1.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
+
+[[package]]
+name = "tor-async-utils"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4680d5ecdb052e950b31cd7c7852aa025968f648046e55251446d7d0e58138e"
+dependencies = [
+ "derive-deftly",
+ "educe",
+ "futures",
+ "oneshot-fused-workaround",
+ "pin-project",
+ "postage",
+ "thiserror 2.0.18",
+ "void",
+]
+
+[[package]]
+name = "tor-basic-utils"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a2351dee62e4edabb624a7e7ba85f6f2b73073ec4590e76c9b6f90fc30b91e9"
+dependencies = [
+ "derive_more",
+ "getrandom 0.3.4",
+ "hex",
+ "itertools 0.14.0",
+ "libc",
+ "paste",
+ "rand 0.9.2",
+ "rand_chacha 0.9.0",
+ "serde",
+ "slab",
+ "smallvec",
+ "thiserror 2.0.18",
+ "weak-table",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-bytes"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e86f91871fcd2e2fb16fcf55b7821b050b11d05d42b7e98104b662417bafda92"
+dependencies = [
+ "bytes",
+ "derive-deftly",
+ "digest",
+ "educe",
+ "getrandom 0.4.2",
+ "safelog",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-llcrypto",
+ "zeroize",
+]
+
+[[package]]
+name = "tor-cell"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ee932b1f0890a0ac689f6fdeec0fe4cc9e11bc1a18b2a09c2fb7adbd0761df4"
+dependencies = [
+ "amplify",
+ "bitflags 2.11.0",
+ "bytes",
+ "caret",
+ "derive-deftly",
+ "derive_more",
+ "educe",
+ "itertools 0.14.0",
+ "paste",
+ "rand 0.9.2",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-cert",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-protover",
+ "tor-units",
+ "void",
+]
+
+[[package]]
+name = "tor-cert"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f774e4097af30e759ddcfcdd50e594183b62c2e70e3dd55f4b61e254d527505"
+dependencies = [
+ "caret",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "digest",
+ "thiserror 2.0.18",
+ "tor-bytes",
+ "tor-checkable",
+ "tor-error",
+ "tor-llcrypto",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-chanmgr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31db74e069430a70f290820d7f60e2cd96364dcb4b8bbd92aee9bf94b9a2dfca"
+dependencies = [
+ "async-trait",
+ "base64ct",
+ "caret",
+ "cfg-if",
+ "derive-deftly",
+ "derive_more",
+ "educe",
+ "futures",
+ "httparse",
+ "oneshot-fused-workaround",
+ "percent-encoding",
+ "postage",
+ "rand 0.9.2",
+ "safelog",
+ "serde",
+ "serde_with",
+ "thiserror 2.0.18",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-cell",
+ "tor-config",
+ "tor-error",
+ "tor-keymgr",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-netdir",
+ "tor-proto",
+ "tor-rtcompat",
+ "tor-socksproto",
+ "tor-units",
+ "tracing",
+ "url",
+ "void",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-checkable"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d65310bcfd125f65e4ac8ee290b352d20a80909ef7226741f57e9a049391d3d7"
+dependencies = [
+ "humantime",
+ "signature",
+ "thiserror 2.0.18",
+ "tor-llcrypto",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-circmgr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1310bb4288894f3bae03d0201ee397bed95fd08b24b18b5303a0bdbee012fba"
+dependencies = [
+ "amplify",
+ "async-trait",
+ "cfg-if",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "downcast-rs",
+ "dyn-clone",
+ "educe",
+ "futures",
+ "humantime-serde",
+ "itertools 0.14.0",
+ "once_cell",
+ "oneshot-fused-workaround",
+ "pin-project",
+ "rand 0.9.2",
+ "retry-error",
+ "safelog",
+ "serde",
+ "thiserror 2.0.18",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-cell",
+ "tor-chanmgr",
+ "tor-config",
+ "tor-dircommon",
+ "tor-error",
+ "tor-guardmgr",
+ "tor-linkspec",
+ "tor-memquota",
+ "tor-netdir",
+ "tor-netdoc",
+ "tor-persist",
+ "tor-proto",
+ "tor-protover",
+ "tor-relay-selection",
+ "tor-rtcompat",
+ "tor-units",
+ "tracing",
+ "void",
+ "weak-table",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-config"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a1148697cfeeb8c35a3bd2ee3eb98a3510610635fca63e46bca33dfb3f450e"
+dependencies = [
+ "amplify",
+ "cfg-if",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "educe",
+ "either",
+ "figment",
+ "fs-mistrust",
+ "futures",
+ "humantime-serde",
+ "itertools 0.14.0",
+ "notify",
+ "paste",
+ "postage",
+ "regex",
+ "serde",
+ "serde-value",
+ "serde_ignored",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "toml 1.1.0+spec-1.1.0",
+ "tor-basic-utils",
+ "tor-error",
+ "tor-rtcompat",
+ "tracing",
+ "void",
+]
+
+[[package]]
+name = "tor-config-path"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a3cc17b20f63fa231e3811005c3c39dd55b4c3bc44c73565be2a632cc53a442"
+dependencies = [
+ "directories",
+ "serde",
+ "shellexpand",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-general-addr",
+]
+
+[[package]]
+name = "tor-consdiff"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77b5da8bcea62b5f6695f9271f8367d6d00977a6bffd2da9a0795c29cb839790"
+dependencies = [
+ "derive_more",
+ "digest",
+ "hex",
+ "imara-diff",
+ "static_assertions",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-llcrypto",
+ "tor-netdoc",
+]
+
+[[package]]
+name = "tor-dirclient"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139aec98a8579613a08b1283ca9da4ceda110e0627798bff22b1b692a7a03bf0"
+dependencies = [
+ "async-compression",
+ "base64ct",
+ "derive_more",
+ "futures",
+ "hex",
+ "http",
+ "httparse",
+ "httpdate",
+ "itertools 0.14.0",
+ "memchr",
+ "thiserror 2.0.18",
+ "tor-circmgr",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-netdoc",
+ "tor-proto",
+ "tor-rtcompat",
+ "tracing",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-dircommon"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c0e029a636b59ac25e4c2e261e306775dac5200c1632f99696cfc2c236b3b5d"
+dependencies = [
+ "base64ct",
+ "derive-deftly",
+ "getset",
+ "humantime",
+ "humantime-serde",
+ "serde",
+ "tor-basic-utils",
+ "tor-checkable",
+ "tor-config",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-netdoc",
+ "tracing",
+]
+
+[[package]]
+name = "tor-dirmgr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a659fc3d3aa9f1c11d940cd5fcc37a641a8db3595ec2b8df6dc4595e7d71ef08"
+dependencies = [
+ "async-trait",
+ "base64ct",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "digest",
+ "educe",
+ "event-listener",
+ "fs-mistrust",
+ "fslock",
+ "futures",
+ "hex",
+ "humantime",
+ "humantime-serde",
+ "itertools 0.14.0",
+ "memmap2",
+ "oneshot-fused-workaround",
+ "paste",
+ "postage",
+ "rand 0.9.2",
+ "rusqlite",
+ "safelog",
+ "scopeguard",
+ "serde",
+ "serde_json",
+ "signature",
+ "static_assertions",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "time",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-checkable",
+ "tor-circmgr",
+ "tor-config",
+ "tor-consdiff",
+ "tor-dirclient",
+ "tor-dircommon",
+ "tor-error",
+ "tor-guardmgr",
+ "tor-llcrypto",
+ "tor-netdir",
+ "tor-netdoc",
+ "tor-persist",
+ "tor-proto",
+ "tor-protover",
+ "tor-rtcompat",
+ "tracing",
+ "void",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-error"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f8163b225e07e00be618cbebdbbc8ff003795121971cdf54297e820cc177569"
+dependencies = [
+ "derive_more",
+ "futures",
+ "paste",
+ "retry-error",
+ "static_assertions",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "tracing",
+ "void",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-general-addr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b53d8066194461d13603437331c24c7a722d3d4e375ed1552c434b3240880f17"
+dependencies = [
+ "derive_more",
+ "thiserror 2.0.18",
+ "void",
+]
+
+[[package]]
+name = "tor-guardmgr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "119df8b27549a1db143713b9afde6d0d5ef1736b9fd03264715bce9d83616d10"
+dependencies = [
+ "amplify",
+ "base64ct",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "dyn-clone",
+ "educe",
+ "futures",
+ "humantime",
+ "humantime-serde",
+ "itertools 0.14.0",
+ "num_enum",
+ "oneshot-fused-workaround",
+ "pin-project",
+ "postage",
+ "rand 0.9.2",
+ "safelog",
+ "serde",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-config",
+ "tor-dircommon",
+ "tor-error",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-netdir",
+ "tor-netdoc",
+ "tor-persist",
+ "tor-proto",
+ "tor-relay-selection",
+ "tor-rtcompat",
+ "tor-units",
+ "tracing",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-hsclient"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9598bfa74aa4734be431d0538e4e6a39f34ab166a4f31ffb584a406b0c72d708"
+dependencies = [
+ "async-trait",
+ "derive-deftly",
+ "derive_more",
+ "educe",
+ "either",
+ "futures",
+ "itertools 0.14.0",
+ "oneshot-fused-workaround",
+ "postage",
+ "rand 0.9.2",
+ "retry-error",
+ "safelog",
+ "slotmap-careful",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-cell",
+ "tor-checkable",
+ "tor-circmgr",
+ "tor-config",
+ "tor-dirclient",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-keymgr",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-netdir",
+ "tor-netdoc",
+ "tor-persist",
+ "tor-proto",
+ "tor-protover",
+ "tor-rtcompat",
+ "tracing",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-hscrypto"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38f7f57feca263556c41c5d86129b6bd4b1f41dbfc566d070b112bd0bb1b6a21"
+dependencies = [
+ "data-encoding",
+ "derive-deftly",
+ "derive_more",
+ "digest",
+ "hex",
+ "humantime",
+ "itertools 0.14.0",
+ "paste",
+ "rand 0.9.2",
+ "safelog",
+ "serde",
+ "signature",
+ "subtle",
+ "thiserror 2.0.18",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-error",
+ "tor-key-forge",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-units",
+ "void",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-key-forge"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cea1159867a98160a0629529fdb3615479d3f963b5ab92914dbcc6f0b68bf5c"
+dependencies = [
+ "derive-deftly",
+ "derive_more",
+ "downcast-rs",
+ "paste",
+ "rand 0.9.2",
+ "rsa",
+ "signature",
+ "ssh-key-fork-arti",
+ "thiserror 2.0.18",
+ "tor-bytes",
+ "tor-cert",
+ "tor-checkable",
+ "tor-error",
+ "tor-llcrypto",
+]
+
+[[package]]
+name = "tor-keymgr"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98e14dbf1a37b9efb1df0650cdf5cb0d6da83589f6d42cfc4d2f7762d72594a5"
+dependencies = [
+ "amplify",
+ "arrayvec",
+ "cfg-if",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "downcast-rs",
+ "dyn-clone",
+ "fs-mistrust",
+ "glob-match",
+ "humantime",
+ "inventory",
+ "itertools 0.14.0",
+ "rand 0.9.2",
+ "safelog",
+ "serde",
+ "signature",
+ "ssh-key-fork-arti",
+ "thiserror 2.0.18",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-config",
+ "tor-config-path",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-key-forge",
+ "tor-llcrypto",
+ "tor-persist",
+ "tracing",
+ "visibility",
+ "walkdir",
+ "web-time-compat",
+ "zeroize",
+]
+
+[[package]]
+name = "tor-linkspec"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af62be67033640f5553797541720e34c12bf9ddb8569655d6241f32e7deed88f"
+dependencies = [
+ "base64ct",
+ "by_address",
+ "caret",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "hex",
+ "itertools 0.14.0",
+ "safelog",
+ "serde",
+ "serde_with",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-config",
+ "tor-llcrypto",
+ "tor-memquota",
+ "tor-protover",
+]
+
+[[package]]
+name = "tor-llcrypto"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc87d3def186b8edc21ec8f3645dd28cf3b13c6259f4e335e71b7ef52c6ea3f2"
+dependencies = [
+ "aes",
+ "base64ct",
+ "ctr",
+ "curve25519-dalek",
+ "der-parser",
+ "derive-deftly",
+ "derive_more",
+ "digest",
+ "ed25519-dalek",
+ "educe",
+ "getrandom 0.2.17",
+ "getrandom 0.3.4",
+ "getrandom 0.4.2",
+ "hex",
+ "rand 0.9.2",
+ "rand_chacha 0.9.0",
+ "rand_core 0.6.4",
+ "rand_core 0.9.5",
+ "rand_jitter",
+ "rdrand",
+ "rsa",
+ "safelog",
+ "serde",
+ "sha1",
+ "sha2",
+ "sha3",
+ "signature",
+ "subtle",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-memquota-cost",
+ "visibility",
+ "x25519-dalek",
+ "zeroize",
+]
+
+[[package]]
+name = "tor-log-ratelim"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70066c9cf52f6c273243e97bc548c9be6e1d15e1bf5962da4f0be19686c85ade"
+dependencies = [
+ "futures",
+ "humantime",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-rtcompat",
+ "tracing",
+ "weak-table",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-memquota"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeeba11704612b6fb277d708d0ff9b471266084d86a6e557f858cdadaa61ca49"
+dependencies = [
+ "cfg-if",
+ "derive-deftly",
+ "derive_more",
+ "dyn-clone",
+ "educe",
+ "futures",
+ "itertools 0.14.0",
+ "paste",
+ "pin-project",
+ "serde",
+ "slotmap-careful",
+ "static_assertions",
+ "sysinfo",
+ "thiserror 2.0.18",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-config",
+ "tor-error",
+ "tor-log-ratelim",
+ "tor-memquota-cost",
+ "tor-rtcompat",
+ "tracing",
+ "void",
+]
+
+[[package]]
+name = "tor-memquota-cost"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0178ecdfe9a2856232aaa41805dfd75b8512a21e8e6d02a67427d00e221b192b"
+dependencies = [
+ "derive-deftly",
+ "itertools 0.14.0",
+ "paste",
+ "void",
+]
+
+[[package]]
+name = "tor-netdir"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "570b4d45feb00f9a04b429ba1a612b956b263ca01bb7b56a38e01d1223d581b7"
+dependencies = [
+ "async-trait",
+ "bitflags 2.11.0",
+ "derive_more",
+ "digest",
+ "futures",
+ "hex",
+ "humantime",
+ "itertools 0.14.0",
+ "num_enum",
+ "rand 0.9.2",
+ "serde",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "time",
+ "tor-basic-utils",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-netdoc",
+ "tor-protover",
+ "tor-units",
+ "tracing",
+ "typed-index-collections",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-netdoc"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee1e809fc7d54cab30451fcb5f765ebb480ac8ad3e62454001ae875d36d1c27e"
+dependencies = [
+ "amplify",
+ "base64ct",
+ "cipher",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "digest",
+ "educe",
+ "enumset",
+ "hex",
+ "humantime",
+ "itertools 0.14.0",
+ "memchr",
+ "paste",
+ "phf 0.13.1",
+ "rand 0.9.2",
+ "saturating-time",
+ "serde",
+ "serde_with",
+ "signature",
+ "smallvec",
+ "strum 0.28.0",
+ "subtle",
+ "thiserror 2.0.18",
+ "time",
+ "tinystr",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-cell",
+ "tor-cert",
+ "tor-checkable",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-protover",
+ "tor-units",
+ "void",
+ "web-time-compat",
+ "zeroize",
+]
+
+[[package]]
+name = "tor-persist"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b6d2a9f700c0cbea6dd9b6b3614c47d26fbeffcb0ffdc10caf713a465fb913b"
+dependencies = [
+ "derive-deftly",
+ "derive_more",
+ "filetime",
+ "fs-mistrust",
+ "fslock",
+ "futures",
+ "itertools 0.14.0",
+ "oneshot-fused-workaround",
+ "paste",
+ "sanitize-filename",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "time",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-error",
+ "tracing",
+ "void",
+ "web-time-compat",
+]
+
+[[package]]
+name = "tor-proto"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+checksum = "db8556b34a5a24a8ce20cd439ad2081b6097645e165d95387ba8eb8fa17f3520"
dependencies = [
+ "amplify",
+ "async-trait",
+ "asynchronous-codec",
+ "bitvec",
"bytes",
- "libc",
- "mio",
- "pin-project-lite",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
+ "caret",
+ "cfg-if",
+ "cipher",
+ "coarsetime",
+ "derive-deftly",
+ "derive_builder_fork_arti",
+ "derive_more",
+ "digest",
+ "educe",
+ "enum_dispatch",
+ "futures",
+ "futures-util",
+ "hkdf",
+ "hmac",
+ "itertools 0.14.0",
+ "nonany",
+ "oneshot-fused-workaround",
+ "pin-project",
+ "postage",
+ "rand 0.9.2",
+ "rand_core 0.9.5",
+ "safelog",
+ "slotmap-careful",
+ "smallvec",
+ "static_assertions",
+ "subtle",
+ "sync_wrapper",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "tor-async-utils",
+ "tor-basic-utils",
+ "tor-bytes",
+ "tor-cell",
+ "tor-cert",
+ "tor-checkable",
+ "tor-config",
+ "tor-error",
+ "tor-hscrypto",
+ "tor-linkspec",
+ "tor-llcrypto",
+ "tor-log-ratelim",
+ "tor-memquota",
+ "tor-protover",
+ "tor-relay-crypto",
+ "tor-rtcompat",
+ "tor-rtmock",
+ "tor-units",
+ "tracing",
+ "typenum",
+ "visibility",
+ "void",
+ "web-time-compat",
+ "zeroize",
]
[[package]]
-name = "tokio-macros"
-version = "2.6.1"
+name = "tor-protover"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+checksum = "3fb1b457a24d4e6f4ab1981f33a55c7c525c2edcad855cbc4bdc544430914fc1"
dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "caret",
+ "paste",
+ "serde_with",
+ "thiserror 2.0.18",
+ "tor-basic-utils",
+ "tor-bytes",
]
[[package]]
-name = "tokio-rustls"
-version = "0.26.4"
+name = "tor-relay-crypto"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+checksum = "7675f5d412acbdf62f8771553d3ca0cfad8afdd78f9b1f7c2befc370ce030bda"
dependencies = [
- "rustls",
- "tokio",
+ "derive-deftly",
+ "derive_more",
+ "humantime",
+ "tor-cert",
+ "tor-checkable",
+ "tor-error",
+ "tor-key-forge",
+ "tor-keymgr",
+ "tor-llcrypto",
+ "tor-persist",
+ "web-time-compat",
]
[[package]]
-name = "toml"
-version = "0.5.11"
+name = "tor-relay-selection"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+checksum = "3110742f2a9e071c7f6c9278cd37e0bae0995d7171516fe4efb2de675645667e"
dependencies = [
+ "rand 0.9.2",
"serde",
+ "tor-basic-utils",
+ "tor-linkspec",
+ "tor-netdir",
+ "tor-netdoc",
]
[[package]]
-name = "toml"
-version = "1.1.0+spec-1.1.0"
+name = "tor-rtcompat"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
+checksum = "1714889530e08014a32123bcf947d9564dda61508bc4e7ec5203dcd726985850"
dependencies = [
- "indexmap",
- "serde_core",
- "serde_spanned",
- "toml_datetime",
- "toml_parser",
- "toml_writer",
- "winnow 1.0.1",
+ "async-trait",
+ "async_executors",
+ "asynchronous-codec",
+ "cfg-if",
+ "coarsetime",
+ "derive_more",
+ "dyn-clone",
+ "educe",
+ "futures",
+ "futures-rustls",
+ "hex",
+ "libc",
+ "paste",
+ "pin-project",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "tor-error",
+ "tor-general-addr",
+ "tracing",
+ "void",
+ "web-time-compat",
+ "zeroize",
]
[[package]]
-name = "toml_datetime"
-version = "1.1.0+spec-1.1.0"
+name = "tor-rtmock"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
+checksum = "4cc3bc02ab55161c3bb25b2ae64ea0e7a20b93cd3272f905dcbd8d5882d7a0be"
dependencies = [
- "serde_core",
+ "amplify",
+ "assert_matches",
+ "async-trait",
+ "derive-deftly",
+ "derive_more",
+ "educe",
+ "futures",
+ "humantime",
+ "itertools 0.14.0",
+ "oneshot-fused-workaround",
+ "pin-project",
+ "priority-queue",
+ "slotmap-careful",
+ "strum 0.28.0",
+ "thiserror 2.0.18",
+ "tor-error",
+ "tor-general-addr",
+ "tor-rtcompat",
+ "tracing",
+ "tracing-test",
+ "void",
+ "web-time-compat",
]
[[package]]
-name = "toml_parser"
-version = "1.1.0+spec-1.1.0"
+name = "tor-socksproto"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
+checksum = "36d84b4d4a8680dc865c7a7c44ea5701be049b88276b818f7cd6529af28c2115"
dependencies = [
- "winnow 1.0.1",
+ "amplify",
+ "caret",
+ "derive-deftly",
+ "educe",
+ "safelog",
+ "subtle",
+ "thiserror 2.0.18",
+ "tor-bytes",
+ "tor-error",
]
[[package]]
-name = "toml_writer"
-version = "1.1.0+spec-1.1.0"
+name = "tor-units"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
+checksum = "2ae5c12f3bba5c4707f3a0a9c508320d6a4827e01e5e7ca0824ae55cbd2a737a"
+dependencies = [
+ "derive-deftly",
+ "derive_more",
+ "serde",
+ "thiserror 2.0.18",
+ "tor-memquota",
+]
[[package]]
name = "tower"
@@ -4284,7 +7424,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"bytes",
"futures-util",
"http",
@@ -4319,6 +7459,19 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror 2.0.18",
+ "time",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -4327,7 +7480,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4379,6 +7532,27 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "tracing-test"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051"
+dependencies = [
+ "tracing-core",
+ "tracing-subscriber",
+ "tracing-test-macro",
+]
+
+[[package]]
+name = "tracing-test-macro"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4391,12 +7565,31 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
+[[package]]
+name = "typed-index-collections"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd"
+dependencies = [
+ "bincode 2.0.1",
+ "serde",
+]
+
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+[[package]]
+name = "uncased"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
+dependencies = [
+ "version_check",
+]
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4469,7 +7662,7 @@ dependencies = [
"glob",
"goblin",
"heck",
- "indexmap",
+ "indexmap 2.13.0",
"once_cell",
"serde",
"tempfile",
@@ -4494,7 +7687,7 @@ dependencies = [
"glob",
"goblin",
"heck",
- "indexmap",
+ "indexmap 2.13.0",
"once_cell",
"serde",
"tempfile",
@@ -4566,10 +7759,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933"
dependencies = [
"anyhow",
- "indexmap",
+ "indexmap 2.13.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4578,10 +7771,10 @@ version = "0.31.0"
source = "git+https://github.com/mozilla/uniffi-rs?branch=main#fc3794f42f001c2728bf80ec55ae9b7552298c14"
dependencies = [
"anyhow",
- "indexmap",
+ "indexmap 2.13.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -4596,7 +7789,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn",
+ "syn 2.0.117",
"toml 0.5.11",
"uniffi_meta 0.29.5",
]
@@ -4612,7 +7805,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn",
+ "syn 2.0.117",
"toml 1.1.0+spec-1.1.0",
"uniffi_meta 0.31.0",
]
@@ -4648,7 +7841,7 @@ checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a"
dependencies = [
"anyhow",
"heck",
- "indexmap",
+ "indexmap 2.13.0",
"tempfile",
"uniffi_internal_macros 0.29.5",
]
@@ -4660,7 +7853,7 @@ source = "git+https://github.com/mozilla/uniffi-rs?branch=main#fc3794f42f001c272
dependencies = [
"anyhow",
"heck",
- "indexmap",
+ "indexmap 2.13.0",
"tempfile",
"uniffi_internal_macros 0.31.0",
]
@@ -4716,6 +7909,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "unty"
+version = "0.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -4770,6 +7969,23 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "visibility"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4813,6 +8029,15 @@ dependencies = [
"wit-bindgen",
]
+[[package]]
+name = "wasix"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332"
+dependencies = [
+ "wasi",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.115"
@@ -4855,7 +8080,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -4885,7 +8110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
- "indexmap",
+ "indexmap 2.13.0",
"wasm-encoder",
"wasmparser",
]
@@ -4896,12 +8121,18 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "bitflags",
+ "bitflags 2.11.0",
"hashbrown 0.15.5",
- "indexmap",
+ "indexmap 2.13.0",
"semver",
]
+[[package]]
+name = "weak-table"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549"
+
[[package]]
name = "web-sys"
version = "0.3.92"
@@ -4922,6 +8153,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time-compat"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59"
+dependencies = [
+ "web-time",
+]
+
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
@@ -5000,12 +8240,107 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -5099,6 +8434,15 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -5283,9 +8627,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
- "indexmap",
+ "indexmap 2.13.0",
"prettyplease",
- "syn",
+ "syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -5301,7 +8645,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -5313,8 +8657,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
- "bitflags",
- "indexmap",
+ "bitflags 2.11.0",
+ "indexmap 2.13.0",
"log",
"serde",
"serde_derive",
@@ -5333,7 +8677,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
- "indexmap",
+ "indexmap 2.13.0",
"log",
"semver",
"serde",
@@ -5358,6 +8702,18 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.6.4",
+ "serde",
+ "zeroize",
+]
+
[[package]]
name = "xshell"
version = "0.2.7"
@@ -5425,7 +8781,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -5446,7 +8802,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -5466,7 +8822,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -5487,7 +8843,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -5507,6 +8863,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -5520,7 +8877,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -5535,6 +8892,34 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "zune-core"
version = "0.5.1"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 9ea232e6b..391e081aa 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -92,7 +92,7 @@ eyre = "0.6"
# database
redb = "2.6"
-rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] }
+rusqlite = { version = "0.36", features = ["bundled-sqlcipher-vendored-openssl"] }
# ids
nid = "3.0"
@@ -104,6 +104,13 @@ dirs = "6.0"
derive_more = { version = "2.0" }
strum = { version = "0.28", features = ["derive"] }
+# built-in tor runtime
+arti = { version = "2.2.0", default-features = false, features = ["tokio", "rustls", "compression", "dns-proxy", "experimental-api", "onion-service-client"] }
+arti-client = { version = "0.41.0", default-features = false, features = ["tokio", "rustls", "compression", "onion-service-client"] }
+tor-config = "0.41.0"
+tor-rtcompat = { version = "0.41.0", default-features = false, features = ["tokio", "rustls"] }
+tor-config-path = "0.41.0"
+
# extensions
tap = "1.0"
@@ -205,6 +212,13 @@ derive_more = { workspace = true, features = [
strum = { workspace = true, features = ["derive"] }
+# built-in tor runtime
+arti = { workspace = true }
+arti-client = { workspace = true }
+tor-config = { workspace = true }
+tor-rtcompat = { workspace = true }
+tor-config-path = { workspace = true }
+
# helpers / utils / exts
tap = "1.0.1"
itertools = "0.14"
@@ -309,3 +323,7 @@ opt-level = 3 # Optimize for speed
[profile.release-smaller]
inherits = "release-base"
opt-level = 'z' # Optimize for size.
+
+[patch.crates-io]
+bdk_chain = { path = "external/bdk/crates/chain" }
+bdk_core = { path = "external/bdk/crates/core" }
diff --git a/rust/crates/cove-common/src/logging.rs b/rust/crates/cove-common/src/logging.rs
index c70d0da06..6d4d5e8bb 100644
--- a/rust/crates/cove-common/src/logging.rs
+++ b/rust/crates/cove-common/src/logging.rs
@@ -1,20 +1,138 @@
-use std::sync::Once;
+use std::{
+ collections::VecDeque,
+ sync::{Mutex, Once},
+};
-use tracing_subscriber::EnvFilter;
+use once_cell::sync::Lazy;
+use tracing::{Event, Level, Subscriber, field::Visit};
+use tracing_subscriber::{EnvFilter, Layer, layer::Context, registry::LookupSpan};
static INIT: Once = Once::new();
+static TOR_LOG_BUFFER: Lazy>> = Lazy::new(|| Mutex::new(VecDeque::new()));
+const MAX_TOR_LOG_LINES: usize = 400;
+
+#[derive(Default)]
+struct TorEventVisitor {
+ message: Option,
+ fields: Vec,
+}
+
+impl Visit for TorEventVisitor {
+ fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
+ let value = format!("{value:?}");
+ if field.name() == "message" {
+ self.message = Some(value.trim_matches('"').to_string());
+ } else {
+ self.fields.push(format!("{}={value}", field.name()));
+ }
+ }
+}
+
+struct TorLogLayer;
+
+impl Layer for TorLogLayer
+where
+ S: Subscriber + for<'a> LookupSpan<'a>,
+{
+ fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
+ let metadata = event.metadata();
+ if !should_capture_tor_event(metadata.level(), metadata.target()) {
+ return;
+ }
+
+ let mut visitor = TorEventVisitor::default();
+ event.record(&mut visitor);
+
+ let message = visitor.message.unwrap_or_default();
+ let fields = if visitor.fields.is_empty() {
+ String::new()
+ } else {
+ format!(" {}", visitor.fields.join(" "))
+ };
+ let line = format!("[{} {}] {}{}", metadata.level(), metadata.target(), message, fields);
+
+ if !is_tor_related(metadata.target(), &line) {
+ return;
+ }
+
+ push_tor_log(line);
+ }
+}
+
+fn should_capture_tor_event(level: &Level, target: &str) -> bool {
+ match *level {
+ Level::ERROR | Level::WARN | Level::INFO => true,
+ Level::DEBUG => target == "arti_client::status" || target.contains("tor_runtime"),
+ Level::TRACE => false,
+ }
+}
+
+fn is_tor_related(target: &str, line: &str) -> bool {
+ if target.starts_with("arti") || target.starts_with("tor_") || target.contains("tor_runtime") {
+ return true;
+ }
+
+ let lowercase = line.to_ascii_lowercase();
+ let has_tor_token = lowercase
+ .split(|ch: char| !ch.is_ascii_alphanumeric())
+ .any(|token| token == "tor" || token == "socks5" || token == "socks5h");
+
+ has_tor_token
+ || lowercase.contains("onion")
+ || lowercase.contains("tor:")
+ || lowercase.contains("tor=")
+}
+
+fn push_tor_log(line: String) {
+ let mut buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned");
+ buffer.push_back(line);
+ while buffer.len() > MAX_TOR_LOG_LINES {
+ let _ = buffer.pop_front();
+ }
+}
+
+pub fn tor_connection_logs() -> Vec {
+ let buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned");
+ buffer.iter().cloned().collect()
+}
+
+pub fn clear_tor_connection_logs() {
+ let mut buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned");
+ buffer.clear();
+}
pub fn init() {
+ init_with_default_filter(default_log_filter());
+}
+
+pub fn init_with_default_filter(default_filter: &str) {
INIT.call_once(|| {
use tracing_subscriber::{fmt, prelude::*};
- if std::env::var("RUST_LOG").is_err() {
- unsafe { std::env::set_var("RUST_LOG", "cove=debug,info") }
- }
+ #[cfg(target_os = "android")]
+ let fmt_layer = fmt::layer().with_writer(std::io::stderr).with_ansi(false);
+
+ #[cfg(not(target_os = "android"))]
+ let fmt_layer = fmt::layer().with_writer(std::io::stdout).with_ansi(false);
+
+ let fmt_filter =
+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter));
tracing_subscriber::registry()
- .with(fmt::layer().with_writer(std::io::stdout).with_ansi(false))
- .with(EnvFilter::from_default_env())
+ .with(fmt_layer.with_filter(fmt_filter))
+ .with(TorLogLayer)
.init();
});
}
+
+fn default_log_filter() -> &'static str {
+ #[cfg(debug_assertions)]
+ {
+ "cove=debug,info"
+ }
+
+ #[cfg(not(debug_assertions))]
+ {
+ "cove=info,info"
+ }
+}
diff --git a/rust/external/bdk b/rust/external/bdk
new file mode 160000
index 000000000..d9aa92b35
--- /dev/null
+++ b/rust/external/bdk
@@ -0,0 +1 @@
+Subproject commit d9aa92b3511d95f6075616d30c146aa5df814495
diff --git a/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch b/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch
new file mode 100644
index 000000000..d755d0b63
--- /dev/null
+++ b/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch
@@ -0,0 +1,155 @@
+diff --git a/crates/chain/src/rusqlite_impl.rs b/crates/chain/src/rusqlite_impl.rs
+index 7e9e2f6f..d9f6a023 100644
+--- a/crates/chain/src/rusqlite_impl.rs
++++ b/crates/chain/src/rusqlite_impl.rs
+@@ -10,6 +10,9 @@ use alloc::{
+ sync::Arc,
+ vec::Vec,
+ };
++use core::convert::TryFrom;
++use std::io;
++
+ use bitcoin::consensus::{Decodable, Encodable};
+ use rusqlite;
+ use rusqlite::named_params;
+@@ -302,9 +305,9 @@ impl tx_graph::ChangeSet {
+ Ok((
+ row.get::<_, Impl>("txid")?,
+ row.get::<_, Option>>("raw_tx")?,
+- row.get::<_, Option>("first_seen")?,
+- row.get::<_, Option>("last_seen")?,
+- row.get::<_, Option>("last_evicted")?,
++ row.get::<_, Option>("first_seen")?,
++ row.get::<_, Option>("last_seen")?,
++ row.get::<_, Option>("last_evicted")?,
+ ))
+ })?;
+ for row in row_iter {
+@@ -313,12 +316,42 @@ impl tx_graph::ChangeSet {
+ changeset.txs.insert(Arc::new(tx));
+ }
+ if let Some(first_seen) = first_seen {
++ let first_seen = u64::try_from(first_seen).map_err(|_| {
++ rusqlite::Error::FromSqlConversionFailure(
++ 0,
++ rusqlite::types::Type::Integer,
++ Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "first_seen must be non-negative",
++ )),
++ )
++ })?;
+ changeset.first_seen.insert(txid, first_seen);
+ }
+ if let Some(last_seen) = last_seen {
++ let last_seen = u64::try_from(last_seen).map_err(|_| {
++ rusqlite::Error::FromSqlConversionFailure(
++ 0,
++ rusqlite::types::Type::Integer,
++ Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "last_seen must be non-negative",
++ )),
++ )
++ })?;
+ changeset.last_seen.insert(txid, last_seen);
+ }
+ if let Some(last_evicted) = last_evicted {
++ let last_evicted = u64::try_from(last_evicted).map_err(|_| {
++ rusqlite::Error::FromSqlConversionFailure(
++ 0,
++ rusqlite::types::Type::Integer,
++ Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "last_evicted must be non-negative",
++ )),
++ )
++ })?;
+ changeset.last_evicted.insert(txid, last_evicted);
+ }
+ }
+@@ -354,12 +387,22 @@ impl tx_graph::ChangeSet {
+ Ok((
+ row.get::<_, Impl>("block_hash")?,
+ row.get::<_, u32>("block_height")?,
+- row.get::<_, u64>("confirmation_time")?,
++ row.get::<_, i64>("confirmation_time")?,
+ row.get::<_, Impl>("txid")?,
+ ))
+ })?;
+ for row in row_iter {
+ let (hash, height, confirmation_time, Impl(txid)) = row?;
++ let confirmation_time = u64::try_from(confirmation_time).map_err(|_| {
++ rusqlite::Error::FromSqlConversionFailure(
++ 0,
++ rusqlite::types::Type::Integer,
++ Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "confirmation_time must be non-negative",
++ )),
++ )
++ })?;
+ changeset.anchors.insert((
+ ConfirmationBlockTime {
+ block_id: BlockId::from((&height, &hash.0)),
+@@ -392,7 +435,12 @@ impl tx_graph::ChangeSet {
+ Self::TXS_TABLE_NAME,
+ ))?;
+ for (&txid, &first_seen) in &self.first_seen {
+- let checked_time = first_seen.to_sql()?;
++ let checked_time = i64::try_from(first_seen).map_err(|_| {
++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "first_seen is too large for sqlite INTEGER",
++ )))
++ })?;
+ statement.execute(named_params! {
+ ":txid": Impl(txid),
+ ":first_seen": Some(checked_time),
+@@ -405,7 +453,12 @@ impl tx_graph::ChangeSet {
+ Self::TXS_TABLE_NAME,
+ ))?;
+ for (&txid, &last_seen) in &self.last_seen {
+- let checked_time = last_seen.to_sql()?;
++ let checked_time = i64::try_from(last_seen).map_err(|_| {
++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "last_seen is too large for sqlite INTEGER",
++ )))
++ })?;
+ statement.execute(named_params! {
+ ":txid": Impl(txid),
+ ":last_seen": Some(checked_time),
+@@ -418,7 +471,12 @@ impl tx_graph::ChangeSet {
+ Self::TXS_TABLE_NAME,
+ ))?;
+ for (&txid, &last_evicted) in &self.last_evicted {
+- let checked_time = last_evicted.to_sql()?;
++ let checked_time = i64::try_from(last_evicted).map_err(|_| {
++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "last_evicted is too large for sqlite INTEGER",
++ )))
++ })?;
+ statement.execute(named_params! {
+ ":txid": Impl(txid),
+ ":last_evicted": Some(checked_time),
+@@ -451,11 +509,17 @@ impl tx_graph::ChangeSet {
+ statement_txid.execute(named_params! {
+ ":txid": Impl(*txid)
+ })?;
++ let checked_confirmation_time = i64::try_from(anchor.confirmation_time).map_err(|_| {
++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new(
++ io::ErrorKind::InvalidData,
++ "confirmation_time is too large for sqlite INTEGER",
++ )))
++ })?;
+ statement.execute(named_params! {
+ ":txid": Impl(*txid),
+ ":block_height": anchor_block.height,
+ ":block_hash": Impl(anchor_block.hash),
+- ":confirmation_time": anchor.confirmation_time,
++ ":confirmation_time": checked_confirmation_time,
+ })?;
+ }
+
diff --git a/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch b/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch
new file mode 100644
index 000000000..e563586ba
--- /dev/null
+++ b/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch
@@ -0,0 +1,56 @@
+diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml
+index 62d0f2b3..aebcc029 100644
+--- a/crates/chain/Cargo.toml
++++ b/crates/chain/Cargo.toml
+@@ -12,9 +12,6 @@ readme = "README.md"
+
+ # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+-[lints]
+-workspace = true
+-
+ [dependencies]
+ bitcoin = { version = "0.32.0", default-features = false }
+ bdk_core = { path = "../core", version = "0.6.3", default-features = false }
+diff --git a/crates/electrum/Cargo.toml b/crates/electrum/Cargo.toml
+index 8fdd7823..0761ebfa 100644
+--- a/crates/electrum/Cargo.toml
++++ b/crates/electrum/Cargo.toml
+@@ -9,9 +9,6 @@ description = "Fetch data from electrum in the form BDK accepts"
+ license = "MIT OR Apache-2.0"
+ readme = "README.md"
+
+-[lints]
+-workspace = true
+-
+ [dependencies]
+ bdk_core = { path = "../core", version = "0.6.1" }
+ electrum-client = { version = "0.24.0", features = [ "proxy" ], default-features = false }
+diff --git a/crates/esplora/Cargo.toml b/crates/esplora/Cargo.toml
+index 1e5906d0..0d99e95e 100644
+--- a/crates/esplora/Cargo.toml
++++ b/crates/esplora/Cargo.toml
+@@ -11,9 +11,6 @@ readme = "README.md"
+
+ # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+-[lints]
+-workspace = true
+-
+ [dependencies]
+ bdk_core = { path = "../core", version = "0.6.3", default-features = false }
+ esplora-client = { version = "0.12.3", default-features = false }
+diff --git a/crates/file_store/Cargo.toml b/crates/file_store/Cargo.toml
+index 714c40e1..f1eb32a8 100644
+--- a/crates/file_store/Cargo.toml
++++ b/crates/file_store/Cargo.toml
+@@ -10,9 +10,6 @@ keywords = ["bitcoin", "persist", "persistence", "bdk", "file"]
+ authors = ["Bitcoin Dev Kit Developers"]
+ readme = "README.md"
+
+-[lints]
+-workspace = true
+-
+ [dependencies]
+ bdk_core = { path = "../core", version = "0.6.1", features = ["serde"]}
+ bincode = { version = "1" }
diff --git a/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch b/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch
new file mode 100644
index 000000000..d033e63a7
--- /dev/null
+++ b/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch
@@ -0,0 +1,13 @@
+diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml
+index aebcc029..f50eec59 100644
+--- a/crates/chain/Cargo.toml
++++ b/crates/chain/Cargo.toml
+@@ -19,7 +19,7 @@ serde = { version = "1", optional = true, features = ["derive", "rc"] }
+ miniscript = { version = "12.3.1", optional = true, default-features = false }
+
+ # Feature dependencies
+-rusqlite = { version = "0.31.0", features = ["bundled"], optional = true }
++rusqlite = { version = "0.36.0", features = ["bundled"], optional = true }
+
+ [dev-dependencies]
+ rand = "0.8"
diff --git a/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch b/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch
new file mode 100644
index 000000000..942f251de
--- /dev/null
+++ b/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch
@@ -0,0 +1,12 @@
+diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs
+index 0cb5b48d..a79bcf5b 100644
+--- a/crates/chain/src/lib.rs
++++ b/crates/chain/src/lib.rs
+@@ -25,6 +25,7 @@
+ )]
+ #![no_std]
+ #![warn(missing_docs)]
++#![allow(unexpected_cfgs)]
+ #![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+
+ pub use bitcoin;
diff --git a/rust/src/database/global_config.rs b/rust/src/database/global_config.rs
index a30c53dea..bfc99abeb 100644
--- a/rust/src/database/global_config.rs
+++ b/rust/src/database/global_config.rs
@@ -10,7 +10,7 @@ use crate::{
color_scheme::ColorSchemeSelection,
fiat::FiatCurrency,
network::Network,
- node::Node,
+ node::{Node, TorMode},
wallet::metadata::{WalletId, WalletMode},
};
@@ -27,6 +27,10 @@ pub enum GlobalConfigKey {
SelectedNetwork,
SelectedFiatCurrency,
SelectedNode(Network),
+ UseTor,
+ TorMode,
+ TorExternalHost,
+ TorExternalPort,
ColorScheme,
AuthType,
HashedPinCode,
@@ -49,6 +53,10 @@ impl From for &'static str {
GlobalConfigKey::SelectedNode(Network::Testnet) => "selected_node_testnet",
GlobalConfigKey::SelectedNode(Network::Testnet4) => "selected_node_testnet4",
GlobalConfigKey::SelectedNode(Network::Signet) => "selected_node_signet",
+ GlobalConfigKey::UseTor => "use_tor",
+ GlobalConfigKey::TorMode => "tor_mode",
+ GlobalConfigKey::TorExternalHost => "tor_external_host",
+ GlobalConfigKey::TorExternalPort => "tor_external_port",
GlobalConfigKey::ColorScheme => "color_scheme",
GlobalConfigKey::AuthType => "auth_type",
GlobalConfigKey::HashedPinCode => "hashed_pin_code",
@@ -88,6 +96,9 @@ pub enum GlobalConfigTableError {
#[error("pin code must be hashed before saving")]
PinCodeMustBeHashed,
+
+ #[error("failed to stop built-in tor proxy before publishing config update: {0}")]
+ BuiltInTorStop(String),
}
impl GlobalConfigTable {
@@ -111,6 +122,9 @@ impl GlobalConfigTable {
Update::FiatCurrencyChanged
);
+ string_config_accessor!(pub tor_mode, GlobalConfigKey::TorMode, TorMode);
+ string_config_accessor!(pub tor_external_host, GlobalConfigKey::TorExternalHost, String);
+
string_config_accessor!(pub wipe_data_pin, GlobalConfigKey::WipeDataPin, String);
string_config_accessor!(pub decoy_pin, GlobalConfigKey::DecoyPin, String);
string_config_accessor!(priv_hashed_pin_code, GlobalConfigKey::HashedPinCode, String);
@@ -247,6 +261,26 @@ impl GlobalConfigTable {
serde_json::from_str(&node_json).unwrap_or_else(|_| Node::default(network))
}
+ pub fn use_tor(&self) -> bool {
+ self.get(GlobalConfigKey::UseTor).unwrap_or(None).unwrap_or_else(|| "false".to_string())
+ == "true"
+ }
+
+ pub fn set_use_tor(&self, use_tor: bool) -> Result<()> {
+ self.set(GlobalConfigKey::UseTor, use_tor.to_string())
+ }
+
+ pub fn tor_external_port(&self) -> u16 {
+ self.get(GlobalConfigKey::TorExternalPort)
+ .unwrap_or(None)
+ .and_then(|port| port.parse::().ok())
+ .unwrap_or(9050)
+ }
+
+ pub fn set_tor_external_port(&self, port: u16) -> Result<()> {
+ self.set(GlobalConfigKey::TorExternalPort, port.to_string())
+ }
+
pub fn set_selected_node(&self, node: &Node) -> Result<()> {
let network = node.network;
let node_json = serde_json::to_string(node)
@@ -317,6 +351,12 @@ impl GlobalConfigTable {
}
pub(crate) fn set(&self, key: GlobalConfigKey, value: String) -> Result<()> {
+ let should_stop_built_in_tor = match key {
+ GlobalConfigKey::TorMode => !matches!(value.parse::(), Ok(TorMode::BuiltIn)),
+ GlobalConfigKey::UseTor => value.eq_ignore_ascii_case("false"),
+ _ => false,
+ };
+
let write_txn =
self.db.begin_write().map_err(|error| Error::DatabaseAccess(error.to_string()))?;
@@ -331,6 +371,13 @@ impl GlobalConfigTable {
.map_err(|error| GlobalConfigTableError::Save(error.to_string()))?;
}
+ if should_stop_built_in_tor && !crate::tor_runtime::request_stop_built_in_proxy() {
+ return Err(GlobalConfigTableError::BuiltInTorStop(
+ "timed out waiting for proxy shutdown".to_string(),
+ )
+ .into());
+ }
+
write_txn.commit().map_err(|error| Error::DatabaseAccess(error.to_string()))?;
Updater::send_update(Update::DatabaseUpdated);
diff --git a/rust/src/database/global_flag.rs b/rust/src/database/global_flag.rs
index caf5bce28..673ec4a92 100644
--- a/rust/src/database/global_flag.rs
+++ b/rust/src/database/global_flag.rs
@@ -17,6 +17,7 @@ pub enum GlobalFlagKey {
AcceptedTerms,
BetaFeaturesEnabled,
BetaImportExportEnabled,
+ TorSettingsDiscovered,
}
#[derive(Debug, Clone, uniffi::Object)]
diff --git a/rust/src/database/macros.rs b/rust/src/database/macros.rs
index de434f582..f89728585 100644
--- a/rust/src/database/macros.rs
+++ b/rust/src/database/macros.rs
@@ -19,6 +19,7 @@ macro_rules! string_config_accessor {
}
paste::paste! {
+ #[allow(dead_code)]
$vis fn [](&self, value: $return_type) -> Result<(), Error> {
let value_str = value.to_string();
self.set($key, value_str)?;
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index aac6c57bc..9febb5653 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -62,6 +62,7 @@ mod seed_qr;
mod send_flow;
mod signed_import;
mod tap_card;
+mod tor_runtime;
mod transaction;
mod transaction_watcher;
mod ur;
@@ -97,6 +98,22 @@ pub enum InitError {
RootDataDirAlreadySet(String),
}
+#[derive(Debug, Clone, uniffi::Error, thiserror::Error)]
+pub enum TorBootstrapError {
+ #[error("failed to initialize built-in tor proxy: {0}")]
+ BuiltInTor(String),
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct BuiltInTorBootstrapStatus {
+ pub percent: u32,
+ pub ready: bool,
+ pub blocked: Option,
+ pub message: String,
+ pub launched: bool,
+ pub last_error: Option,
+}
+
/// set root data directory before any database access
/// required for Android to specify app-specific storage path
#[uniffi::export]
@@ -104,3 +121,26 @@ fn set_root_data_dir(path: String) -> Result<(), InitError> {
cove_common::consts::set_root_data_dir(PathBuf::from(path))
.map_err(InitError::RootDataDirAlreadySet)
}
+
+#[uniffi::export(async_runtime = "tokio")]
+async fn ensure_built_in_tor_bootstrap() -> Result {
+ let endpoint = tor_runtime::built_in_socks_endpoint().await.map_err(|error| match error {
+ tor_runtime::Error::Proxy(message) => TorBootstrapError::BuiltInTor(message),
+ })?;
+ Ok(endpoint.to_string())
+}
+
+#[uniffi::export]
+fn tor_connection_logs() -> Vec {
+ logging::tor_connection_logs()
+}
+
+#[uniffi::export]
+fn built_in_tor_bootstrap_status() -> BuiltInTorBootstrapStatus {
+ tor_runtime::built_in_bootstrap_status()
+}
+
+#[uniffi::export]
+fn clear_tor_connection_logs() {
+ logging::clear_tor_connection_logs();
+}
diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs
index b74edf402..74202751e 100644
--- a/rust/src/manager/wallet_manager/actor.rs
+++ b/rust/src/manager/wallet_manager/actor.rs
@@ -1,13 +1,9 @@
use crate::{
- database::{Database, wallet_data::WalletDataDb},
+ database::{Database, global_config::GlobalConfigKey, wallet_data::WalletDataDb},
historical_price_service::HistoricalPriceService,
manager::wallet_manager::{Error, SendFlowErrorAlert, WalletManagerError},
mnemonic,
- node::{
- Node,
- client::{NodeClient, NodeClientOptions},
- client_builder::NodeClientBuilder,
- },
+ node::{Node, client::NodeClient, client_builder::NodeClientBuilder},
transaction::{ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TxId},
transaction_watcher::TransactionWatcher,
wallet::{
@@ -65,6 +61,7 @@ pub struct WalletActor {
pub reconciler: Sender,
pub wallet: Wallet,
pub node_client: Option,
+ pub node_client_signature: Option,
pub db: WalletDataDb,
pub state: ActorState,
@@ -181,6 +178,7 @@ impl WalletActor {
seed,
wallet,
node_client: None,
+ node_client_signature: None,
last_scan_finished: None,
last_height_fetched: None,
state: ActorState::Initial,
@@ -911,8 +909,7 @@ impl WalletActor {
let network = self.wallet.network;
let node = Database::global().global_config.selected_node();
- let options = NodeClientOptions { batch_size: 1 };
- let client_builder = NodeClientBuilder { node, options };
+ let client_builder = NodeClientBuilder::with_defaults(node, 1);
let watcher = TransactionWatcher::new(self.addr.clone(), tx_id, client_builder, network);
let addr = spawn_actor(watcher);
@@ -1474,18 +1471,38 @@ impl WalletActor {
Some(())
}
+ fn node_client_signature_for(node: &Node) -> String {
+ let global_config = Database::global().global_config.clone();
+ let use_tor = global_config.use_tor();
+ let tor_mode =
+ global_config.get(GlobalConfigKey::TorMode).ok().flatten().unwrap_or_default();
+ let tor_external_host =
+ global_config.get(GlobalConfigKey::TorExternalHost).ok().flatten().unwrap_or_default();
+ let tor_external_port = global_config.tor_external_port();
+
+ format!(
+ "node={node:?}|use_tor={use_tor}|tor_mode={tor_mode}|tor_external_host={tor_external_host}|tor_external_port={tor_external_port}"
+ )
+ }
+
async fn node_client(&mut self) -> Result<&NodeClient, Error> {
- let node_client = self.node_client.as_ref();
- if node_client.is_none() {
- let node = Database::global().global_config.selected_node();
- let node_client = NodeClient::new(&node).await.map_err(|err| {
+ let selected_node = Database::global().global_config.selected_node();
+ let selected_signature = Self::node_client_signature_for(&selected_node);
+
+ let reuse_cached = self.node_client_signature.as_ref() == Some(&selected_signature);
+ if !reuse_cached {
+ self.node_client = None;
+ self.node_client_signature = None;
+
+ let node_client = NodeClient::new(&selected_node).await.map_err(|err| {
Error::NodeConnectionFailed(format!("failed to create node client: {err}"))
})?;
self.node_client = Some(node_client);
+ self.node_client_signature = Some(selected_signature);
}
- Ok(self.node_client.as_ref().expect("just checked"))
+ Ok(self.node_client.as_ref().expect("node client initialized"))
}
}
diff --git a/rust/src/node.rs b/rust/src/node.rs
index 1902ebc57..7d8e8c998 100644
--- a/rust/src/node.rs
+++ b/rust/src/node.rs
@@ -27,6 +27,30 @@ pub enum ApiType {
Rpc,
}
+#[derive(
+ Debug,
+ Copy,
+ Clone,
+ Hash,
+ Eq,
+ PartialEq,
+ Default,
+ derive_more::Display,
+ strum::EnumString,
+ uniffi::Enum,
+ serde::Serialize,
+ serde::Deserialize,
+)]
+pub enum TorMode {
+ #[strum(serialize = "BuiltIn", serialize = "BUILT_IN")]
+ #[default]
+ BuiltIn,
+ #[strum(serialize = "Orbot", serialize = "ORBOT")]
+ Orbot,
+ #[strum(serialize = "External", serialize = "EXTERNAL")]
+ External,
+}
+
#[derive(
Debug, Clone, Hash, Eq, PartialEq, uniffi::Record, serde::Serialize, serde::Deserialize,
)]
diff --git a/rust/src/node/client.rs b/rust/src/node/client.rs
index b14c1bad8..d3a252ad9 100644
--- a/rust/src/node/client.rs
+++ b/rust/src/node/client.rs
@@ -18,9 +18,14 @@ use bdk_wallet::{
},
};
use bitcoin::{Transaction, Txid};
-use tracing::debug;
+use cove_util::ResultExt as _;
+use tracing::{debug, info, warn};
-use crate::node::Node;
+use crate::{
+ database::Database,
+ node::{Node, TorMode},
+ tor_runtime,
+};
use super::{ApiType, client_builder::NodeClientBuilder};
@@ -79,48 +84,127 @@ pub enum Error {
#[error("failed to get transaction: {0}")]
ElectrumGetTransaction(electrum_client::Error),
+
+ #[error("failed to resolve tor endpoint: {0}")]
+ ResolveTorEndpoint(String),
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NodeClientOptions {
pub batch_size: usize,
+ pub use_tor: bool,
+ pub tor_mode: TorMode,
+ pub tor_external_host: String,
+ pub tor_external_port: u16,
}
-impl NodeClient {
- pub async fn new(node: &Node) -> Result {
- match node.api_type {
- ApiType::Esplora => {
- let client = esplora::EsploraClient::new_from_node(node)?;
- Ok(Self::Esplora(client))
- }
+impl Default for NodeClientOptions {
+ fn default() -> Self {
+ Self {
+ batch_size: ESPLORA_BATCH_SIZE,
+ use_tor: false,
+ tor_mode: TorMode::BuiltIn,
+ tor_external_host: "127.0.0.1".to_string(),
+ tor_external_port: 9050,
+ }
+ }
+}
- ApiType::Electrum => {
- let client = electrum::ElectrumClient::new_from_node(node).await?;
- Ok(Self::Electrum(client))
- }
+impl NodeClientOptions {
+ pub async fn resolve_tor_endpoint(mut self) -> Result {
+ info!(
+ use_tor = self.use_tor,
+ tor_mode = ?self.tor_mode,
+ tor_external_host = %self.tor_external_host,
+ tor_external_port = self.tor_external_port,
+ "resolving tor endpoint"
+ );
+
+ if !self.use_tor {
+ info!("tor disabled; skipping tor endpoint resolution");
+ return Ok(self);
+ }
- ApiType::Rpc => {
- // TODO: implement rpc check, with auth
- todo!()
+ match self.tor_mode {
+ TorMode::External | TorMode::Orbot => {
+ if self.tor_external_host.is_empty() {
+ warn!("tor external host empty; defaulting to 127.0.0.1");
+ self.tor_external_host = "127.0.0.1".to_string();
+ }
+ if self.tor_external_port == 0 {
+ warn!("tor external port missing; defaulting to 9050");
+ self.tor_external_port = 9050;
+ }
+ info!(
+ tor_mode = ?self.tor_mode,
+ tor_external_host = %self.tor_external_host,
+ tor_external_port = self.tor_external_port,
+ "using configured tor endpoint"
+ );
+ }
+ TorMode::BuiltIn => {
+ info!("tor mode is built-in; requesting Arti socks endpoint");
+ let endpoint = tor_runtime::built_in_socks_endpoint()
+ .await
+ .map_err_str(Error::ResolveTorEndpoint)?;
+ self.tor_external_host = endpoint.ip().to_string();
+ self.tor_external_port = endpoint.port();
+ info!(%endpoint, "resolved built-in tor socks endpoint");
}
}
+
+ Ok(self)
+ }
+}
+
+impl NodeClient {
+ pub async fn new(node: &Node) -> Result {
+ let db = Database::global();
+ let config = db.global_config();
+ let tor_external_host = config
+ .tor_external_host()
+ .ok()
+ .filter(|host| !host.is_empty())
+ .unwrap_or_else(|| "127.0.0.1".to_string());
+
+ let batch_size = match node.api_type {
+ ApiType::Electrum => ELECTRUM_BATCH_SIZE,
+ ApiType::Esplora | ApiType::Rpc => ESPLORA_BATCH_SIZE,
+ };
+
+ let options = NodeClientOptions {
+ batch_size,
+ use_tor: config.use_tor(),
+ tor_mode: config.tor_mode().unwrap_or_default(),
+ tor_external_host,
+ tor_external_port: config.tor_external_port(),
+ };
+
+ info!(node = %node.url, api_type = ?node.api_type, options = ?options, "creating node client with db-backed options");
+
+ Self::new_with_options(node, options).await
}
pub async fn try_from_builder(builder: &NodeClientBuilder) -> Result {
- let node_client = Self::new_with_options(&builder.node, builder.options).await?;
+ let node_client = Self::new_with_options(&builder.node, builder.options.clone()).await?;
Ok(node_client)
}
pub async fn new_with_options(node: &Node, options: NodeClientOptions) -> Result {
+ info!(node = %node.url, api_type = ?node.api_type, options = ?options, "creating node client with explicit options");
+ let options = options.resolve_tor_endpoint().await?;
+
+ info!(node = %node.url, api_type = ?node.api_type, resolved_options = ?options, "node client options resolved");
+
match node.api_type {
ApiType::Esplora => {
- let client = esplora::EsploraClient::new_from_node_and_options(node, options)?;
+ let client = esplora::EsploraClient::new_from_node_and_options(node, &options)?;
Ok(Self::Esplora(client))
}
ApiType::Electrum => {
let client =
- electrum::ElectrumClient::new_from_node_and_options(node, options).await?;
+ electrum::ElectrumClient::new_from_node_and_options(node, &options).await?;
Ok(Self::Electrum(client))
}
diff --git a/rust/src/node/client/electrum.rs b/rust/src/node/client/electrum.rs
index 10abfa4c9..6a7e7cebe 100644
--- a/rust/src/node/client/electrum.rs
+++ b/rust/src/node/client/electrum.rs
@@ -2,7 +2,7 @@ use std::sync::Arc;
use bdk_electrum::{
BdkElectrumClient,
- electrum_client::{self, Client, ElectrumApi as _, Param},
+ electrum_client::{self, Client, Config, ConfigBuilder, ElectrumApi as _, Param, Socks5Config},
};
use bdk_wallet::chain::{
BlockId, ConfirmationBlockTime, TxGraph,
@@ -17,13 +17,17 @@ use bitcoin::{Transaction, Txid, consensus::Decodable};
use serde::Deserialize;
use serde_json::Value;
use tap::TapFallible as _;
+use tokio::time::sleep;
use tracing::{debug, error, warn};
use super::{ELECTRUM_BATCH_SIZE, Error, NodeClientOptions};
-use crate::node::Node;
+use crate::node::{Node, TorMode};
type ElectrumClientInner = BdkElectrumClient;
+const BUILT_IN_TOR_CONNECT_RETRY_DELAYS_MS: [u64; 8] =
+ [500, 1000, 1500, 2000, 3000, 5000, 8000, 12000];
+
#[derive(Debug, Deserialize)]
struct ElectrumTransactionResponse {
hex: String,
@@ -49,24 +53,95 @@ impl ElectrumClient {
}
pub async fn new_from_node(node: &Node) -> Result {
- Self::new_from_node_and_options(node, Self::default_options()).await
+ let options = Self::default_options();
+ Self::new_from_node_and_options(node, &options).await
}
pub async fn new_from_node_and_options(
node: &Node,
- options: NodeClientOptions,
+ options: &NodeClientOptions,
) -> Result {
let url = node.url.strip_suffix('/').unwrap_or(&node.url).to_string();
+ debug!(
+ api_type = "electrum",
+ tor_enabled = options.use_tor,
+ tor_mode = ?options.tor_mode,
+ batch_size = options.batch_size,
+ "creating electrum client from node and options"
+ );
+ let config = Self::connection_config(options);
+ let retry_delays = if options.use_tor && matches!(options.tor_mode, TorMode::BuiltIn) {
+ BUILT_IN_TOR_CONNECT_RETRY_DELAYS_MS.as_slice()
+ } else {
+ &[]
+ };
- // use spawn_blocking for the synchronous TCP connection to avoid blocking the async runtime
- let inner_client = cove_tokio::unblock::run_blocking(move || Client::new(&url))
- .await
- .map_err(Error::CreateElectrumClient)?;
+ let mut attempt: usize = 0;
+ let inner_client = loop {
+ let conn_url = url.clone();
+ let config = config.clone();
+
+ // use spawn_blocking for the synchronous TCP connection to avoid blocking the async runtime
+ match cove_tokio::unblock::run_blocking(move || Client::from_config(&conn_url, config))
+ .await
+ {
+ Ok(client) => break client,
+ Err(error) => {
+ if Self::is_built_in_tor_bootstrap_socks_failure(&error)
+ && let Some(delay_ms) = retry_delays.get(attempt).copied()
+ {
+ warn!(
+ api_type = "electrum",
+ tor_enabled = options.use_tor,
+ tor_mode = ?options.tor_mode,
+ attempt = attempt + 1,
+ max_retries = retry_delays.len(),
+ delay_ms,
+ "electrum connect failed while built-in tor may still be bootstrapping; retrying"
+ );
+ attempt += 1;
+ sleep(std::time::Duration::from_millis(delay_ms)).await;
+ continue;
+ }
+
+ let built_in_tor_status =
+ if options.use_tor && matches!(options.tor_mode, TorMode::BuiltIn) {
+ crate::tor_runtime::built_in_status_summary()
+ } else {
+ "n/a".to_string()
+ };
+
+ error!(
+ api_type = "electrum",
+ tor_enabled = options.use_tor,
+ tor_mode = ?options.tor_mode,
+ attempt = attempt + 1,
+ max_retries = retry_delays.len(),
+ built_in_tor_status = %built_in_tor_status,
+ "failed to create electrum client"
+ );
+
+ if options.use_tor
+ && matches!(options.tor_mode, TorMode::BuiltIn)
+ && Self::is_built_in_tor_bootstrap_socks_failure(&error)
+ {
+ let enriched = format!(
+ "{error}; built-in tor status: {built_in_tor_status}; this often means Tor bootstrap is still incomplete or bootstrap connectivity failed"
+ );
+ return Err(Error::CreateElectrumClient(electrum_client::Error::Message(
+ enriched,
+ )));
+ }
+
+ return Err(Error::CreateElectrumClient(error));
+ }
+ }
+ };
let bdk_client = BdkElectrumClient::new(inner_client);
let client = Arc::new(bdk_client);
- Ok(Self::new_with_options(client, options))
+ Ok(Self::new_with_options(client, options.clone()))
}
pub async fn get_height(&self) -> Result {
@@ -278,8 +353,35 @@ impl ElectrumClient {
Ok(tx_id)
}
- const fn default_options() -> NodeClientOptions {
- NodeClientOptions { batch_size: ELECTRUM_BATCH_SIZE }
+ fn default_options() -> NodeClientOptions {
+ NodeClientOptions { batch_size: ELECTRUM_BATCH_SIZE, ..NodeClientOptions::default() }
+ }
+
+ fn connection_config(options: &NodeClientOptions) -> Config {
+ let socks5_addr = if options.use_tor {
+ let endpoint = format!("{}:{}", options.tor_external_host, options.tor_external_port);
+ debug!(
+ api_type = "electrum",
+ tor_enabled = true,
+ tor_mode = ?options.tor_mode,
+ "electrum using socks5 endpoint"
+ );
+ Some(endpoint)
+ } else {
+ debug!("electrum connecting without tor proxy");
+ None
+ };
+
+ ConfigBuilder::new().socks5(socks5_addr.map(Socks5Config::new)).build()
+ }
+
+ fn is_built_in_tor_bootstrap_socks_failure(error: &electrum_client::Error) -> bool {
+ matches!(
+ error,
+ electrum_client::Error::IOError(io_error)
+ if io_error.kind() == std::io::ErrorKind::Other
+ && io_error.to_string().contains("general SOCKS server failure")
+ )
}
}
@@ -292,7 +394,9 @@ impl std::fmt::Debug for ElectrumClient {
#[cfg(test)]
mod tests {
use super::*;
- use std::str::FromStr;
+ use bdk_electrum::electrum_client::{ElectrumApi, Param};
+ use std::{str::FromStr, time::Duration};
+ use tokio::time::timeout;
#[tokio::test]
#[ignore] // requires external network connection to blockstream electrum server
@@ -318,4 +422,56 @@ mod tests {
Err(e) => panic!("Fallback method failed: {e:?}"),
}
}
+
+ #[tokio::test]
+ #[ignore] // requires local Tor SOCKS proxy and reachable onion electrum server
+ async fn test_onion_electrum_via_tor_proxy() {
+ cove_tokio::init();
+
+ let node = crate::node::Node {
+ url: "tcp://xotqmhnei2wy7fk423tekp62ilcxpawnf4aiqmnkfhuutfkimgpqk5qd.onion:50001"
+ .to_string(),
+ name: "onion-electrum".to_string(),
+ api_type: crate::node::ApiType::Electrum,
+ network: cove_types::network::Network::Bitcoin,
+ };
+
+ let options = NodeClientOptions {
+ batch_size: 10,
+ use_tor: true,
+ tor_mode: crate::node::TorMode::External,
+ tor_external_host: "127.0.0.1".to_string(),
+ tor_external_port: 9050,
+ };
+
+ let client = timeout(
+ Duration::from_secs(20),
+ ElectrumClient::new_from_node_and_options(&node, &options),
+ )
+ .await
+ .expect("timed out creating electrum client")
+ .expect("failed to create electrum client through tor proxy");
+
+ let raw_version = timeout(Duration::from_secs(20), async {
+ cove_tokio::unblock::run_blocking({
+ let inner = client.client.clone();
+ move || {
+ inner.inner.raw_call(
+ "server.version",
+ [Param::String("cove-test".to_string()), Param::String("1.4".to_string())],
+ )
+ }
+ })
+ .await
+ })
+ .await
+ .expect("timed out waiting for server.version response")
+ .expect("server.version call failed via onion electrum through tor proxy");
+
+ let response =
+ raw_version.as_array().expect("server.version response should be a JSON array");
+ assert!(response.len() >= 2, "server.version response array too short");
+ assert!(response[0].is_string(), "server software field must be a string");
+ assert!(response[1].is_string(), "protocol version field must be a string");
+ }
}
diff --git a/rust/src/node/client/esplora.rs b/rust/src/node/client/esplora.rs
index 2d9a9bfbf..5bd6f648c 100644
--- a/rust/src/node/client/esplora.rs
+++ b/rust/src/node/client/esplora.rs
@@ -24,29 +24,48 @@ pub struct EsploraClient {
}
impl EsploraClient {
- pub const fn new(client: Arc) -> Self {
- Self { client, options: NodeClientOptions { batch_size: ESPLORA_BATCH_SIZE } }
+ pub fn new(client: Arc) -> Self {
+ Self::new_with_options(
+ client,
+ NodeClientOptions { batch_size: ESPLORA_BATCH_SIZE, ..NodeClientOptions::default() },
+ )
}
pub fn new_from_node(node: &Node) -> Result {
- let client = esplora_client::Builder::new(&node.url)
- .build_async()
- .map_err(Error::CreateEsploraClient)?
- .into();
-
- Ok(Self::new(client))
+ let options = NodeClientOptions::default();
+ Self::new_from_node_and_options(node, &options)
}
pub fn new_from_node_and_options(
node: &Node,
- options: NodeClientOptions,
+ options: &NodeClientOptions,
) -> Result {
- let client = esplora_client::Builder::new(&node.url)
- .build_async()
- .map_err(Error::CreateEsploraClient)?
- .into();
+ debug!(
+ api_type = "esplora",
+ tor_enabled = options.use_tor,
+ tor_mode = ?options.tor_mode,
+ batch_size = options.batch_size,
+ "creating esplora client from node and options"
+ );
+ let mut builder = esplora_client::Builder::new(&node.url);
+
+ if options.use_tor {
+ debug!(
+ api_type = "esplora",
+ tor_enabled = true,
+ tor_mode = ?options.tor_mode,
+ "esplora using socks proxy"
+ );
+ let proxy =
+ format!("socks5h://{}:{}", options.tor_external_host, options.tor_external_port);
+ builder = builder.proxy(&proxy);
+ } else {
+ debug!("esplora connecting without tor proxy");
+ }
+
+ let client = builder.build_async().map_err(Error::CreateEsploraClient)?.into();
- Ok(Self::new_with_options(client, options))
+ Ok(Self::new_with_options(client, options.clone()))
}
pub const fn new_with_options(client: Arc, options: NodeClientOptions) -> Self {
diff --git a/rust/src/node/client_builder.rs b/rust/src/node/client_builder.rs
index 55f2f7868..e132c413b 100644
--- a/rust/src/node/client_builder.rs
+++ b/rust/src/node/client_builder.rs
@@ -1,4 +1,4 @@
-use crate::node::client::Error;
+use crate::{database::Database, node::client::Error};
use super::{
Node,
@@ -11,6 +11,26 @@ pub struct NodeClientBuilder {
pub options: NodeClientOptions,
}
impl NodeClientBuilder {
+ pub fn with_defaults(node: Node, batch_size: usize) -> Self {
+ let db = Database::global();
+ let config = db.global_config();
+ let tor_external_host = config
+ .tor_external_host()
+ .ok()
+ .filter(|host| !host.is_empty())
+ .unwrap_or_else(|| "127.0.0.1".into());
+
+ let options = NodeClientOptions {
+ batch_size,
+ use_tor: config.use_tor(),
+ tor_mode: config.tor_mode().unwrap_or_default(),
+ tor_external_host,
+ tor_external_port: config.tor_external_port(),
+ };
+
+ Self { node, options }
+ }
+
pub async fn build(&self) -> Result {
let node_client = NodeClient::try_from_builder(self).await?;
Ok(node_client)
diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs
index 175e14922..277a36013 100644
--- a/rust/src/node_connect.rs
+++ b/rust/src/node_connect.rs
@@ -1,7 +1,16 @@
-use tracing::error;
+use tracing::{error, info, warn};
use url::Url;
-use crate::{database::Database, network::Network, node::Node};
+use cove_util::ResultExt as _;
+
+use crate::{
+ database::{Database, global_flag::GlobalFlagKey},
+ network::Network,
+ node::{
+ ApiType, Node,
+ client::{NodeClient, NodeClientOptions},
+ },
+};
use cove_macros::impl_default_for;
use eyre::{Context, eyre};
@@ -124,7 +133,9 @@ impl NodeSelector {
#[uniffi::method]
pub async fn check_selected_node(&self, node: Node) -> Result<(), Error> {
- node.check_url().await.map_err(|error| Error::NodeAccessError(format!("{error:?}")))?;
+ check_node_with_tor_inference(&node)
+ .await
+ .map_err(|error| Error::NodeAccessError(format!("{error:?}")))?;
Ok(())
}
@@ -138,9 +149,30 @@ impl NodeSelector {
entered_name: String,
) -> Result {
let node_type = name.to_ascii_lowercase();
+ let hinted_api_type = if node_type.contains("electrum") {
+ ApiType::Electrum
+ } else if node_type.contains("esplora") {
+ ApiType::Esplora
+ } else {
+ error!("invalid node type: {node_type}");
+ return Err(Error::ParseNodeUrlError("invalid node type".to_string()));
+ };
+ let inferred_api_type = infer_api_type_from_url_hint(&url);
+ let api_type = match inferred_api_type {
+ Some(inferred) if inferred != hinted_api_type => {
+ warn!(
+ requested_type = ?hinted_api_type,
+ inferred_type = ?inferred,
+ url = %url,
+ "custom node type mismatched url; using type inferred from url",
+ );
+ inferred
+ }
+ Some(inferred) => inferred,
+ None => hinted_api_type,
+ };
- let url =
- parse_node_url(&url).map_err(|error| Error::ParseNodeUrlError(error.to_string()))?;
+ let url = parse_node_url(&url, api_type).map_err_str(Error::ParseNodeUrlError)?;
if !url.domain().unwrap_or_default().contains('.') {
return Err(Error::ParseNodeUrlError("invalid url, no domain".to_string()));
@@ -154,13 +186,10 @@ impl NodeSelector {
entered_name
};
- let node = if node_type.contains("electrum") {
- Node::new_electrum(name, url_string, self.network)
- } else if node_type.contains("esplora") {
- Node::new_esplora(name, url_string, self.network)
- } else {
- error!("invalid node type: {node_type}");
- Node::default(self.network)
+ let node = match api_type {
+ ApiType::Electrum => Node::new_electrum(name, url_string, self.network),
+ ApiType::Esplora => Node::new_esplora(name, url_string, self.network),
+ ApiType::Rpc => Node::default(self.network),
};
Ok(node)
@@ -169,12 +198,25 @@ impl NodeSelector {
#[uniffi::method]
/// Check the node url and set it as selected node if it is valid
pub async fn check_and_save_node(&self, node: Node) -> Result<(), Error> {
- node.check_url().await.map_err(|error| {
+ check_node_with_tor_inference(&node).await.map_err(|error| {
tracing::warn!("error checking node: {error:?}");
Error::NodeAccessError(error.to_string())
})?;
- Database::global()
+ let database = Database::global();
+
+ if node_implies_tor(&node) {
+ database
+ .global_flag
+ .set_bool_config(GlobalFlagKey::TorSettingsDiscovered, true)
+ .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?;
+ database
+ .global_config
+ .set_use_tor(true)
+ .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?;
+ }
+
+ database
.global_config
.set_selected_node(&node)
.map_err(|error| Error::SetSelectedNodeError(error.to_string()))?;
@@ -237,7 +279,97 @@ fn node_list(network: Network) -> Vec {
}
}
-fn parse_node_url(url: &str) -> eyre::Result {
+fn node_implies_tor(node: &Node) -> bool {
+ Url::parse(&node.url)
+ .ok()
+ .and_then(|parsed| parsed.host_str().map(str::to_string))
+ .is_some_and(|host| host.ends_with(".onion"))
+}
+
+async fn check_node_with_tor_inference(node: &Node) -> Result<(), crate::node::Error> {
+ let inferred_tor = node_implies_tor(node);
+ info!(node = %node.url, api_type = ?node.api_type, inferred_tor, "checking node with tor inference");
+
+ let db = Database::global();
+ let config = db.global_config();
+
+ if !inferred_tor {
+ if config.use_tor() {
+ info!(node = %node.url, "node does not imply tor, but global tor is enabled; checking through configured tor client");
+ let client = NodeClient::new(node).await?;
+ client.check_url().await?;
+ return Ok(());
+ }
+
+ info!(node = %node.url, "node does not imply tor and global tor is disabled; checking directly");
+ return node.check_url().await;
+ }
+
+ let tor_external_host = config
+ .tor_external_host()
+ .ok()
+ .filter(|host| !host.is_empty())
+ .unwrap_or_else(|| "127.0.0.1".to_string());
+
+ let batch_size = match node.api_type {
+ ApiType::Electrum => 10,
+ ApiType::Esplora | ApiType::Rpc => 1,
+ };
+
+ let options = NodeClientOptions {
+ batch_size,
+ use_tor: true,
+ tor_mode: config.tor_mode().unwrap_or_default(),
+ tor_external_host,
+ tor_external_port: config.tor_external_port(),
+ };
+
+ info!(node = %node.url, options = ?options, "node implies tor; building tor-enabled node client");
+
+ let client = NodeClient::new_with_options(node, options).await?;
+ info!(node = %node.url, "running node check through tor-capable client");
+ client.check_url().await?;
+
+ Ok(())
+}
+
+fn parse_node_url(url: &str, api_type: ApiType) -> eyre::Result {
+ match api_type {
+ ApiType::Electrum => parse_electrum_url(url),
+ ApiType::Esplora | ApiType::Rpc => parse_http_url(url),
+ }
+}
+
+fn infer_api_type_from_url_hint(url: &str) -> Option {
+ let lowered = url.trim().to_ascii_lowercase();
+
+ if lowered.is_empty() {
+ return None;
+ }
+
+ if lowered.starts_with("tcp://") || lowered.starts_with("ssl://") {
+ return Some(ApiType::Electrum);
+ }
+
+ if lowered.starts_with("http://") || lowered.starts_with("https://") {
+ return Some(ApiType::Esplora);
+ }
+
+ if lowered.contains("://") {
+ return None;
+ }
+
+ if lowered.contains('/') {
+ return Some(ApiType::Esplora);
+ }
+
+ match lowered.rsplit_once(':') {
+ Some((_, "50001" | "50002")) => Some(ApiType::Electrum),
+ _ => None,
+ }
+}
+
+fn parse_electrum_url(url: &str) -> eyre::Result {
let url = url.replace("http://", "tcp://");
let url = url.replace("https://", "ssl://");
@@ -266,6 +398,13 @@ fn parse_node_url(url: &str) -> eyre::Result {
_ => {}
}
+ if !matches!(url.scheme(), "ssl" | "tcp") {
+ return Err(eyre!(
+ "invalid electrum url scheme `{}`; expected tcp:// or ssl://",
+ url.scheme(),
+ ));
+ }
+
// set the port to if not set, default to 50002 for ssl and 50001 for tcp
match (url.port(), url.scheme()) {
(Some(_), _) => {}
@@ -279,6 +418,26 @@ fn parse_node_url(url: &str) -> eyre::Result {
Ok(url)
}
+fn parse_http_url(url: &str) -> eyre::Result {
+ let url = if url.contains("://") { url.to_string() } else { format!("https://{url}") };
+
+ let mut url = Url::parse(&url)?;
+
+ if !matches!(url.scheme(), "http" | "https") {
+ return Err(eyre!(
+ "invalid esplora url scheme `{}`; expected http:// or https://",
+ url.scheme(),
+ ));
+ }
+
+ if url.port().is_none() {
+ let default_port = if url.scheme() == "http" { 80 } else { 443 };
+ url.set_port(Some(default_port)).map_err(|()| eyre!("can't set port"))?;
+ }
+
+ Ok(url)
+}
+
#[uniffi::export]
impl NodeSelection {
fn to_node(&self) -> Node {
diff --git a/rust/src/tor_runtime.rs b/rust/src/tor_runtime.rs
new file mode 100644
index 000000000..8d0a6da68
--- /dev/null
+++ b/rust/src/tor_runtime.rs
@@ -0,0 +1,416 @@
+use std::{
+ net::{Ipv4Addr, SocketAddr},
+ path::PathBuf,
+};
+
+use futures::{FutureExt as _, StreamExt as _};
+use tokio::{
+ net::TcpStream,
+ sync::watch,
+ time::{Duration, sleep},
+};
+
+use arti::proxy::ListenProtocols;
+use arti_client::{
+ BootstrapBehavior, TorClient, TorClientConfig, config::TorClientConfigBuilder,
+ status::BootstrapStatus,
+};
+use once_cell::sync::Lazy;
+use parking_lot::{Condvar, Mutex};
+use tor_config::Listen;
+use tor_rtcompat::{NetStreamListener, NetStreamProvider, PreferredRuntime, ToplevelBlockOn};
+use tracing::{debug, error, info, warn};
+
+use crate::BuiltInTorBootstrapStatus;
+use cove_common::consts::ROOT_DATA_DIR;
+
+const BUILT_IN_TOR_SOCKS_PORT: u16 = 39050;
+
+#[derive(Debug, Clone, thiserror::Error)]
+pub enum Error {
+ #[error("failed to initialize built-in tor proxy: {0}")]
+ Proxy(String),
+}
+
+#[derive(Debug, Clone)]
+struct BuiltInTorState {
+ endpoint: Option,
+ launched: bool,
+ last_error: Option,
+ shutdown_tx: Option>,
+ bootstrap_status: BuiltInTorBootstrapStatus,
+}
+
+#[derive(Debug, Clone)]
+struct BuiltInTorPaths {
+ state_dir: PathBuf,
+ cache_dir: PathBuf,
+}
+
+static BUILT_IN_TOR_STATE: Lazy> = Lazy::new(|| {
+ Mutex::new(BuiltInTorState {
+ endpoint: None,
+ launched: false,
+ last_error: None,
+ shutdown_tx: None,
+ bootstrap_status: default_bootstrap_status(false, None),
+ })
+});
+static BUILT_IN_TOR_STOPPED: Lazy = Lazy::new(Condvar::new);
+
+fn clear_built_in_state(reason: &str) {
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ state.endpoint = None;
+ state.launched = false;
+ state.shutdown_tx = None;
+ state.bootstrap_status = default_bootstrap_status(false, state.last_error.clone());
+ warn!(%reason, "cleared built-in tor state");
+ BUILT_IN_TOR_STOPPED.notify_all();
+}
+
+fn set_built_in_error(error: String) {
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ state.last_error = Some(error.clone());
+ state.bootstrap_status = default_bootstrap_status(state.launched, Some(error.clone()));
+ warn!(%error, "recorded built-in tor runtime error");
+}
+
+fn take_built_in_error() -> Option {
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ state.last_error.take()
+}
+
+pub(crate) fn built_in_status_summary() -> String {
+ let state = BUILT_IN_TOR_STATE.lock();
+ let last_error = if state.last_error.is_some() { "present" } else { "none" };
+
+ format!("launched={}, last_error={last_error}", state.launched)
+}
+
+pub(crate) fn built_in_bootstrap_status() -> BuiltInTorBootstrapStatus {
+ let state = BUILT_IN_TOR_STATE.lock();
+ let mut status = state.bootstrap_status.clone();
+ status.launched = state.launched;
+ status.last_error = state.last_error.clone();
+ status
+}
+
+fn default_bootstrap_status(
+ launched: bool,
+ last_error: Option,
+) -> BuiltInTorBootstrapStatus {
+ BuiltInTorBootstrapStatus {
+ percent: if launched { 1 } else { 0 },
+ ready: false,
+ blocked: last_error.clone(),
+ message: if launched {
+ "Starting Tor".to_string()
+ } else {
+ "Built-in Tor is not running".to_string()
+ },
+ launched,
+ last_error,
+ }
+}
+
+fn set_bootstrap_status(status: &BootstrapStatus) {
+ let percent = (status.as_frac() * 100.0).round().clamp(0.0, 100.0) as u32;
+ let blocked = status.blocked().map(|blockage| blockage.to_string());
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ state.bootstrap_status = BuiltInTorBootstrapStatus {
+ percent,
+ ready: status.ready_for_traffic(),
+ blocked,
+ message: status.to_string(),
+ launched: state.launched,
+ last_error: state.last_error.clone(),
+ };
+}
+
+pub(crate) fn request_stop_built_in_proxy() -> bool {
+ let shutdown_tx = {
+ let state = BUILT_IN_TOR_STATE.lock();
+ state.shutdown_tx.clone()
+ };
+
+ match shutdown_tx {
+ Some(tx) => {
+ if tx.send(true).is_err() {
+ warn!("failed to request built-in tor shutdown: runtime channel closed");
+ return wait_for_built_in_proxy_stopped();
+ }
+ info!("requested built-in tor shutdown");
+ wait_for_built_in_proxy_stopped()
+ }
+ None => {
+ debug!("built-in tor shutdown requested but runtime is not active");
+ true
+ }
+ }
+}
+
+fn wait_for_built_in_proxy_stopped() -> bool {
+ const STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
+
+ let started_at = std::time::Instant::now();
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ loop {
+ if !state.launched && state.endpoint.is_none() {
+ info!("built-in tor proxy stopped");
+ return true;
+ }
+
+ let Some(remaining) = STOP_TIMEOUT.checked_sub(started_at.elapsed()) else {
+ warn!("timed out waiting for built-in tor proxy to stop");
+ return false;
+ };
+
+ if BUILT_IN_TOR_STOPPED.wait_for(&mut state, remaining).timed_out() {
+ warn!("timed out waiting for built-in tor proxy to stop");
+ return false;
+ }
+ }
+}
+
+fn built_in_tor_paths() -> BuiltInTorPaths {
+ let tor_root = ROOT_DATA_DIR.join("tor");
+ BuiltInTorPaths { state_dir: tor_root.join("state"), cache_dir: tor_root.join("cache") }
+}
+
+fn ensure_built_in_tor_dirs(paths: &BuiltInTorPaths) -> Result<(), Error> {
+ std::fs::create_dir_all(&paths.state_dir).map_err(|error| {
+ Error::Proxy(format!(
+ "failed to create built-in tor state dir {}: {error}",
+ paths.state_dir.display()
+ ))
+ })?;
+
+ std::fs::create_dir_all(&paths.cache_dir).map_err(|error| {
+ Error::Proxy(format!(
+ "failed to create built-in tor cache dir {}: {error}",
+ paths.cache_dir.display()
+ ))
+ })?;
+
+ Ok(())
+}
+
+fn build_tor_client_config() -> Result {
+ let paths = built_in_tor_paths();
+ ensure_built_in_tor_dirs(&paths)?;
+
+ info!(
+ state_dir = %paths.state_dir.display(),
+ cache_dir = %paths.cache_dir.display(),
+ "configuring built-in tor storage directories"
+ );
+
+ TorClientConfigBuilder::from_directories(&paths.state_dir, &paths.cache_dir).build().map_err(
+ |error| Error::Proxy(format!("failed to build built-in tor client config: {error}")),
+ )
+}
+
+async fn wait_for_socks_listener(endpoint: SocketAddr) -> Result<(), Error> {
+ const MAX_ATTEMPTS: usize = 40;
+ const RETRY_DELAY_MS: u64 = 100;
+
+ for attempt in 1..=MAX_ATTEMPTS {
+ if TcpStream::connect(endpoint).await.is_ok() {
+ info!(%endpoint, attempt, "built-in tor socks listener is ready");
+ return Ok(());
+ }
+
+ {
+ let state = BUILT_IN_TOR_STATE.lock();
+ if !state.launched && state.endpoint.is_none() {
+ let startup_error = state.last_error.clone().unwrap_or_else(|| {
+ "built-in tor runtime stopped before socks listener became ready".to_string()
+ });
+ return Err(Error::Proxy(startup_error));
+ }
+ }
+
+ sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
+ }
+
+ if let Some(error) = take_built_in_error() {
+ return Err(Error::Proxy(error));
+ }
+
+ Err(Error::Proxy(format!(
+ "built-in tor socks listener not ready at {endpoint} after {MAX_ATTEMPTS} attempts"
+ )))
+}
+
+pub async fn built_in_socks_endpoint() -> Result {
+ let cached_endpoint = {
+ let state = BUILT_IN_TOR_STATE.lock();
+ if let Some(endpoint) = state.endpoint {
+ debug!(%endpoint, launched = state.launched, "built-in tor endpoint already cached");
+ Some(endpoint)
+ } else {
+ None
+ }
+ };
+
+ if let Some(endpoint) = cached_endpoint {
+ wait_for_socks_listener(endpoint).await?;
+ return Ok(endpoint);
+ }
+
+ info!("built-in tor endpoint requested without cache; launching proxy");
+ launch_built_in_proxy().await
+}
+
+async fn launch_built_in_proxy() -> Result {
+ let endpoint = SocketAddr::from((Ipv4Addr::LOCALHOST, BUILT_IN_TOR_SOCKS_PORT));
+ info!(
+ %endpoint,
+ configured_port = BUILT_IN_TOR_SOCKS_PORT,
+ "resolved built-in tor endpoint"
+ );
+
+ {
+ let state = BUILT_IN_TOR_STATE.lock();
+ if let Some(endpoint) = state.endpoint {
+ return Ok(endpoint);
+ }
+
+ if state.launched {
+ return Err(Error::Proxy(
+ "built-in tor launch already in progress without a ready endpoint".to_string(),
+ ));
+ }
+ }
+
+ let client_config = build_tor_client_config()?;
+ let socks_listen = Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT);
+
+ let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
+ {
+ let mut state = BUILT_IN_TOR_STATE.lock();
+ if let Some(endpoint) = state.endpoint {
+ return Ok(endpoint);
+ }
+ if state.launched {
+ return Err(Error::Proxy(
+ "built-in tor launch already in progress without a ready endpoint".to_string(),
+ ));
+ }
+ state.launched = true;
+ state.endpoint = Some(endpoint);
+ state.last_error = None;
+ state.shutdown_tx = Some(shutdown_tx);
+ state.bootstrap_status = default_bootstrap_status(true, None);
+ }
+
+ std::thread::spawn(move || {
+ info!("starting built-in tor runtime thread");
+
+ let proxy_runtime = match PreferredRuntime::create() {
+ Ok(runtime) => {
+ info!("built-in tor runtime created");
+ runtime
+ }
+ Err(error) => {
+ let message = format!("failed to create built-in tor runtime: {error}");
+ error!("{message}");
+ set_built_in_error(message);
+ clear_built_in_state("runtime creation failed");
+ return;
+ }
+ };
+
+ let run_result = proxy_runtime.block_on(async {
+ info!("creating built-in Tor client");
+ let client = TorClient::with_runtime(proxy_runtime.clone())
+ .config(client_config)
+ .bootstrap_behavior(BootstrapBehavior::OnDemand)
+ .create_unbootstrapped_async()
+ .await
+ .map_err(|error| format!("failed to create built-in tor client: {error}"))?;
+ set_bootstrap_status(&client.bootstrap_status());
+
+ info!("launching Arti SOCKS proxy task");
+ let mut listeners = Vec::new();
+ for addrs in socks_listen
+ .ip_addrs()
+ .map_err(|error| format!("invalid built-in tor socks listener: {error}"))?
+ {
+ for addr in addrs {
+ let listener = proxy_runtime
+ .listen(&addr)
+ .await
+ .map_err(|error| format!("failed to listen on built-in tor socks address {addr}: {error}"))?;
+ info!(address = ?listener.local_addr(), "Listening on built-in Tor SOCKS address");
+ listeners.push(listener);
+ }
+ }
+ if listeners.is_empty() {
+ return Err("failed to open built-in tor socks listener".to_string());
+ }
+
+ let proxy = arti::proxy::run_proxy_with_listeners(
+ client.isolated_client(),
+ listeners,
+ ListenProtocols::SocksOnly,
+ None,
+ )
+ .map(|result| result.map_err(|error| format!("built-in tor socks proxy exited: {error}")));
+
+ let mut bootstrap_events = client.bootstrap_events();
+ let status_watcher = async move {
+ while let Some(status) = bootstrap_events.next().await {
+ set_bootstrap_status(&status);
+ }
+ futures::future::pending::>().await
+ };
+
+ let bootstrap_client = client.clone();
+ let bootstrap = async move {
+ bootstrap_client
+ .bootstrap()
+ .await
+ .map_err(|error| format!("built-in tor bootstrap failed: {error}"))?;
+ let status = bootstrap_client.bootstrap_status();
+ set_bootstrap_status(&status);
+ info!("Sufficiently bootstrapped; proxy now functional.");
+ futures::future::pending::>().await
+ };
+
+ tokio::select! {
+ run_result = proxy => run_result,
+ run_result = bootstrap => run_result,
+ run_result = status_watcher => run_result,
+ changed = shutdown_rx.changed() => {
+ match changed {
+ Ok(()) => {
+ info!("built-in tor shutdown signal received");
+ Ok(())
+ }
+ Err(_) => {
+ info!("built-in tor shutdown channel closed");
+ Ok(())
+ }
+ }
+ }
+ }
+ });
+
+ if let Err(error) = run_result {
+ let message = format!("built-in tor proxy exited: {error:?}");
+ error!("{message}");
+ set_built_in_error(message);
+ clear_built_in_state("proxy exited with error");
+ return;
+ }
+
+ warn!("built-in tor proxy task returned without error");
+ clear_built_in_state("proxy task returned");
+ });
+
+ info!(%endpoint, "built-in tor launch initiated; waiting for socks listener");
+ wait_for_socks_listener(endpoint).await?;
+ info!(%endpoint, "built-in tor endpoint ready");
+ Ok(endpoint)
+}
diff --git a/rust/src/wallet_scanner.rs b/rust/src/wallet_scanner.rs
index 8aafe08be..0545f7453 100644
--- a/rust/src/wallet_scanner.rs
+++ b/rust/src/wallet_scanner.rs
@@ -25,7 +25,7 @@ use crate::{
keychain::Keychain,
manager::wallet_manager::{SingleOrMany, WalletManagerReconcileMessage},
mnemonic::MnemonicExt,
- node::{client::NodeClientOptions, client_builder::NodeClientBuilder},
+ node::client_builder::NodeClientBuilder,
wallet::{
WalletAddressType, WalletError,
metadata::{DiscoveryState, FoundAddress, FoundJson, WalletId, WalletMetadata},
@@ -162,9 +162,7 @@ impl WalletScanner {
}
let node = db.global_config().selected_node();
- let options = NodeClientOptions { batch_size: 1 };
-
- let client_builder = NodeClientBuilder { node, options };
+ let client_builder = NodeClientBuilder::with_defaults(node, 1);
Ok(Self::new(metadata.id, client_builder, wallets, scan_source, reconciler))
}