mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5
# Conflicts: # commons/build.gradle.kts # quartz/build.gradle.kts
This commit is contained in:
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
|
||||
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
|
||||
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
|
||||
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
|
||||
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
|
||||
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
|
||||
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
|
||||
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
|
||||
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
|
||||
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
|
||||
by lowest id; older re-inserts blocked but acknowledged as already covered.
|
||||
|
||||
**STORE-W02 — addressable supersession.** Same as W01 with unique index
|
||||
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
|
||||
|
||||
+5
-3
@@ -268,9 +268,11 @@ front:
|
||||
sequentially: `for peer in aioquic picoquic quic-go quinn; do
|
||||
quic/interop/run-matrix.sh -s $peer; done`. Plan at
|
||||
`quic/interop/plans/2026-05-06-interop-runner.md`.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
|
||||
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
|
||||
Marmot variant prompts a human to drive the Android UI.
|
||||
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
|
||||
relay-backed suite boots the embedded `amy serve` relay (geode) — no
|
||||
external relay binary; only the Marmot suites additionally need `cargo`
|
||||
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
|
||||
drive the Android UI.
|
||||
|
||||
If a change is documentation-only, UI-only, build-script-only, or otherwise
|
||||
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
|
||||
|
||||
@@ -399,9 +399,9 @@ dependencies {
|
||||
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
|
||||
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
|
||||
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
|
||||
debugImplementation("androidx.compose.runtime:runtime-tracing")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
|
||||
debugImplementation(libs.androidx.compose.runtime.tracing)
|
||||
debugImplementation(libs.androidx.tracing.perfetto)
|
||||
debugImplementation(libs.androidx.tracing.perfetto.binary)
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
@@ -599,6 +599,15 @@ dependencies {
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
// In-process Nostr relay (geode) so unit tests that drive a real
|
||||
// NostrClient talk to an embedded relay instead of a public one. Same
|
||||
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
|
||||
// testFixtures (RelayClientTest base, preload/publish helpers) and the
|
||||
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
|
||||
testImplementation(project(":geode"))
|
||||
testImplementation(testFixtures(project(":geode")))
|
||||
testImplementation(libs.androidx.sqlite.bundled.jvm)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.junit.ktx)
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlaybackErrorOverlayFitTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
|
||||
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioPlayerBoxOverflowTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private class Bounds {
|
||||
var top = 0f
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
@@ -70,7 +70,8 @@ import org.junit.Test
|
||||
* fallback would silently start reading a dead value too.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSyncTest {
|
||||
companion object {
|
||||
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
|
||||
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
|
||||
.build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = {
|
||||
listOf(Constants.mom, Constants.nos)
|
||||
},
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
NostrClient(socketBuilder, appScope)
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiatjafSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = { listOf(fiatjaf) },
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
val newClient = NostrClient(socketBuilder, appScope)
|
||||
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
|
||||
import kotlin.time.DurationUnit
|
||||
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
|
||||
private const val STATE_DUMP_TAG = "STATE DUMP"
|
||||
|
||||
fun debugState(context: Context) {
|
||||
// Everything below is logged at DEBUG, and every argument is built eagerly (the
|
||||
// eager Log.d overload, not the lambda one). Gate on the level that would drop
|
||||
// those lines, because the arguments are the expensive part: nine materialising
|
||||
// LargeCache.filter scans over notes/addressables/users/channels, plus three
|
||||
// passes calling Event.countMemory() — which walks every tag of every cached
|
||||
// event. MainActivity.onPause() calls this unconditionally, so without the gate
|
||||
// a release build (minLevel WARN) did all of that on every backgrounding and
|
||||
// threw the result away. Benchmark builds sit at INFO and paid it too.
|
||||
if (Log.minLevel > LogLevel.DEBUG) return
|
||||
|
||||
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
data class VanishEventItem(
|
||||
@@ -124,7 +125,10 @@ class VanishRequestsState(
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// A cancelled check has no result. Reporting ERROR would show the relay as
|
||||
// having answered badly when it was never asked.
|
||||
if (e is CancellationException) throw e
|
||||
item.complianceResults.update {
|
||||
it + (relay to ComplianceStatus.ERROR)
|
||||
}
|
||||
|
||||
+2
-1
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -60,6 +60,7 @@ import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -162,7 +163,10 @@ class NappletResourceFetcher(
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Cancellation is not an upstream failure — do not report it to the
|
||||
// napplet as one, and do not keep the request alive past it.
|
||||
if (e is CancellationException) throw e
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -201,6 +201,9 @@ class EventNotificationConsumer(
|
||||
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||
.getOrNull()
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
private suspend fun dispatchForAccount(
|
||||
event: Event,
|
||||
account: Account,
|
||||
|
||||
+3
@@ -112,6 +112,9 @@ class NotificationDispatcher(
|
||||
// recipient account.
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
|
||||
+3
@@ -81,6 +81,9 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be rendered.
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.webrtc.RtpSender
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val TAG = "CallSession"
|
||||
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
|
||||
@@ -429,6 +430,7 @@ class CallSession(
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.withTranslation
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MathParser
|
||||
import ru.noties.jlatexmath.JLatexMathDrawable
|
||||
|
||||
@@ -130,12 +131,11 @@ fun LatexEquation(
|
||||
Canvas(modifier = equationModifier) {
|
||||
drawIntoCanvas { canvas ->
|
||||
val native = canvas.nativeCanvas
|
||||
val checkpoint = native.save()
|
||||
// Position the icon's baseline on the text baseline within the padded box.
|
||||
native.translate(0f, drawTopPx)
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(native)
|
||||
native.restoreToCount(checkpoint)
|
||||
native.withTranslation(y = drawTopPx) {
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailing.isNotEmpty()) {
|
||||
|
||||
+2
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -159,7 +159,7 @@ fun LocationPreviewMap(
|
||||
remember(pinColor, pinEmoji) {
|
||||
if (pinColor != null && pinEmoji != null) {
|
||||
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
|
||||
BitmapDrawable(context.resources, bitmap)
|
||||
bitmap.toDrawable(context.resources)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -2647,7 +2647,6 @@ class AccountViewModel(
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
.clear()
|
||||
feedStates.destroy()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun loadMentions(
|
||||
|
||||
-1
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
|
||||
-1
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
liveJob?.cancel()
|
||||
liveJob = null
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+11
-3
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,11 +59,15 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
|
||||
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
|
||||
private val MonthShortFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM", Locale.getDefault())
|
||||
// Cached per locale rather than in a single val that captures the locale once: the month
|
||||
// names have to follow a language the user changes while the app is running.
|
||||
private val monthShortFormatters = ConcurrentHashMap<Locale, DateTimeFormatter>()
|
||||
|
||||
private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) }
|
||||
|
||||
@Composable
|
||||
fun CalendarEventListCard(
|
||||
@@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) {
|
||||
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
}
|
||||
val day = localDate.dayOfMonth.toString()
|
||||
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() }
|
||||
// LocalLocale rather than Locale.getDefault(): the latter is not observable, so a
|
||||
// locale change while the app runs would leave the month name in the old language.
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.size(width = 52.dp, height = 60.dp),
|
||||
|
||||
-1
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun toggle(address: Address) {
|
||||
|
||||
+13
-16
@@ -119,7 +119,6 @@ class MarmotFileUploader(
|
||||
// imprecisely.
|
||||
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
|
||||
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
|
||||
val v2Cipher = cipher
|
||||
|
||||
item.orchestrator.uploadEncrypted(
|
||||
uri = media.uri,
|
||||
@@ -142,21 +141,19 @@ class MarmotFileUploader(
|
||||
// compression and metadata stripping — because that is what the
|
||||
// key was derived from.
|
||||
val reference =
|
||||
v2Cipher?.let {
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = it.ciphertextSha256,
|
||||
plaintextSha256 = it.plaintextSha256,
|
||||
nonce = it.nonce,
|
||||
mediaType = it.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
EncryptedMediaReferenceV2(
|
||||
locators =
|
||||
listOf(
|
||||
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
|
||||
),
|
||||
ciphertextSha256 = cipher.ciphertextSha256,
|
||||
plaintextSha256 = cipher.plaintextSha256,
|
||||
nonce = cipher.nonce,
|
||||
mediaType = cipher.mediaType,
|
||||
filename = filename,
|
||||
dim = serverResult.fileHeader.dim?.toString(),
|
||||
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
results.add(
|
||||
Mip04UploadResult(
|
||||
url = serverResult.url,
|
||||
|
||||
-1
@@ -801,7 +801,6 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -132,7 +132,6 @@ class ChessViewModelNew(
|
||||
fun clearFocusedGame() = logic.clearFocusedGame()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
logic.stopPolling()
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -739,7 +739,6 @@ class LongFormPostViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -612,7 +612,6 @@ open class NewProductViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
writingAssistant?.close()
|
||||
writingAssistant = null
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
|
||||
-1
@@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
cancel()
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private suspend fun rescan() {
|
||||
|
||||
-1
@@ -597,7 +597,6 @@ open class NestNewMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -139,6 +139,9 @@ class NotificationFeedFilter(
|
||||
AttestationRequestEvent.KIND,
|
||||
)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
val NOTIFICATION_KINDS =
|
||||
// Kinds that RENDER as a row on the Notifications tab. This is a
|
||||
// display gate over whatever is already in LocalCache — it plays no
|
||||
@@ -268,6 +271,9 @@ class NotificationFeedFilter(
|
||||
|
||||
// Shared with EventNotificationConsumer so push notifications and the
|
||||
// in-app feed apply the same per-kind "is this event for me" rule.
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be routed.
|
||||
@Suppress("DEPRECATION")
|
||||
fun tagsAnEventByUser(
|
||||
note: Note,
|
||||
authorHex: HexKey,
|
||||
|
||||
-1
@@ -660,7 +660,6 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -196,6 +196,5 @@ class RelayFeedViewModel :
|
||||
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -43,10 +43,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -90,6 +92,9 @@ class EventSync(
|
||||
|
||||
/** Maximum number of completed-relay entries kept in the activity log. */
|
||||
const val MAX_ACTIVITY_LOG = 5000
|
||||
|
||||
/** Poll interval while waiting for the last forwarded events to be acknowledged. */
|
||||
const val OUTBOX_DRAIN_POLL_MS = 100L
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -392,6 +397,13 @@ class EventSync(
|
||||
|
||||
val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>()
|
||||
|
||||
// (event id, destination) pairs already counted as sent. The outbox is
|
||||
// at-least-once: it writes an event as soon as the socket is ready and
|
||||
// resends everything still unacknowledged when the connection finishes
|
||||
// syncing, so one event can hit the same relay twice before its OK lands.
|
||||
// The relay dedups the second copy; the counters must too.
|
||||
val sentPairs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val runningState =
|
||||
SyncState.Running(
|
||||
relaysCompleted = 0,
|
||||
@@ -423,7 +435,12 @@ class EventSync(
|
||||
success: Boolean,
|
||||
) {
|
||||
super.onSent(relay, cmdStr, cmd, success)
|
||||
if (cmd is EventCmd) {
|
||||
// `success` is "written to the socket", not "OK received". A write to a
|
||||
// destination that is still connecting fails and the outbox resends it
|
||||
// once the socket opens; counting the failed attempt too made every
|
||||
// cold destination report one extra event sent. Likewise a successful
|
||||
// resend of an unacknowledged event is the same send, not a second one.
|
||||
if (cmd is EventCmd && success && sentPairs.add(cmd.event.id + relay.url.url)) {
|
||||
var hasSent = false
|
||||
|
||||
if (outboxDedup.contains(cmd.event.id)) {
|
||||
@@ -586,6 +603,13 @@ class EventSync(
|
||||
},
|
||||
)
|
||||
|
||||
// `publish` is fire-and-forget through the client's outbox, and `use` closes
|
||||
// the client as soon as this block returns. Without a drain, the events
|
||||
// forwarded from the last page of the last relay are still waiting for a
|
||||
// socket or an OK when the outbox is destroyed — the sync reports Done and
|
||||
// silently never delivers them. Bounded by the same per-relay timeout.
|
||||
awaitOutboxDrain(client, outboxDedup + inboxDedup + dmDedup)
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Done(
|
||||
totalEventsReceived = runningState.eventsReceived.value,
|
||||
@@ -613,4 +637,22 @@ class EventSync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until no forwarded event in [ids] has a relay left in the client's outbox, or
|
||||
* until [RELAY_TIMEOUT_MS] passes. Ids that drain are dropped from the working set so
|
||||
* each poll only revisits what is still pending.
|
||||
*/
|
||||
private suspend fun awaitOutboxDrain(
|
||||
client: INostrClient,
|
||||
ids: Set<HexKey>,
|
||||
) {
|
||||
val pending = ids.toMutableSet()
|
||||
withTimeoutOrNull(RELAY_TIMEOUT_MS) {
|
||||
while (pending.isNotEmpty()) {
|
||||
pending.removeAll { client.pendingPublishRelaysFor(it).isNullOrEmpty() }
|
||||
if (pending.isNotEmpty()) delay(OUTBOX_DRAIN_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -116,6 +116,5 @@ open class StringFeedViewModel(
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
bundler.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
currentJob?.cancel()
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -459,7 +459,6 @@ class ReloadMintViewModel : ViewModel() {
|
||||
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would
|
||||
// outlive the screen — cancel it when the screen goes away.
|
||||
job?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
-1
@@ -267,7 +267,6 @@ class TopUpMintViewModel : ViewModel() {
|
||||
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would
|
||||
// outlive the screen — cancel it when the screen goes away.
|
||||
job?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
discovery?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="transparent">#00FFFFFF</color>
|
||||
|
||||
<!-- Launch splash / window background. Tracks MaterialTheme's background so the
|
||||
first composed frame does not step to a different colour. See values-night. -->
|
||||
|
||||
+4
-1
@@ -47,6 +47,9 @@ import java.io.File
|
||||
*/
|
||||
class LocalCacheSearchParityTest {
|
||||
companion object {
|
||||
/** Hoisted out of [loadCorpus]: building a Json format is expensive enough that the compiler warns on it. */
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private lateinit var corpus: List<Event>
|
||||
|
||||
@BeforeClass
|
||||
@@ -57,7 +60,7 @@ class LocalCacheSearchParityTest {
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py")
|
||||
|
||||
val root = Json { ignoreUnknownKeys = true }.parseToJsonElement(file.readText()).jsonObject
|
||||
val root = json.parseToJsonElement(file.readText()).jsonObject
|
||||
corpus =
|
||||
root["cases"]!!
|
||||
.jsonArray
|
||||
|
||||
+1
@@ -153,6 +153,7 @@ class RelayAuthPromptBusTest {
|
||||
* answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that
|
||||
* long despite the user having answered. Marking it shown is what makes the answer land now.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() =
|
||||
runTest {
|
||||
|
||||
+3
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -185,6 +186,7 @@ class RelayAuthSessionGrantsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() =
|
||||
runTest {
|
||||
@@ -205,6 +207,7 @@ class RelayAuthSessionGrantsTest {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun neverAllowStopsAuthenticatingBeforeItsWriteLands() =
|
||||
runTest {
|
||||
|
||||
+2
@@ -70,6 +70,7 @@ class Nip34NotificationCoverageTest {
|
||||
* A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles
|
||||
* modern comments through its own separate wiring.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private val nip34ParticipantKinds =
|
||||
setOf(
|
||||
GitPatchEvent.KIND,
|
||||
@@ -131,6 +132,7 @@ class Nip34NotificationCoverageTest {
|
||||
* uppercase `E`. Asserting the wrong half passes the kind list while matching
|
||||
* nothing on the wire.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `status kinds are pulled by the lowercase-e engagement subscription`() {
|
||||
val eAnchoredActivityKinds =
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
|
||||
|
||||
import com.vitorpamplona.geode.InProcessRelays
|
||||
import com.vitorpamplona.geode.RelayEngine
|
||||
import com.vitorpamplona.geode.testing.RelayClientTest
|
||||
import com.vitorpamplona.geode.testing.preload
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Drives [EventSync] end to end against geode's in-process relays: one
|
||||
* "source" relay that already holds the account's history and three empty
|
||||
* destination relays (outbox / inbox / DM). No network, no public relay —
|
||||
* every relay is a [RelayEngine] inside this JVM, so the assertions are on
|
||||
* what actually landed in each destination store, not on "it didn't crash".
|
||||
*
|
||||
* The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy])
|
||||
* to cover the [RelayAuthenticator] wiring the sync screen relies on when a
|
||||
* user's relay demands AUTH before serving REQs.
|
||||
*/
|
||||
class EventSyncTest : RelayClientTest() {
|
||||
private val account = NostrSignerSync(KeyPair())
|
||||
private val other = NostrSignerSync(KeyPair())
|
||||
|
||||
private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/")
|
||||
private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/")
|
||||
private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/")
|
||||
private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/")
|
||||
|
||||
/** Separate hub so only the source relay demands AUTH; destinations stay open. */
|
||||
private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) })
|
||||
|
||||
@After
|
||||
fun tearDownAuthHub() {
|
||||
authHub.close()
|
||||
}
|
||||
|
||||
private fun note(
|
||||
author: NostrSignerSync,
|
||||
content: String,
|
||||
tagged: HexKey? = null,
|
||||
): Event = author.sign(eventTemplate<Event>(1, content) { tagged?.let { pTag(it) } })
|
||||
|
||||
private fun legacyDm(
|
||||
author: NostrSignerSync,
|
||||
recipient: HexKey,
|
||||
): Event = author.sign(eventTemplate<Event>(4, "ciphertext") { pTag(recipient) })
|
||||
|
||||
private val mine = List(3) { note(account, "mine $it") }
|
||||
private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) }
|
||||
private val dmToMe = legacyDm(other, account.pubKey)
|
||||
private val noise = note(other, "unrelated")
|
||||
|
||||
private fun corpus(): List<Event> = mine + mentions + dmToMe + noise
|
||||
|
||||
/** [decorate] runs on every client the sync builds, e.g. to attach an authenticator. */
|
||||
private fun eventSync(
|
||||
builder: WebsocketBuilder,
|
||||
decorate: (NostrClient) -> Unit = {},
|
||||
): EventSync =
|
||||
EventSync(
|
||||
accountPubKey = account.pubKey,
|
||||
relayDb = { listOf(source) },
|
||||
outboxTargets = { setOf(outbox) },
|
||||
inboxTargets = { setOf(inbox) },
|
||||
dmTargets = { setOf(dm) },
|
||||
clientBuilder = { NostrClient(builder, scope).also(decorate) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/**
|
||||
* Publishes are fire-and-forget on the client side, so the destination
|
||||
* store can lag `runSync` returning by a few ticks. Poll instead of
|
||||
* asserting a snapshot.
|
||||
*/
|
||||
private suspend fun RelayEngine.awaitCount(
|
||||
filter: Filter,
|
||||
expected: Int,
|
||||
): Int =
|
||||
withTimeoutOrNull(10_000) {
|
||||
while (store.count(filter) < expected) delay(25)
|
||||
store.count(filter)
|
||||
} ?: store.count(filter)
|
||||
|
||||
private suspend fun assertRouted(hubOfTargets: InProcessRelays) {
|
||||
val outboxRelay = hubOfTargets.getOrCreate(outbox)
|
||||
val inboxRelay = hubOfTargets.getOrCreate(inbox)
|
||||
val dmRelay = hubOfTargets.getOrCreate(dm)
|
||||
|
||||
assertEquals(
|
||||
"every event authored by the account lands on the outbox relay",
|
||||
mine.size,
|
||||
outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size),
|
||||
)
|
||||
assertEquals(
|
||||
"non-DM mentions land on the inbox relay",
|
||||
mentions.size,
|
||||
inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size),
|
||||
)
|
||||
assertEquals(
|
||||
"the kind-4 DM lands on the DM relay",
|
||||
1,
|
||||
dmRelay.awaitCount(Filter(kinds = listOf(4)), 1),
|
||||
)
|
||||
|
||||
// Routing is exclusive per rule: nothing leaks across destinations and the
|
||||
// unrelated note never leaves the source.
|
||||
assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter()))
|
||||
assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter()))
|
||||
assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter()))
|
||||
assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() =
|
||||
runBlocking {
|
||||
hub.getOrCreate(source).preload(corpus())
|
||||
|
||||
val sync = eventSync(hub)
|
||||
withTimeout(30_000) { sync.runSync() }
|
||||
|
||||
val done = sync.syncState.value
|
||||
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
|
||||
done as EventSync.SyncState.Done
|
||||
assertEquals(
|
||||
"mine + mentions + dm match a routing rule; noise does not",
|
||||
mine.size + mentions.size + 1,
|
||||
done.totalEventsReceived,
|
||||
)
|
||||
// runSync drains the outbox before closing its client, so by the time
|
||||
// Done is published every forwarded event has been written to its
|
||||
// destination socket — not merely queued.
|
||||
assertEquals(
|
||||
"every routed event was sent before the client closed",
|
||||
mine.size + mentions.size + 1,
|
||||
done.totalEventsSent,
|
||||
)
|
||||
|
||||
assertRouted(hub)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncReadsFromAuthRequiredSourceOnceAuthenticated() =
|
||||
runBlocking {
|
||||
authHub.getOrCreate(source).preload(corpus())
|
||||
|
||||
// Source demands NIP-42 before serving REQs; destinations are the open hub.
|
||||
val router =
|
||||
object : WebsocketBuilder {
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
out: WebSocketListener,
|
||||
): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out)
|
||||
}
|
||||
|
||||
val authSigner = NostrSignerSync(KeyPair())
|
||||
var authenticator: RelayAuthenticator? = null
|
||||
val sync =
|
||||
eventSync(router) { client ->
|
||||
authenticator =
|
||||
RelayAuthenticator(client = client, scope = scope) { _, template, _ ->
|
||||
listOf(authSigner.sign(template))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
withTimeout(30_000) { sync.runSync() }
|
||||
} finally {
|
||||
authenticator?.destroy()
|
||||
}
|
||||
|
||||
val done = sync.syncState.value
|
||||
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
|
||||
assertEquals(
|
||||
"the auth-gated source still yields every routed event",
|
||||
mine.size + mentions.size + 1,
|
||||
(done as EventSync.SyncState.Done).totalEventsReceived,
|
||||
)
|
||||
|
||||
assertRouted(hub)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class HexBenchmark {
|
||||
/** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */
|
||||
@Test
|
||||
fun hexIsHex64ThenDecode() {
|
||||
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null }
|
||||
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+9
-9
@@ -9,18 +9,18 @@ import java.util.Properties
|
||||
// compiles this buildscript {} section in an earlier stage that can't see the
|
||||
// file's imports (hence the qualified Properties) or share code with the body,
|
||||
// but it can publish values — the gate is computed once here and read below
|
||||
// via `by extra`.
|
||||
// via the project's extra properties.
|
||||
buildscript {
|
||||
val localProperties = File(rootDir, "local.properties")
|
||||
val sonarProperties by extra(
|
||||
val sonarProperties =
|
||||
java.util.Properties().apply {
|
||||
if (localProperties.exists()) localProperties.inputStream().use { load(it) }
|
||||
},
|
||||
)
|
||||
val sonarEnabled by extra(
|
||||
}
|
||||
extra.set("sonarProperties", sonarProperties)
|
||||
val sonarEnabled =
|
||||
sonarProperties.getProperty("sonar.host.url") != null &&
|
||||
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") },
|
||||
)
|
||||
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }
|
||||
extra.set("sonarEnabled", sonarEnabled)
|
||||
if (sonarEnabled) {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
@@ -110,9 +110,9 @@ subprojects {
|
||||
// `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the
|
||||
// command line. sonar.projectKey/projectName default to the root project name
|
||||
// ("Amethyst") and only need overriding in local.properties if desired.
|
||||
val sonarEnabled: Boolean by extra
|
||||
val sonarEnabled = extra["sonarEnabled"] as Boolean
|
||||
if (sonarEnabled) {
|
||||
val sonarProperties: Properties by extra
|
||||
val sonarProperties = extra["sonarProperties"] as Properties
|
||||
apply(plugin = "org.sonarqube")
|
||||
|
||||
sonarProperties
|
||||
|
||||
+1
-1
@@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage:
|
||||
| Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. |
|
||||
| JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. |
|
||||
| File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. |
|
||||
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
|
||||
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
|
||||
|
||||
The JVM suite drives `runCli` **in-process** through the shared
|
||||
`amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr,
|
||||
|
||||
+4
-4
@@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic.
|
||||
9. **Test suite** — largely in place, two layers:
|
||||
- **Shell harnesses** under `cli/tests/` — ten suites: `blossom`
|
||||
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
|
||||
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`,
|
||||
`marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`,
|
||||
`relaygroup`, `sync`, plus the shared `headless/` helpers. See
|
||||
`cli/tests/README.md`.
|
||||
None run in CI yet (the relay-backed ones need Rust + a ~3 min
|
||||
cold `nostr-rs-relay` build).
|
||||
`cli/tests/README.md`. Every relay-backed suite runs against the
|
||||
embedded `amy serve` relay (geode) — no external relay binary.
|
||||
None run in CI yet (the Marmot ones need Rust for MDK's `wn`).
|
||||
- **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing,
|
||||
exit-code contract, and `--json` shape tests driving `runCli`
|
||||
in-process via the `amy.home` isolation seam.
|
||||
|
||||
@@ -110,7 +110,7 @@ application {
|
||||
// JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy
|
||||
// then signs a kind:7 whose `content` is four replacement characters.
|
||||
// Whitenoise rejects it with "Invalid reaction content".
|
||||
val patchAmyLauncherCharset by tasks.registering {
|
||||
val patchAmyLauncherCharset = tasks.register("patchAmyLauncherCharset") {
|
||||
val appName = application.applicationName
|
||||
val startScriptsTask = tasks.named("startScripts")
|
||||
dependsOn(startScriptsTask)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
marmot/state/
|
||||
marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
cache/state-cache-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
|
||||
+46
-39
@@ -1,21 +1,24 @@
|
||||
# amy CLI test harnesses
|
||||
|
||||
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
|
||||
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
|
||||
or no relay at all, depending on the suite. Eleven directories:
|
||||
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an
|
||||
embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live
|
||||
public servers, or no relay at all, depending on the suite. No suite depends on
|
||||
an external relay binary or a Rust toolchain for its relay: every relay-backed
|
||||
harness boots geode from the `amy` binary it already built, so the relay under
|
||||
test is the same server code that runs in production. Eleven directories:
|
||||
|
||||
```
|
||||
cli/tests/
|
||||
├── lib.sh # shared logging, results, assertions
|
||||
├── headless/ # shared bits used by every harness
|
||||
│ └── helpers.sh
|
||||
│ └── helpers.sh # amy wrappers, assertions, embedded relay boot
|
||||
├── blossom/ # Blossom blob lifecycle vs LIVE public servers
|
||||
│ └── blossom-live.sh
|
||||
├── cache/ # local-store-as-cache semantics (profile show
|
||||
│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay
|
||||
│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve`
|
||||
├── clink/ # CLINK pointer decode — local-only, no relay
|
||||
│ └── clink-headless.sh
|
||||
├── dm/ # NIP-17 DM interop (amy ↔ amy)
|
||||
├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
|
||||
│ ├── dm-interop-headless.sh
|
||||
│ ├── setup.sh # preflight + identities
|
||||
│ └── tests-dm.sh
|
||||
@@ -24,7 +27,7 @@ cli/tests/
|
||||
├── marmot/ # Marmot / MLS group-messaging interop
|
||||
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
|
||||
│ ├── marmot-interop-headless.sh # zero-prompt
|
||||
│ ├── setup.sh # preflight + wn + relay + identities
|
||||
│ ├── setup.sh # preflight + wn + identities
|
||||
│ ├── tests-create.sh # tests 01–05
|
||||
│ ├── tests-manage.sh # tests 06–08, 11
|
||||
│ ├── tests-extras.sh # tests 09, 10, 12, 13
|
||||
@@ -60,7 +63,7 @@ Suite notes:
|
||||
and mined-nonce round-trips through `pow check`.
|
||||
- **`cache/cache-headless.sh`** proves the local store is the source of
|
||||
truth for reads: `profile show` served from cache vs `--refresh`, and
|
||||
`store stat` reporting the right histogram, vs a loopback nostr-rs-relay.
|
||||
`store stat` reporting the right histogram, vs the embedded `amy serve` relay.
|
||||
- **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/
|
||||
list/browse against an embedded relay (`amy serve`, which boots geode) —
|
||||
no external relay binary. geode doesn't sign 39000-39003, so browse/info
|
||||
@@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios:
|
||||
A third, slimmer harness covers the NIP-17 DM surface:
|
||||
|
||||
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
|
||||
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
|
||||
No MDK required — only `amy` and the relay binary (which
|
||||
is shared with the Marmot harness's checkout at
|
||||
`marmot/state-headless/nostr-rs-relay/`).
|
||||
Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay.
|
||||
No MDK, no Rust — only `amy`.
|
||||
|
||||
A harness covers Blossom blob storage (BUD-01/02/04/09) against **live**
|
||||
public servers rather than a loopback relay:
|
||||
@@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
|
||||
|
||||
On the machine that runs the harness:
|
||||
|
||||
- **Rust 1.90+** — install via https://rustup.rs
|
||||
- **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only;
|
||||
the relay is `amy serve`, no Rust needed for it)
|
||||
- **git**, **curl**, **jq** — package manager
|
||||
- **~5 GB disk** for the first-run build of `wn` + `wnd`
|
||||
- Public internet access (for the default relay set and fetching crates)
|
||||
- Internet access for fetching crates on the first build. Test traffic
|
||||
stays on the machine unless you pass `--public-relays`.
|
||||
|
||||
On the Android side:
|
||||
|
||||
- Amethyst installed on an **emulator** or a **physical device**
|
||||
- The device must reach the same relays the harness uses (see below)
|
||||
- The device must reach the harness's embedded relay over the network
|
||||
(see below), or the public relays when running with `--public-relays`
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -240,9 +244,11 @@ The script will, in order:
|
||||
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
|
||||
5. Ask you to paste **your Amethyst account npub** (Identity A). This is
|
||||
cached for subsequent runs.
|
||||
6. Add the default public relays to both daemons and run a sanity check
|
||||
(publish a KP from B, fetch it from C).
|
||||
7. Print an **Amethyst setup checklist** — add the same relays to Amethyst,
|
||||
6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`),
|
||||
add it to both daemons and run a sanity check (publish a KP from B,
|
||||
fetch it from C). With `--public-relays` the default public set is used
|
||||
instead and the relay is not started.
|
||||
7. Print an **Amethyst setup checklist** — add the same relay to Amethyst,
|
||||
publish a KP, verify you are logged in with A.
|
||||
8. Run all 13 tests sequentially. Each test either:
|
||||
- runs `wn` commands fully automatically and asserts on JSON output, **or**
|
||||
@@ -253,9 +259,10 @@ The script will, in order:
|
||||
## Command-line flags
|
||||
|
||||
```
|
||||
--local-relays Use ws://localhost:8080 instead of the default public relays.
|
||||
Required if the public relays reject kinds 444/445/30443.
|
||||
Run 'just docker-up' inside the mdk checkout first.
|
||||
--public-relays Use the public relay set below instead of the embedded relay.
|
||||
The only mode whose test traffic leaves the machine; the
|
||||
public relays may reject kinds 444/445/30443.
|
||||
--port N Port for the embedded relay (default 8080).
|
||||
--transponder Run Test 14 (push notifications via the transponder service).
|
||||
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
|
||||
-h, --help Show help.
|
||||
@@ -267,31 +274,31 @@ Environment overrides:
|
||||
WN_REPO=/some/path/mdk # use an existing checkout
|
||||
```
|
||||
|
||||
## Default relays
|
||||
## Relays
|
||||
|
||||
By default the harness owns the only relay: `amy serve` (geode) bound to
|
||||
`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it
|
||||
over the network:
|
||||
|
||||
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays,
|
||||
Settings → Key Package Relays and Settings → DM Inbox Relays.
|
||||
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
|
||||
|
||||
With `--public-relays` the daemons are bootstrapped on
|
||||
|
||||
```
|
||||
wss://relay.damus.io
|
||||
wss://nos.lol
|
||||
wss://relay.primal.net
|
||||
wss://nostr.bitcoiner.social
|
||||
wss://nostr.mom
|
||||
```
|
||||
|
||||
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable
|
||||
events). If the **sanity check fails** — meaning C cannot read the KeyPackage
|
||||
that B just published — the harness warns you and continues. In that case
|
||||
re-run with `--local-relays` after starting the Docker stack:
|
||||
|
||||
```bash
|
||||
cd state/mdk
|
||||
just docker-up
|
||||
cd ../..
|
||||
./marmot-interop.sh --local-relays
|
||||
```
|
||||
|
||||
For Amethyst with `--local-relays`:
|
||||
|
||||
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and
|
||||
Settings → Key Package Relays.
|
||||
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
|
||||
instead and Amethyst is left on its own relay set, so the run surfaces
|
||||
real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the
|
||||
public relays drop). If the **sanity check fails** in that mode — meaning C
|
||||
cannot read the KeyPackage that B just published — the harness warns you and
|
||||
continues; re-run without `--public-relays` to rule the relays out.
|
||||
|
||||
## How human interaction works
|
||||
|
||||
|
||||
Vendored
+11
-11
@@ -3,8 +3,8 @@
|
||||
# cache-headless.sh — verifies the file-backed event store is the
|
||||
# source of truth for `amy` reads.
|
||||
#
|
||||
# Two amy identities (A and B) talk to a local nostr-rs-relay. We
|
||||
# assert that:
|
||||
# Two amy identities (A and B) talk to a local embedded relay
|
||||
# (`amy serve`, i.e. geode). We assert that:
|
||||
#
|
||||
# 1. After A runs `amy create`, A's local store contains the bootstrap
|
||||
# events (kind:0 / 3 / 10002 / 10050 / 10051 …).
|
||||
@@ -39,10 +39,13 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Reuse the relay binary the marmot harness builds.
|
||||
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
|
||||
# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity
|
||||
# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all
|
||||
# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like
|
||||
# 127.0.0.1. Nothing here depends on that parse — amy publishes to and
|
||||
# reads from the relay it was told about.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8092}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -72,14 +75,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay
|
||||
source "$TESTS_DIR/marmot/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
|
||||
# Keep the dm setup's preflight (just checks for amy + the relay) but
|
||||
# define our own identity bootstrap so we don't pull in DM-specific
|
||||
# wiring.
|
||||
# Keep the dm setup's preflight (just checks for amy) but define our own
|
||||
# identity bootstrap so we don't pull in DM-specific wiring.
|
||||
# shellcheck source=../dm/setup.sh
|
||||
source "$TESTS_DIR/dm/setup.sh"
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness.
|
||||
#
|
||||
# Two `amy` processes (Identity A and Identity D) talk to each other
|
||||
# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No
|
||||
# whitenoise-rs, no Marmot, no public internet traffic.
|
||||
# through a local embedded relay (`amy serve`, i.e. geode) on
|
||||
# ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no
|
||||
# public internet traffic.
|
||||
#
|
||||
# Usage: ./dm-interop-headless.sh [--port N] [--no-build]
|
||||
#
|
||||
@@ -26,16 +27,16 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Share the nostr-rs-relay checkout with the Marmot harness to avoid
|
||||
# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full
|
||||
# isolation between runs.
|
||||
# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's
|
||||
# `isLocalHost()` filter doesn't silently strip it out of the kind:10050
|
||||
# inbox events during recipient-relay resolution. 127.0.0.2 is still pure
|
||||
# loopback — no network traffic, no config needed.
|
||||
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
|
||||
# start_local_relay in headless/helpers.sh. Override RELAY_DATA if you
|
||||
# want full isolation between runs.
|
||||
# 127.0.0.2 used to dodge Quartz's `isLocalHost()` strip of loopback
|
||||
# relays in kind:10050 inbox lists. That filter now covers all of
|
||||
# 127.0.0.0/8, so the strict-inbox sends (dm-01/02/05/06) fail with
|
||||
# no_dm_relays regardless of which loopback address the relay binds;
|
||||
# only the fallback-chain tests (dm-03/04) are unaffected. Kept for
|
||||
# parity with the other harnesses until that routing rule is revisited.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8090}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -68,12 +69,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
|
||||
# Reuse start_local_relay / stop_local_relay from the Marmot harness's
|
||||
# setup.sh — the relay lifecycle is identical. preflight() there also
|
||||
# builds whitenoise-rs, which we don't need; setup.sh in this dir
|
||||
# defines a slimmer preflight_dm().
|
||||
# shellcheck source=../marmot/setup.sh
|
||||
source "$TESTS_DIR/marmot/setup.sh"
|
||||
# setup.sh in this dir defines the slim preflight_dm() (amy only, no
|
||||
# MDK); the relay lifecycle (start_local_relay / stop_local_relay, the
|
||||
# embedded `amy serve`) comes from the shared headless helpers.
|
||||
# shellcheck source=setup.sh
|
||||
source "$SCRIPT_DIR/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
|
||||
+7
-29
@@ -3,20 +3,21 @@
|
||||
# setup.sh — amy-only preflight + identity bootstrap for the
|
||||
# NIP-17 DM interop harness. Much slimmer than the Marmot setup:
|
||||
#
|
||||
# - Builds `amy` (same retry-on-503 logic as setup.sh).
|
||||
# - Builds nostr-rs-relay if missing.
|
||||
# - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback
|
||||
# relay is `amy serve` (geode), so amy is the only binary needed.
|
||||
# - Bootstraps two fresh amy identities (A and D), each with its own
|
||||
# `--data-dir`, both pointed at the loopback relay.
|
||||
# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict
|
||||
# recipient-inbox routing has something to resolve to.
|
||||
#
|
||||
# The heavy `start_local_relay` / `stop_local_relay` helpers live in
|
||||
# the Marmot harness's setup.sh and are sourced by the top-level harness.
|
||||
# The `start_local_relay` / `stop_local_relay` helpers (embedded
|
||||
# `amy serve`) live in ../headless/helpers.sh and are sourced by the
|
||||
# top-level harness.
|
||||
|
||||
# --- preflight (amy + relay only, no wn / Marmot patches) -------------------
|
||||
# --- preflight (amy only, no wn / Marmot patches, no Rust) -------------------
|
||||
preflight_dm() {
|
||||
banner "Preflight (DM harness)"
|
||||
for cmd in jq git cargo; do
|
||||
for cmd in jq git curl; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
fail_msg "missing required tool: $cmd"
|
||||
exit 1
|
||||
@@ -43,29 +44,6 @@ preflight_dm() {
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
|
||||
info "amy: $AMY_BIN"
|
||||
|
||||
# nostr-rs-relay (same build path as the Marmot harness).
|
||||
if [[ ! -x "$RELAY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
if [[ ! -d "$RELAY_REPO/.git" ]]; then
|
||||
step "cloning nostr-rs-relay into $RELAY_REPO"
|
||||
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
local attempt max=4
|
||||
for attempt in $(seq 1 $max); do
|
||||
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
|
||||
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
[[ -x "$RELAY_BIN" ]] && break
|
||||
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying"
|
||||
done
|
||||
[[ -x "$RELAY_BIN" ]] || {
|
||||
fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "relay bin: $RELAY_BIN"
|
||||
}
|
||||
|
||||
# --- amy identity wrappers ---------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# tests-dm.sh — NIP-17 DM interop tests for two `amy` clients.
|
||||
#
|
||||
# Identity A (sender) and Identity D (recipient) each live in their own
|
||||
# --data-dir and share one loopback nostr-rs-relay. Tests cover:
|
||||
# --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover:
|
||||
#
|
||||
# dm-01 text round-trip (both directions)
|
||||
# dm-02 dm list surfaces prior exchange with type:text discriminator
|
||||
|
||||
@@ -66,5 +66,94 @@ assert_eq() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- embedded relay (amy serve → geode) --------------------------------------
|
||||
# Every relay-backed harness talks to ONE loopback relay, and that relay is
|
||||
# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy
|
||||
# binary the harness already built. No Rust toolchain, no clone, no cargo
|
||||
# build, no external relay binary: the relay under test is part of the
|
||||
# product, so a harness run exercises the same server code `amy serve`
|
||||
# and the standalone geode distribution run in production.
|
||||
#
|
||||
# Callers set (before sourcing or at least before calling):
|
||||
# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`)
|
||||
# RELAY_HOST host clients connect to. The harnesses use 127.0.0.2 for
|
||||
# parity with each other; note that Quartz's isLocalHost()
|
||||
# treats all of 127.0.0.0/8 as loopback, so it does NOT
|
||||
# survive the NIP-17 / NIP-65 relay-list parsers any better
|
||||
# than 127.0.0.1 does.
|
||||
# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to
|
||||
# 0.0.0.0 when a device on the LAN must reach the relay.
|
||||
# RELAY_PORT listen port
|
||||
# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT
|
||||
# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs
|
||||
#
|
||||
# The relay process runs as its own amy account ("relay") inside its own
|
||||
# $HOME under $RELAY_DATA, so its identity and store never mix with the
|
||||
# test identities. The store is in-memory (amy serve's default): every run
|
||||
# starts from an empty relay, matching the state wipe the harnesses do.
|
||||
start_local_relay() {
|
||||
banner "Starting embedded relay (amy serve / geode) on $RELAY_URL"
|
||||
local relay_home="$RELAY_DATA/home"
|
||||
local bind="${RELAY_BIND:-$RELAY_HOST}"
|
||||
mkdir -p "$relay_home" "$RELAY_DATA/logs"
|
||||
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; }
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
# with a clear error beats a mysterious-looking daemon stall later.
|
||||
# bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on
|
||||
# loopback returns immediately.
|
||||
if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then
|
||||
fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `amy serve` resolves its admin pubkey from the account, so the relay
|
||||
# needs an identity of its own. Idempotent across --reuse-state runs.
|
||||
if [[ ! -d "$relay_home/.amy/relay" ]]; then
|
||||
HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \
|
||||
>"$RELAY_DATA/logs/init.log" 2>&1 \
|
||||
|| { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; }
|
||||
fi
|
||||
|
||||
nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \
|
||||
serve --host "$bind" --port "$RELAY_PORT" \
|
||||
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
|
||||
echo "$!" > "$RELAY_DATA/pid"
|
||||
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
|
||||
|
||||
# Readiness = the NIP-11 document answers on the same port. geode serves
|
||||
# it on a plain GET with `Accept: application/nostr+json` (anything else
|
||||
# gets a 426 hint, which curl -f would treat as failure).
|
||||
local deadline=$(( $(date +%s) + 60 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sSf -m 1 -H 'Accept: application/nostr+json' \
|
||||
"http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then
|
||||
info "relay up"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
|
||||
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_local_relay() {
|
||||
local pid_file="$RELAY_DATA/pid"
|
||||
[[ -f "$pid_file" ]] || return 0
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping relay pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
|
||||
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
|
||||
# Both exist in lib.sh already; this file only adds headless-specific niceties.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
|
||||
#
|
||||
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
|
||||
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local
|
||||
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
|
||||
# machine. Matches the 13 test scenarios in marmot-interop.sh but without
|
||||
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay
|
||||
# (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever
|
||||
# leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without
|
||||
# any human prompts — all checks run to completion and the exit code
|
||||
# reflects pass/fail totals.
|
||||
#
|
||||
@@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn"
|
||||
WND_BIN="$WN_REPO/target/release/wnd"
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Local relay wiring — cloned + built during preflight, started on
|
||||
# $RELAY_PORT. The harness never touches the public internet for test
|
||||
# traffic; wn/wnd/amy all point at this one loopback endpoint.
|
||||
# Local relay wiring — the embedded `amy serve` (geode), started on
|
||||
# $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness
|
||||
# never touches the public internet for test traffic; wn/wnd/amy all
|
||||
# point at this one loopback endpoint.
|
||||
#
|
||||
# Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer
|
||||
# strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox
|
||||
@@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
# Amethyst's public defaults instead of the loopback. 127.0.0.2 is
|
||||
# still pure loopback (no network traffic) but isn't on the strip list.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
|
||||
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
|
||||
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
@@ -73,7 +72,7 @@ BLOSSOM_PID=""
|
||||
|
||||
NO_BUILD=0
|
||||
# Every run starts from empty stores. wnd already wipes B's and C's data dirs
|
||||
# on each start, but A's amy home and the relay's SQLite file used to survive,
|
||||
# on each start, but A's amy home and the relay's state used to survive,
|
||||
# and the leftovers are not inert: a KeyPackage A published in an earlier run
|
||||
# is still on the relay for B to invite with, an old group's kind:445 events
|
||||
# still arrive and fail to decrypt, and A's cursors still say it has seen them.
|
||||
@@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then
|
||||
# Keep the relay checkout + its build (minutes to rebuild) and the log and
|
||||
# results history; drop everything that holds protocol state.
|
||||
# Keep the log and results history; drop everything that holds protocol
|
||||
# state (the relay is in-memory, so wiping its dir just drops its identity).
|
||||
#
|
||||
# run.env counts as protocol state: it is where tests hand each other group
|
||||
# ids. Leaving it behind a wipe leaves ids naming groups nobody is in any
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
# Sequential, all-or-nothing. Script drives the `wn` side automatically and
|
||||
# prompts the human operator at each step that requires Amethyst UI action.
|
||||
#
|
||||
# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build]
|
||||
# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build]
|
||||
#
|
||||
# By default the harness boots its own relay — `amy serve`, i.e. geode — bound
|
||||
# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone
|
||||
# reaches it over the LAN (ws://<laptop-ip>:PORT, or ws://10.0.2.2:PORT from an
|
||||
# emulator). Pass --public-relays to run the old real-world path against the
|
||||
# public relay set instead; that is the only mode that touches the internet.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
B_DIR="$STATE_DIR/B"
|
||||
@@ -27,6 +34,24 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
|
||||
WN_BIN=""
|
||||
WND_BIN=""
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
|
||||
# Embedded relay (default mode). Bound on every interface so a device on the
|
||||
# same network can reach it; the daemons connect over $RELAY_HOST. Loopback
|
||||
# `ws://` relays are only accepted by MDK behind this explicit opt-in.
|
||||
#
|
||||
# Known limit, inherited from the old --local-relays mode: the URL wn
|
||||
# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's
|
||||
# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome
|
||||
# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an
|
||||
# address the device can dial (e.g. the laptop's LAN IP) to have wn
|
||||
# advertise that instead; the daemons then connect to it too.
|
||||
RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
|
||||
RELAY_BIND="${RELAY_BIND:-0.0.0.0}"
|
||||
RELAY_PORT="${RELAY_PORT:-8080}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
RELAY_DATA="$STATE_DIR/relay"
|
||||
export WN_ALLOW_LOOPBACK_RELAYS=1
|
||||
B_NPUB=""
|
||||
B_HEX=""
|
||||
C_NPUB=""
|
||||
@@ -34,6 +59,7 @@ C_HEX=""
|
||||
A_NPUB=""
|
||||
A_HEX=""
|
||||
|
||||
# Only used with --public-relays.
|
||||
DEFAULT_RELAYS=(
|
||||
"wss://relay.damus.io"
|
||||
"wss://nos.lol"
|
||||
@@ -41,7 +67,7 @@ DEFAULT_RELAYS=(
|
||||
"wss://nostr.bitcoiner.social"
|
||||
"wss://nostr.mom"
|
||||
)
|
||||
USE_LOCAL_RELAYS=0
|
||||
USE_PUBLIC_RELAYS=0
|
||||
ENABLE_TRANSPONDER=0
|
||||
NO_BUILD=0
|
||||
|
||||
@@ -50,7 +76,9 @@ usage() {
|
||||
marmot-interop.sh — Amethyst <-> MDK interop harness
|
||||
|
||||
Options:
|
||||
--local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up')
|
||||
--public-relays Use the public relay set instead of the embedded relay
|
||||
(amy serve / geode, the default). Only mode that leaves the machine.
|
||||
--port N Port for the embedded relay (default 8080)
|
||||
--transponder Run Test 14 (MIP-05 push notifications)
|
||||
--no-build Don't rebuild wn/wnd if binaries are missing
|
||||
-h, --help Show this help
|
||||
@@ -62,8 +90,12 @@ EOF
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--local-relays) USE_LOCAL_RELAYS=1 ;;
|
||||
--transponder) ENABLE_TRANSPONDER=1 ;;
|
||||
--public-relays) USE_PUBLIC_RELAYS=1 ;;
|
||||
--local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;;
|
||||
--port)
|
||||
[[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; }
|
||||
RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--transponder) ENABLE_TRANSPONDER=1 ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
@@ -77,6 +109,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
preflight() {
|
||||
@@ -88,6 +122,26 @@ preflight() {
|
||||
printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE"
|
||||
done
|
||||
|
||||
# The embedded relay is `amy serve`, so amy has to exist unless the run
|
||||
# goes to the public relays. Same transient-503 retry as the headless
|
||||
# harness: one bad jitpack/dl.google.com roll must not abort the run.
|
||||
if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
local attempt max_attempts=4
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
step "building :cli:installDist (attempt $attempt/$max_attempts)"
|
||||
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
|
||||
&& [[ -x "$AMY_BIN" ]]; then
|
||||
break
|
||||
fi
|
||||
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
|
||||
done
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
|
||||
printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
WN_BIN="$WN_REPO/target/release/wn"
|
||||
WND_BIN="$WN_REPO/target/release/wnd"
|
||||
|
||||
@@ -285,7 +339,7 @@ discover_a_relays() {
|
||||
if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
|
||||
info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working"
|
||||
else
|
||||
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}."
|
||||
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}."
|
||||
warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach."
|
||||
warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)."
|
||||
fi
|
||||
@@ -300,7 +354,7 @@ discover_a_relays() {
|
||||
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
warn "sqlite3 not installed — skipping wn user_relays cache probe."
|
||||
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays."
|
||||
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays."
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -383,12 +437,7 @@ discover_a_relays() {
|
||||
# --- relays ------------------------------------------------------------------
|
||||
configure_relays() {
|
||||
banner "Configuring relays"
|
||||
local relays=()
|
||||
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
|
||||
relays=( "ws://localhost:8080" )
|
||||
else
|
||||
relays=( "${DEFAULT_RELAYS[@]}" )
|
||||
fi
|
||||
local relays=( "${RELAY_LIST[@]}" )
|
||||
# Each relay × 3 types × 2 daemons produces a lot of repetitive "ok"
|
||||
# lines — the happy path doesn't need any of it on screen. Quiet the
|
||||
# per-add logging into $LOG_FILE and only surface real failures as
|
||||
@@ -527,27 +576,28 @@ configure_relays() {
|
||||
info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)"
|
||||
else
|
||||
warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)"
|
||||
warn "Consider rerunning with --local-relays."
|
||||
warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
|
||||
fi
|
||||
# best-effort cleanup so re-runs don't accumulate dead sanity groups
|
||||
wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true
|
||||
wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
|
||||
else
|
||||
warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
|
||||
warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)."
|
||||
warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
instruct_amethyst_setup() {
|
||||
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
|
||||
# Offline/sandbox path: we own the only relay, so the harness DOES
|
||||
# need to dictate Amethyst's relay config — nothing is discoverable
|
||||
# via the public network.
|
||||
prompt_human "Configure Amethyst to match this --local-relays harness:
|
||||
if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then
|
||||
# Offline/sandbox path (default): we own the only relay — the embedded
|
||||
# `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need
|
||||
# to dictate Amethyst's relay config; nothing is discoverable via the
|
||||
# public network.
|
||||
prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode):
|
||||
1. Settings -> Relays: add as READ+WRITE
|
||||
ws://10.0.2.2:8080 (Android emulator)
|
||||
ws://<your-LAN-ip>:8080 (physical device on same Wi-Fi)
|
||||
ws://10.0.2.2:$RELAY_PORT (Android emulator)
|
||||
ws://<your-LAN-ip>:$RELAY_PORT (physical device on same Wi-Fi)
|
||||
2. Settings -> Key Package Relays: add the SAME URL
|
||||
3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL
|
||||
4. Trigger key-package publish (toggle KP relay on/off if needed)
|
||||
@@ -555,7 +605,7 @@ instruct_amethyst_setup() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Public-relay path: the harness should behave like any real Nostr
|
||||
# --public-relays path: the harness should behave like any real Nostr
|
||||
# client — discover A's advertised relays via kind:10002 / 10050 /
|
||||
# 10051 and publish there, rather than forcing A to adopt the
|
||||
# harness's own relay set. That lets the tests surface real-world
|
||||
@@ -1301,6 +1351,7 @@ main() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM HUP
|
||||
stop_daemons
|
||||
stop_local_relay
|
||||
print_summary
|
||||
exit "$rc"
|
||||
}
|
||||
@@ -1311,6 +1362,12 @@ main() {
|
||||
banner "Amethyst <-> MDK interop harness ($RUN_TS)"
|
||||
|
||||
preflight
|
||||
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
|
||||
RELAY_LIST=( "${DEFAULT_RELAYS[@]}" )
|
||||
else
|
||||
RELAY_LIST=( "$RELAY_URL" )
|
||||
start_local_relay
|
||||
fi
|
||||
start_daemon B "$B_DIR" "$B_SOCKET"
|
||||
start_daemon C "$C_DIR" "$C_SOCKET"
|
||||
ensure_identity B
|
||||
@@ -1327,7 +1384,7 @@ main() {
|
||||
# plane, then summarise what wn sees. Surfaces up front the kind of
|
||||
# failure (A's 10050 unreachable from wn, missing KP list, etc.) that
|
||||
# would otherwise bite as a silent Test 03 timeout.
|
||||
if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then
|
||||
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
|
||||
discover_a_relays
|
||||
fi
|
||||
|
||||
|
||||
@@ -147,29 +147,8 @@ preflight() {
|
||||
info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))"
|
||||
info "wnd: $WND_BIN"
|
||||
|
||||
# Clone/build nostr-rs-relay — the harness's single loopback relay.
|
||||
if [[ ! -x "$RELAY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
if [[ ! -d "$RELAY_REPO/.git" ]]; then
|
||||
step "cloning nostr-rs-relay into $RELAY_REPO"
|
||||
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
local attempt max=4
|
||||
for attempt in $(seq 1 $max); do
|
||||
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
|
||||
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
[[ -x "$RELAY_BIN" ]] && break
|
||||
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying"
|
||||
done
|
||||
[[ -x "$RELAY_BIN" ]] || {
|
||||
fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1
|
||||
}
|
||||
fi
|
||||
info "relay bin: $RELAY_BIN"
|
||||
# The loopback relay is `amy serve` (geode) — see start_local_relay in
|
||||
# ../headless/helpers.sh. Nothing to clone or build beyond amy itself.
|
||||
}
|
||||
|
||||
# --- local QUIC broker -------------------------------------------------------
|
||||
@@ -262,75 +241,8 @@ stop_quic_broker() {
|
||||
}
|
||||
|
||||
# --- local relay -------------------------------------------------------------
|
||||
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
|
||||
# runs against this one loopback endpoint — no external network traffic.
|
||||
start_local_relay() {
|
||||
banner "Starting local nostr-rs-relay on $RELAY_URL"
|
||||
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
|
||||
|
||||
# Render a minimal config file each run so port/limits come from the
|
||||
# harness rather than whatever was left on disk from a previous session.
|
||||
cat >"$RELAY_DATA/config.toml" <<EOF
|
||||
[info]
|
||||
relay_url = "$RELAY_URL"
|
||||
name = "amethyst-headless-harness"
|
||||
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
|
||||
|
||||
[database]
|
||||
data_directory = "$RELAY_DATA"
|
||||
|
||||
[network]
|
||||
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
|
||||
port = $RELAY_PORT
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 3600
|
||||
|
||||
[limits]
|
||||
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
|
||||
# exercising Marmot traffic the public relays reject.
|
||||
max_event_bytes = 524288
|
||||
max_ws_message_bytes = 1048576
|
||||
max_ws_frame_bytes = 1048576
|
||||
EOF
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
# with a clear error beats a mysterious-looking daemon stall later.
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
|
||||
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
|
||||
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
|
||||
echo "$!" > "$RELAY_DATA/pid"
|
||||
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
|
||||
|
||||
local deadline=$(( $(date +%s) + 20 ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then
|
||||
info "relay up"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
|
||||
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_local_relay() {
|
||||
local pid_file="$RELAY_DATA/pid"
|
||||
[[ -f "$pid_file" ]] || return 0
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
info "stopping relay pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
# start_local_relay / stop_local_relay live in ../headless/helpers.sh: the
|
||||
# relay is the embedded `amy serve` (geode), shared by every harness.
|
||||
|
||||
# --- daemons -----------------------------------------------------------------
|
||||
start_daemon() {
|
||||
@@ -480,9 +392,9 @@ configure_relays() {
|
||||
|
||||
step "publishing A's KeyPackage"
|
||||
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
|
||||
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443
|
||||
# Give the relay a breath to ingest the kind:10002 / 10050 / 30443
|
||||
# writes and push them out on the discovery subscription so that the
|
||||
# first `wn keys check` that follows actually sees them instead of
|
||||
# racing the relay's WAL flush.
|
||||
# racing the relay's ingest queue.
|
||||
sleep 2
|
||||
}
|
||||
|
||||
+5
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* What a front end renders for one live agent text stream.
|
||||
@@ -203,6 +204,9 @@ class MarmotAgentStreamWatcher(
|
||||
try {
|
||||
quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray())
|
||||
} catch (e: Exception) {
|
||||
// Without this a cancelled watcher keeps dialling the remaining
|
||||
// candidates instead of stopping.
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" }
|
||||
continue
|
||||
}
|
||||
@@ -222,6 +226,7 @@ class MarmotAgentStreamWatcher(
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" }
|
||||
} finally {
|
||||
runCatching { stream.close() }
|
||||
|
||||
-1
@@ -68,6 +68,5 @@ abstract class FeedViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.toLongValue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -164,7 +165,7 @@ class LiveStreamTopZappersViewModel(
|
||||
when (val ev = note.event) {
|
||||
is LnZapEvent -> {
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
val sats = ev.amount()?.toLongValue() ?: return null
|
||||
ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
is Bolt12ZapEvent -> {
|
||||
@@ -180,7 +181,7 @@ class LiveStreamTopZappersViewModel(
|
||||
): ZapContribution? {
|
||||
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
|
||||
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
|
||||
val sats = receiptEv.amount()?.toLong() ?: return null
|
||||
val sats = receiptEv.amount()?.toLongValue() ?: return null
|
||||
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.toLongValue
|
||||
|
||||
/**
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on the
|
||||
@@ -59,7 +60,7 @@ data class RoomZap(
|
||||
eventId = event.id,
|
||||
sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey,
|
||||
targetPubkey = event.zappedAuthor().firstOrNull(),
|
||||
amountSats = event.amount?.toLong(),
|
||||
amountSats = event.amount?.toLongValue(),
|
||||
createdAtSec = event.createdAt,
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ class PartialTokensTest {
|
||||
fun aHalfWrittenFromOpensThePeoplePicker() {
|
||||
val picker = pickerAtEnd("zaps from:ali")
|
||||
assertTrue(picker is ActivePicker.People)
|
||||
assertEquals(KeyField.FROM, (picker as ActivePicker.People).keyField)
|
||||
assertEquals(KeyField.FROM, picker.keyField)
|
||||
assertEquals("ali", picker.token.partial)
|
||||
assertEquals(5, picker.token.start)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class PartialTokensTest {
|
||||
fun aHalfWrittenDateOpensTheCalendar() {
|
||||
val picker = pickerAtEnd("since:2026-0")
|
||||
assertTrue(picker is ActivePicker.Calendar)
|
||||
assertEquals(DateField.SINCE, (picker as ActivePicker.Calendar).dateField)
|
||||
assertEquals(DateField.SINCE, picker.dateField)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-1
@@ -838,7 +838,6 @@ class NestViewModel(
|
||||
closed = true
|
||||
teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true)
|
||||
teardown(targetState = ConnectionUiState.Closed, finalCleanup = true)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun observeSpeakerState(s: NestsSpeaker) {
|
||||
|
||||
+2
-2
@@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest {
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
epochs: List<ByteArray>,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
retained[nostrGroupId] = epochs
|
||||
retained[nostrGroupId] = retainedSecrets
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
|
||||
|
||||
+2
-2
@@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest {
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
epochs: List<ByteArray>,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
retained[nostrGroupId] = epochs
|
||||
retained[nostrGroupId] = retainedSecrets
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty()
|
||||
|
||||
@@ -255,7 +255,7 @@ compose.desktop {
|
||||
// The arch is selected at task-execution time from the host JVM's os.arch, so
|
||||
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
|
||||
// BUILDING.md documents local-dev fetch.
|
||||
val createReleaseAppImage by tasks.registering(Exec::class) {
|
||||
val createReleaseAppImage = tasks.register<Exec>("createReleaseAppImage") {
|
||||
group = "compose desktop"
|
||||
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
|
||||
dependsOn("createReleaseDistributable")
|
||||
@@ -331,7 +331,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) {
|
||||
// proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is
|
||||
// a regression guard, not a workaround. It's wired onto every release task so
|
||||
// it fails the build immediately if the .so disappears.
|
||||
val verifyJkeychainNativeSurvivesProguard by tasks.registering {
|
||||
val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") {
|
||||
description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)"
|
||||
group = "verification"
|
||||
dependsOn("proguardReleaseJars")
|
||||
@@ -396,7 +396,7 @@ listOf(
|
||||
// into the .app) so the subsequent bundle signing seals already-signed code.
|
||||
// Runs only on macOS with the Developer ID identity exported — a no-op on every
|
||||
// other leg and on unsigned local/PR builds.
|
||||
val signMacJarNatives by tasks.registering {
|
||||
val signMacJarNatives = tasks.register("signMacJarNatives") {
|
||||
description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized"
|
||||
group = "build"
|
||||
dependsOn("proguardReleaseJars")
|
||||
|
||||
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowOverflow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -146,7 +145,6 @@ fun LivesSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
maxLines = 2,
|
||||
overflow = FlowRowOverflow.Clip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ranked.forEach { channel ->
|
||||
|
||||
@@ -22,7 +22,7 @@ kotlin {
|
||||
// Generate a BuildConfig.kt carrying the app version from the catalog so
|
||||
// RelayInfo.VERSION (reported over NIP-11) tracks releases automatically
|
||||
// instead of being a hand-bumped literal.
|
||||
val generateVersionFile by tasks.registering {
|
||||
val generateVersionFile = tasks.register("generateVersionFile") {
|
||||
val versionValue = libs.versions.app.get()
|
||||
val outDir = layout.buildDirectory.dir("generated/version/kotlin")
|
||||
inputs.property("version", versionValue)
|
||||
|
||||
@@ -311,14 +311,14 @@ class KtorRelayTest {
|
||||
)
|
||||
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
|
||||
|
||||
// Duplicate insert returns OK false; this also exercises the
|
||||
// "non-empty message" branch of the serializer.
|
||||
// Duplicate insert returns OK true with a `duplicate:` message (NIP-01);
|
||||
// this also exercises the "non-empty message" branch of the serializer.
|
||||
val ok2 =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
|
||||
assertEquals(true, ok2, "duplicate insert must round-trip OK true (NIP-01 duplicate:)")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// the authoritative one. Keeping the pattern table in one file means a new
|
||||
// forbidden API lands in every module at once instead of drifting per copy.
|
||||
|
||||
val verifyKmpPurity by tasks.registering {
|
||||
val verifyKmpPurity = tasks.register("verifyKmpPurity") {
|
||||
group = "verification"
|
||||
description = "Fails if iOS-targeted source sets import JVM-only deps."
|
||||
// Every source set that feeds an Apple target. Modules that don't have a
|
||||
|
||||
@@ -28,6 +28,7 @@ uiautomator = "2.4.0"
|
||||
biometricKtx = "1.4.0-alpha02"
|
||||
coil = "3.6.2"
|
||||
composeBom = "2026.09.00"
|
||||
tracingPerfetto = "1.0.1"
|
||||
composeRuntimeAnnotation = "1.12.1"
|
||||
coreKtx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
@@ -136,6 +137,9 @@ androidx-camera-extensions = { module = "androidx.camera:camera-extensions", ver
|
||||
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
|
||||
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing" }
|
||||
androidx-tracing-perfetto = { group = "androidx.tracing", name = "tracing-perfetto", version.ref = "tracingPerfetto" }
|
||||
androidx-tracing-perfetto-binary = { group = "androidx.tracing", name = "tracing-perfetto-binary", version.ref = "tracingPerfetto" }
|
||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" }
|
||||
androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" }
|
||||
|
||||
@@ -24,12 +24,10 @@ import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.marmot.ingest
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@@ -170,7 +168,7 @@ fun benchJoinWelcome(): BenchResult =
|
||||
}
|
||||
},
|
||||
) { (bob, wrap) ->
|
||||
runBlocking { bob.manager.ingest(wrap as GiftWrapEvent) }
|
||||
runBlocking { bob.manager.ingest(wrap) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +221,7 @@ fun benchIngestAppMessage(members: Int): BenchResult =
|
||||
}
|
||||
},
|
||||
) { (bob, event) ->
|
||||
runBlocking { bob.manager.ingest(event as GroupEvent) }
|
||||
runBlocking { bob.manager.ingest(event) }
|
||||
}
|
||||
|
||||
private const val BENCH_RELAY = "wss://bench.invalid"
|
||||
|
||||
@@ -154,7 +154,7 @@ val hangInteropCacheDir =
|
||||
val moqRelayVersion = "0.10.25"
|
||||
val moqTokenCliVersion = "0.5.23"
|
||||
|
||||
val interopInstallMoqRelay by tasks.registering(Exec::class) {
|
||||
val interopInstallMoqRelay = tasks.register<Exec>("interopInstallMoqRelay") {
|
||||
description = "cargo install moq-relay $moqRelayVersion (interop)"
|
||||
group = "interop"
|
||||
commandLine(
|
||||
@@ -178,7 +178,7 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) {
|
||||
doFirst { hangInteropCacheDir.asFile.mkdirs() }
|
||||
}
|
||||
|
||||
val interopInstallMoqTokenCli by tasks.registering(Exec::class) {
|
||||
val interopInstallMoqTokenCli = tasks.register<Exec>("interopInstallMoqTokenCli") {
|
||||
description = "cargo install moq-token-cli $moqTokenCliVersion (interop)"
|
||||
group = "interop"
|
||||
commandLine(
|
||||
@@ -201,7 +201,7 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) {
|
||||
doFirst { hangInteropCacheDir.asFile.mkdirs() }
|
||||
}
|
||||
|
||||
val interopBuildSidecars by tasks.registering(Exec::class) {
|
||||
val interopBuildSidecars = tasks.register<Exec>("interopBuildSidecars") {
|
||||
description = "cargo build --release for nestsClient/tests/hang-interop sidecars"
|
||||
group = "interop"
|
||||
workingDir = hangInteropDir.asFile
|
||||
@@ -226,7 +226,7 @@ val interopBuildSidecars by tasks.registering(Exec::class) {
|
||||
outputs.dir(hangInteropDir.dir("target/release"))
|
||||
}
|
||||
|
||||
val interopBuildHangSidecars by tasks.registering {
|
||||
val interopBuildHangSidecars = tasks.register("interopBuildHangSidecars") {
|
||||
description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)."
|
||||
group = "interop"
|
||||
dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli)
|
||||
@@ -305,7 +305,7 @@ fun resolveBunBinary(): String {
|
||||
fun resolveNpxBinary(): String =
|
||||
System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx"
|
||||
|
||||
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
|
||||
val interopBuildBrowserHarness = tasks.register<Exec>("interopBuildBrowserHarness") {
|
||||
description = "bun install && bun build for the browser interop harness"
|
||||
group = "interop"
|
||||
workingDir = browserInteropDir.asFile
|
||||
@@ -325,7 +325,7 @@ val interopBuildBrowserHarness by tasks.registering(Exec::class) {
|
||||
outputs.dir(browserInteropDir.dir("dist"))
|
||||
}
|
||||
|
||||
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
|
||||
val interopInstallPlaywrightChromium = tasks.register<Exec>("interopInstallPlaywrightChromium") {
|
||||
description = "Install Playwright Chromium + dependencies for the browser interop harness"
|
||||
group = "interop"
|
||||
workingDir = browserInteropDir.asFile
|
||||
|
||||
+4
@@ -56,6 +56,10 @@ internal class Nip46Test {
|
||||
sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356",
|
||||
)
|
||||
|
||||
// The round trip is only type-safe by construction: the caller hands in a T,
|
||||
// the message is encoded and decoded, and the decoder returns the same
|
||||
// BunkerMessage subtype. The runtime has no way to check that.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun <T : BunkerMessage> encodeDecodeEvent(req: T): T {
|
||||
val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson()
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.quartz.utils
|
||||
|
||||
actual fun BigDecimal.toLongValue(): Long = toLong()
|
||||
+1
-1
@@ -760,7 +760,7 @@ class MarmotInboundProcessor(
|
||||
val author = payloadAuthor(candidate.content.decodeToString())
|
||||
val sender = candidate.senderAccount
|
||||
val valid = author != null && sender != null && author == sender
|
||||
if (valid && sender != null) {
|
||||
if (valid) {
|
||||
convergence.recordWitness(groupId, candidate.stateId, sender)
|
||||
}
|
||||
return GroupEventResult.AppMessageOnCandidateBranch(
|
||||
|
||||
+2
-2
@@ -561,7 +561,7 @@ class MarmotConvergenceEngine(
|
||||
|
||||
return mutex.withLock {
|
||||
val ctx = contexts[groupId] ?: return@withLock null
|
||||
if (rewound && selectedTipId != null) {
|
||||
if (rewound) {
|
||||
adoptBranch(ctx, graph, selectedTipId, inputs.baseId)
|
||||
}
|
||||
// Keep the states of branches that LOST but stay eligible: losing
|
||||
@@ -576,7 +576,7 @@ class MarmotConvergenceEngine(
|
||||
.forEach { tipId ->
|
||||
var cursor: String? = tipId
|
||||
while (cursor != null && cursor !in canonical) {
|
||||
graph.statesById[cursor]?.let { ctx.candidateStates[cursor!!] = it }
|
||||
graph.statesById[cursor]?.let { ctx.candidateStates[cursor] = it }
|
||||
cursor = graph.parentOf[cursor]
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.store.RejectionReason
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
@@ -212,7 +213,13 @@ class RelaySession(
|
||||
}
|
||||
|
||||
is IEventStore.InsertOutcome.Rejected -> {
|
||||
send(OkMessage(cmd.event.id, false, outcome.reason))
|
||||
// NIP-01: an event the relay already holds is acknowledged with
|
||||
// `OK true` and the `duplicate:` prefix. Every real client
|
||||
// (amethyst's outbox included) resends an event whose OK has not
|
||||
// landed yet, and treats OK false as a rejection to surface —
|
||||
// so answering false here turns a routine resend into an error.
|
||||
val duplicate = outcome.reason.startsWith(RejectionReason.PREFIX_DUPLICATE)
|
||||
send(OkMessage(cmd.event.id, duplicate, outcome.reason))
|
||||
}
|
||||
|
||||
is IEventStore.InsertOutcome.Failed -> {
|
||||
|
||||
+13
@@ -44,8 +44,21 @@ object RejectionReason {
|
||||
*/
|
||||
const val PREFIX_REPLACED = "replaced:"
|
||||
|
||||
/** NIP-01 prefix for "already have this event" — answered with `OK true`, not false. */
|
||||
const val PREFIX_DUPLICATE = "duplicate:"
|
||||
|
||||
// The standard store reasons.
|
||||
const val DUPLICATE = "duplicate: already have this event"
|
||||
|
||||
/**
|
||||
* A replaceable or addressable event that a stored version already supersedes
|
||||
* (newer `created_at`, or the same `created_at` and a lower id). Nothing is
|
||||
* written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps
|
||||
* `duplicate:` as the machine-readable prefix for "already covered", and that
|
||||
* is what nostr-rs-relay sends here too, so clients that retry on anything
|
||||
* else (MDK's `wn`) settle instead of re-offering the same event forever.
|
||||
*/
|
||||
const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored"
|
||||
const val EXPIRED = "blocked: Cannot insert an expired event"
|
||||
const val DELETED = "blocked: a deletion event exists"
|
||||
const val VANISHED = "blocked: a request to vanish event exists"
|
||||
|
||||
+22
@@ -62,6 +62,14 @@ class SQLiteEventStore(
|
||||
val extraPragmas: List<String> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
/** SQLite's message for the unique index on `event_headers (id)`. */
|
||||
private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id"
|
||||
|
||||
/**
|
||||
* Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and
|
||||
* `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns.
|
||||
*/
|
||||
private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey"
|
||||
const val DATABASE_VERSION = 5
|
||||
}
|
||||
|
||||
@@ -526,6 +534,20 @@ class SQLiteEventStore(
|
||||
*/
|
||||
private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome {
|
||||
val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED
|
||||
// A second copy of an event the store already holds trips the unique index on
|
||||
// event_headers.id. That is not a refusal of the event but a statement that it
|
||||
// is already here, and NIP-01 has a dedicated answer for it (`OK true` with the
|
||||
// `duplicate:` prefix) — so name it, instead of leaking SQLite's constraint text
|
||||
// for the session to turn into a rejection the client then retries or reports.
|
||||
if (message.contains(DUPLICATE_ID_CONSTRAINT)) {
|
||||
return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE)
|
||||
}
|
||||
// The replaceable / addressable unique indexes fire only when the supersession
|
||||
// trigger found nothing older to delete, i.e. the stored version already wins
|
||||
// (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`.
|
||||
if (message.contains(SUPERSEDED_CONSTRAINT)) {
|
||||
return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED)
|
||||
}
|
||||
val refusal =
|
||||
message.contains("blocked:") ||
|
||||
message.contains("duplicate:") ||
|
||||
|
||||
@@ -23,3 +23,15 @@ package com.vitorpamplona.quartz.utils
|
||||
operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other)
|
||||
|
||||
operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other)
|
||||
|
||||
/**
|
||||
* Truncate to a Long, the way Number.toLong() does on every platform.
|
||||
*
|
||||
* It has to be an expect *function* rather than a member of `expect class
|
||||
* BigDecimal`: every actual is already a Number and so already has toLong(),
|
||||
* but java.math.BigDecimal leaves toByte()/toShort() abstract, which makes
|
||||
* `expect class BigDecimal : Number` impossible to actualize with the JVM
|
||||
* typealias. Without this, `amount.toLong()` in shared code resolves only in
|
||||
* the platform compilations and breaks `compileCommonMainKotlinMetadata`.
|
||||
*/
|
||||
expect fun BigDecimal.toLongValue(): Long
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ class AuthOutcomeTest {
|
||||
phase: RelayAuthSnapshot.Phase,
|
||||
successCount: Int = 0,
|
||||
) {
|
||||
state.value = state.value.put(relay, RelayAuthSnapshot(phase, null, successCount))
|
||||
state.value = state.value.putting(relay, RelayAuthSnapshot(phase, null, successCount))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-2
@@ -120,8 +120,13 @@ class NostrServerTest {
|
||||
server.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-01: `["OK", <id>, true, "duplicate: already have this event"]`. A client
|
||||
* resends any event whose OK has not landed, so a duplicate must read as
|
||||
* success — OK false would make every such resend look like a rejection.
|
||||
*/
|
||||
@Test
|
||||
fun duplicateEventReturnsOkFalse() =
|
||||
fun duplicateEventReturnsOkTrueWithDuplicatePrefix() =
|
||||
runTest {
|
||||
val dispatcher = UnconfinedTestDispatcher(testScheduler)
|
||||
val store = EventStore(null)
|
||||
@@ -138,7 +143,72 @@ class NostrServerTest {
|
||||
val okMessages = collector.rawMessagesContaining("OK")
|
||||
assertEquals(2, okMessages.size)
|
||||
assertTrue(okMessages[0].contains(",true,"))
|
||||
assertTrue(okMessages[1].contains(",false,"))
|
||||
assertTrue(okMessages[1].contains(",true,"))
|
||||
assertTrue(okMessages[1].contains("duplicate:"))
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE-W01: a replaceable event older than the stored version is not written,
|
||||
* and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the
|
||||
* NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a
|
||||
* rejection the client would keep retrying.
|
||||
*/
|
||||
@Test
|
||||
fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() =
|
||||
runTest {
|
||||
val dispatcher = UnconfinedTestDispatcher(testScheduler)
|
||||
val store = EventStore(null)
|
||||
val server = createServer(dispatcher, store)
|
||||
val collector = MessageCollector()
|
||||
val c1 = server.connect(collector.sendCallback)
|
||||
|
||||
val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L)
|
||||
val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L)
|
||||
c1.insert(newer)
|
||||
c1.insert(older)
|
||||
|
||||
val okMessages = collector.rawMessagesContaining("OK")
|
||||
assertEquals(2, okMessages.size)
|
||||
assertTrue(okMessages[0].contains(",true,"))
|
||||
assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}")
|
||||
assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix")
|
||||
|
||||
val stored = store.query<Event>(Filter(kinds = listOf(0)))
|
||||
assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one")
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE-W02 tie: two addressable events with the same `d` tag and the same
|
||||
* `created_at` — the lower id wins, the other is acknowledged as superseded.
|
||||
* This is the exact shape MDK's `wn keys publish` produces when it mints a
|
||||
* second KeyPackage within the same second as the first.
|
||||
*/
|
||||
@Test
|
||||
fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() =
|
||||
runTest {
|
||||
val dispatcher = UnconfinedTestDispatcher(testScheduler)
|
||||
val store = EventStore(null)
|
||||
val server = createServer(dispatcher, store)
|
||||
val collector = MessageCollector()
|
||||
val c1 = server.connect(collector.sendCallback)
|
||||
|
||||
val dTag = arrayOf(arrayOf("d", "kp"))
|
||||
val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag)
|
||||
val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag)
|
||||
c1.insert(lowerId)
|
||||
c1.insert(higherId)
|
||||
|
||||
val okMessages = collector.rawMessagesContaining("OK")
|
||||
assertEquals(2, okMessages.size)
|
||||
assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}")
|
||||
assertTrue(okMessages[1].contains("duplicate:"))
|
||||
|
||||
val stored = store.query<Event>(Filter(kinds = listOf(30443)))
|
||||
assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie")
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.quartz.utils
|
||||
|
||||
actual fun BigDecimal.toLongValue(): Long = toLong()
|
||||
+2
-2
@@ -245,8 +245,8 @@ class LastResortKeyPackageReuseTest {
|
||||
|
||||
override suspend fun load(): ByteArray? = bytes
|
||||
|
||||
override suspend fun save(data: ByteArray) {
|
||||
bytes = data
|
||||
override suspend fun save(snapshot: ByteArray) {
|
||||
bytes = snapshot
|
||||
}
|
||||
|
||||
override suspend fun delete() {
|
||||
|
||||
+8
-2
@@ -110,10 +110,16 @@ class InternTradeoffBenchmark {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the last measured corpus so the JIT cannot drop the allocations we
|
||||
* just paid for. A field rather than a local: a local's assignments are
|
||||
* visible to the compiler's data flow, which then folds the `check` below
|
||||
* into a constant.
|
||||
*/
|
||||
private var sink: Any? = null
|
||||
|
||||
@Test
|
||||
fun internCostAndBenefit() {
|
||||
var sink: Any? = null
|
||||
|
||||
println("\n=== corpus: $EVENTS events, $AUTHORS authors, $RELAYS relays ===")
|
||||
|
||||
// ---- memory ----
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ class IndexableContentGoldenTest {
|
||||
}
|
||||
}
|
||||
val rejoined = visited.joinToString(event.indexableSeparator())
|
||||
if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)!!} content=${event.indexableContent().take(120)}"
|
||||
if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)} content=${event.indexableContent().take(120)}"
|
||||
}
|
||||
assertEquals("visitor and indexed content disagree", emptyList<String>(), disagreements)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.quartz.utils
|
||||
|
||||
actual fun BigDecimal.toLongValue(): Long = toLong()
|
||||
+1
-3
@@ -203,9 +203,7 @@ class Http3GetClient(
|
||||
body += frame.body
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user