Merge pull request #4249 from vitorpamplona/claude/jolly-galileo-1jnyq4

Add Tor connection splash screen with escape hatch for desktop
This commit is contained in:
Vitor Pamplona
2026-09-28 09:40:36 -04:00
committed by GitHub
6 changed files with 223 additions and 63 deletions
+5 -1
View File
@@ -190,6 +190,10 @@ Where:
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
The AppImage is the exception: `amethyst-desktop-<version>-<x86_64|aarch64>.AppImage`.
AppImageHub flags `linux` in an AppImage name (every AppImage is for Linux) and
expects the AppImage arch names.
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
any change is a breaking contract.
@@ -199,7 +203,7 @@ Examples:
- `amethyst-desktop-1.12.1-macos-x64.dmg`
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
- `amethyst-desktop-1.12.1-windows-x64.msi`
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
- `amethyst-desktop-1.12.1-x86_64.AppImage`
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
---
@@ -1863,6 +1863,9 @@
<string name="tor_connection_failed_body">Amethyst couldn't finish bootstrapping Tor. Use a regular (non-Tor) connection so the feed can load? We'll remember this choice for the next hour and re-try Tor after that.</string>
<string name="tor_continue_without_for_session">Use regular connection</string>
<string name="tor_keep_waiting">Keep waiting</string>
<string name="tor_splash_connecting">Connecting to Tor…</string>
<string name="tor_splash_error">Tor error: %1$s</string>
<string name="tor_splash_explainer">Amethyst uses Tor to hide your IP address from relays. Starting Tor can take a minute and needs internet access.</string>
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
<string name="notification_channel_status_on">On</string>
<string name="notification_channel_status_silent">Silent</string>
@@ -95,6 +95,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrN
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.account.AccountManager
@@ -122,6 +124,7 @@ import com.vitorpamplona.amethyst.desktop.service.scheduledposts.LocalScheduledP
import com.vitorpamplona.amethyst.desktop.service.scheduledposts.OsScheduler
import com.vitorpamplona.amethyst.desktop.service.scheduledposts.runHeadlessPublish
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog
@@ -160,6 +163,8 @@ import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsAccordionCard
import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsEntry
import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsMeta
import com.vitorpamplona.amethyst.desktop.ui.settings.WalletConnectSettingsSection
import com.vitorpamplona.amethyst.desktop.ui.tor.TorConnectingSplash
import com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsDialog
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -342,14 +347,11 @@ fun main(args: Array<String>) {
val feedSearchActiveState = remember { mutableStateOf(false) }
// Tor state at Window level — survives key() app rebuild
var torSettings by remember {
mutableStateOf(
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.load(),
)
}
val torSettings = remember { DesktopTorPreferences.load() }
val torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) }
val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) }
// "Use regular connection" on the Tor splash: Tor off for this run only, never saved.
val torSessionBypassFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val windowScope = rememberCoroutineScope()
val torManager =
remember {
@@ -734,6 +736,7 @@ fun main(args: Array<String>) {
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
torSessionBypassFlow = torSessionBypassFlow,
initialTorSettings = torSettings,
onNavigateToScreen = { navigateToScreen = it },
)
@@ -770,6 +773,7 @@ fun App(
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow<Boolean>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {},
testOverrides: LaunchTestOverrides? = null,
@@ -826,6 +830,7 @@ fun App(
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
torSessionBypassFlow = torSessionBypassFlow,
initialTorSettings = initialTorSettings,
onNavigateToScreen = onNavigateToScreen,
testOverrides = testOverrides,
@@ -861,6 +866,7 @@ private fun AppInner(
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow<Boolean>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit,
testOverrides: LaunchTestOverrides?,
@@ -875,56 +881,42 @@ private fun AppInner(
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings.
// Tests can short-circuit the prefs read via `testOverrides.torSettingsOverride` so the
// Tor splash gate (below) does not block them behind a real kmp-tor runtime.
// torSettings is the user's saved choice (what the settings UI shows and saves);
// effectiveTorSettings is what this session actually routes by.
var torSettings by remember {
mutableStateOf(
testOverrides?.torSettingsOverride
?: com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.load(),
)
mutableStateOf(testOverrides?.torSettingsOverride ?: DesktopTorPreferences.load())
}
val torSessionBypass by torSessionBypassFlow.collectAsState()
val effectiveTorSettings = if (torSessionBypass) torSettings.copy(torType = TorType.OFF) else torSettings
// Gate: block EVERYTHING until Tor proxy is ready (when Tor expected)
// This must be before any OkHttpClient/Coil/relay creation
val torStatus by torManager.status.collectAsState()
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.foundation.layout.Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
androidx.compose.material3.CircularProgressIndicator()
androidx.compose.foundation.layout.Spacer(
modifier =
androidx.compose.ui.Modifier
.height(16.dp),
)
if (torStatus is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error) {
androidx.compose.material3.Text(
"Tor error: ${(torStatus as com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error).message}",
)
} else {
androidx.compose.material3.Text("Connecting to Tor...")
}
androidx.compose.foundation.layout.Spacer(
modifier =
androidx.compose.ui.Modifier
.height(24.dp),
)
androidx.compose.material3.Icon(
painter = splashIcon,
contentDescription = "Amethyst",
modifier =
androidx.compose.ui.Modifier
.size(96.dp),
tint = androidx.compose.material3.MaterialTheme.colorScheme.primary,
)
}
val isTorExpected = effectiveTorSettings.torType != TorType.OFF
if (isTorExpected && torStatus !is TorServiceStatus.Active) {
var showTorSettings by remember { mutableStateOf(false) }
TorConnectingSplash(
status = torStatus,
onContinueWithoutTor = {
torSessionBypassFlow.value = true
torTypeFlow.value = TorType.OFF
},
onOpenTorSettings = { showTorSettings = true },
)
if (showTorSettings) {
TorSettingsDialog(
currentSettings = torSettings,
torStatus = torStatus,
onSettingsChanged = { newSettings ->
// No onRestartApp(): nothing below the gate has been built yet, and a
// rebuild would only restart the splash (and its escape timer).
torSettings = newSettings
DesktopTorPreferences.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
},
onDismiss = { showTorSettings = false },
)
}
return // Nothing below runs until Tor is Active
}
@@ -985,15 +977,15 @@ private fun AppInner(
// Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation =
remember(torSettings) {
remember(effectiveTorSettings) {
com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation(
torSettings =
com.vitorpamplona.amethyst.commons.tor.TorRelaySettings(
torType = torSettings.torType,
onionRelaysViaTor = torSettings.onionRelaysViaTor,
dmRelaysViaTor = torSettings.dmRelaysViaTor,
newRelaysViaTor = torSettings.newRelaysViaTor,
trustedRelaysViaTor = torSettings.trustedRelaysViaTor,
torType = effectiveTorSettings.torType,
onionRelaysViaTor = effectiveTorSettings.onionRelaysViaTor,
dmRelaysViaTor = effectiveTorSettings.dmRelaysViaTor,
newRelaysViaTor = effectiveTorSettings.newRelaysViaTor,
trustedRelaysViaTor = effectiveTorSettings.trustedRelaysViaTor,
),
// TODO: populate from account relay lists
classification =
@@ -1499,9 +1491,11 @@ private fun AppInner(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
// Saving re-applies the saved choice, ending a
// session bypass from the splash.
torSessionBypassFlow.value = false
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
DesktopTorPreferences.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.tor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_logo
import com.vitorpamplona.amethyst.commons.resources.connect_via_tor2
import com.vitorpamplona.amethyst.commons.resources.tor_continue_without_for_session
import com.vitorpamplona.amethyst.commons.resources.tor_splash_connecting
import com.vitorpamplona.amethyst.commons.resources.tor_splash_error
import com.vitorpamplona.amethyst.commons.resources.tor_splash_explainer
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.desktop.platform.IconResources
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
/**
* How long the splash waits before offering a way past Tor. Long enough that a normal
* bootstrap finishes without the user ever seeing the choice, short enough that someone
* offline (or on a network that blocks Tor) is not left staring at a spinner.
*/
private const val ESCAPE_DELAY_MS = 10_000L
/**
* Shown instead of the app while the embedded Tor is expected but not yet routable.
*
* Tor never finishes bootstrapping without internet access, so this screen must not be a dead
* end: after [ESCAPE_DELAY_MS] (or at once, on an error) it offers to continue over a regular
* connection for this session, or to open the Tor settings (e.g. to point at an external Tor).
*/
@Composable
fun TorConnectingSplash(
status: TorServiceStatus,
onContinueWithoutTor: () -> Unit,
onOpenTorSettings: () -> Unit,
) {
var showEscape by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(ESCAPE_DELAY_MS)
showEscape = true
}
Box(
modifier = Modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 420.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
text =
if (status is TorServiceStatus.Error) {
stringResource(Res.string.tor_splash_error, status.message)
} else {
stringResource(Res.string.tor_splash_connecting)
},
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(Res.string.tor_splash_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
Icon(
painter = IconResources.rawBitmapPainter,
contentDescription = stringResource(Res.string.app_logo),
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary,
)
if (showEscape || status is TorServiceStatus.Error) {
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onOpenTorSettings) {
Text(stringResource(Res.string.connect_via_tor2))
}
Button(onClick = onContinueWithoutTor) {
Text(stringResource(Res.string.tor_continue_without_for_session))
}
}
}
}
}
}
@@ -129,6 +129,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -190,6 +191,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -266,6 +268,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
@@ -342,6 +345,7 @@ class AppStateMachineTest {
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
torSessionBypassFlow = MutableStateFlow(false),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
+26 -4
View File
@@ -28,12 +28,12 @@
# amethyst-desktop-1.08.0-windows-arm64.zip
# amethyst-desktop-1.08.0-linux-x64.deb
# amethyst-desktop-1.08.0-linux-x64.rpm
# amethyst-desktop-1.08.0-linux-x64.AppImage
# amethyst-desktop-1.08.0-x86_64.AppImage
# amethyst-desktop-1.08.0-linux-x64.flatpak
# amethyst-desktop-1.08.0-linux-x64.tar.gz
# amethyst-desktop-1.08.0-linux-arm64.deb
# amethyst-desktop-1.08.0-linux-arm64.rpm
# amethyst-desktop-1.08.0-linux-arm64.AppImage
# amethyst-desktop-1.08.0-aarch64.AppImage
# amethyst-desktop-1.08.0-linux-arm64.flatpak
# amethyst-desktop-1.08.0-linux-arm64.tar.gz
# amy-1.08.0-macos-arm64.tar.gz
@@ -56,7 +56,12 @@
# geode-1.08.0-windows-x64.zip
# geode-1.08.0-windows-arm64.zip
#
# Two assets break the family/arch shape on purpose: the no-JRE jar bundles for
# The AppImage breaks the family/arch shape on purpose: AppImageHub
# (appimage.github.io) flags "linux" in an AppImage name, since every AppImage
# is for Linux, and expects the AppImage arch names, so it is
# amethyst-desktop-<version>-<x86_64|aarch64>.AppImage
#
# Two more assets break the family/arch shape on purpose: the no-JRE jar bundles for
# Homebrew-core are pure JVM bytecode (no bundled runtime), so a single
# platform-independent artifact serves every OS:
# amy-1.08.0-jvm.tar.gz
@@ -87,6 +92,19 @@ geode_asset_name() {
printf 'geode-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
}
# AppImage variant: no <family> (all AppImages are for Linux) and the AppImage
# arch names (x86_64 / aarch64) instead of x64 / arm64.
# Usage: appimage_asset_name <arch> <version>
appimage_asset_name() {
local arch="$1" version="$2" appimage_arch
case "$arch" in
x64) appimage_arch="x86_64" ;;
arm64) appimage_arch="aarch64" ;;
*) echo "appimage_asset_name: unsupported arch '$arch'" >&2; return 1 ;;
esac
printf 'amethyst-desktop-%s-%s.AppImage' "$version" "$appimage_arch"
}
# Copy + rename build outputs into <dest_dir> using the canonical naming scheme.
# Usage: collect_assets <family> <arch> <version> <dest_dir>
# Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives).
@@ -115,7 +133,11 @@ collect_assets() {
*.tar.gz) ext="tar.gz" ;;
*) ext="${base##*.}" ;;
esac
dst="$dest/$(asset_name "$family" "$arch" "$version" "$ext")"
if [[ "$ext" == "AppImage" ]]; then
dst="$dest/$(appimage_asset_name "$arch" "$version")"
else
dst="$dest/$(asset_name "$family" "$arch" "$version" "$ext")"
fi
cp "$src" "$dst"
echo "Collected: $dst"
done