diff --git a/BUILDING.md b/BUILDING.md index 3e0d1a3658..4dd6367113 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -190,6 +190,10 @@ Where: | `` | `x64`, `arm64` | | `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` | +The AppImage is the exception: `amethyst-desktop--.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` --- diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index cb47a915a8..c98506f31e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1863,6 +1863,9 @@ 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. Use regular connection Keep waiting + Connecting to Tor… + Tor error: %1$s + Amethyst uses Tor to hide your IP address from relays. Starting Tor can take a minute and needs internet access. Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. On Silent diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ce53aa2bde..88859f79c6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) { 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) { 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, externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, + torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow, 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, externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, + torSessionBypassFlow: kotlinx.coroutines.flow.MutableStateFlow, 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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorConnectingSplash.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorConnectingSplash.kt new file mode 100644 index 0000000000..d6b93b9b90 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorConnectingSplash.kt @@ -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)) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt index cd98c7ea9c..8db43c0fd2 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt @@ -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( diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index 100e7247b2..f426348776 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -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--.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 (all AppImages are for Linux) and the AppImage +# arch names (x86_64 / aarch64) instead of x64 / arm64. +# Usage: appimage_asset_name +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 using the canonical naming scheme. # Usage: collect_assets # 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