Add censorship-resistant NIP-05 verification using the Namecoin blockchain
to the Desktop (JVM) app, porting functionality from PRs #1734, #1771,
New files:
- DesktopNamecoinNameService: app-level service wrapping the Quartz
NamecoinNameResolver with caching, custom server support, and live
state flows. Uses plain JVM sockets (no Tor support on Desktop yet).
- DesktopNamecoinPreferences: Java Preferences API-backed persistence
for Namecoin settings (enabled toggle + custom ElectrumX servers).
- NamecoinSettings: Desktop copy of the settings data class (no Android
dependencies).
- LocalNamecoin: CompositionLocals for threading Namecoin service/prefs
through the compose tree.
- NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX
servers — toggle, active server display with DEFAULT/CUSTOM badge,
add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent
for Enter-to-submit instead of Android KeyboardActions.
- ImportFollowListDialog: Dialog for importing follow lists via npub,
hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys
with Namecoin blockchain support.
Modified:
- Main.kt: Instantiate DesktopNamecoinPreferences and
DesktopNamecoinNameService in App composable, provide via
CompositionLocals, wire into RelaySettingsScreen.
- SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the
search bar, resolve via DesktopNamecoinNameService with loading/error
states, display resolved user above standard results. Uses
LaunchedEffect with key-based cancellation for stale lookups.
- DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen
from CompositionLocal.
Tests:
- DesktopNamecoinPreferencesTest: round-trip persistence, add/remove
servers, enable/disable, reset, duplicate handling.
- NamecoinSettingsTest: server string parsing/formatting, round-trips,
edge cases, toElectrumxServers conversion.
The Quartz KMP library (commonMain + jvmAndroid) already contains the
core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver,
NamecoinLookupCache) shared across Android and Desktop.
The bridge was emitting `xs=https://cdn.nostr.build` for URLs like
`https://cdn.nostr.build/i/<sha>.jpg`, dropping the `/i/` prefix. The
local cache appends `/<sha>` to the xs server hint per spec, so it would
try to fetch `https://cdn.nostr.build/<sha>` — a 404 on nostr.build's
non-Blossom CDN scheme.
Anchor the server-base extraction on the slash immediately preceding the
sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`.
The cache then appends `/<sha>` and reaches the original blob.
Affects all three bridge entry points:
- MediaUrlContent.toCoilModel (note media)
- bridgeProfilePictureUrl (profile pictures)
- LocalBlossomCacheRedirectInterceptor (everything else via OkHttp)
Flat Blossom paths (`<host>/<sha>`) keep emitting `xs=<host>` unchanged.
bridgeProfilePictureUrl previously returned a `blossom:` URI, but
profile pictures go through ProfilePictureFetcher (Coil routes by type
on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher
and never sees BlossomFetcher. NetworkFetcher then tries to issue an
HTTP request against the `blossom:` scheme and fails — silently — so
profile pictures from Blossom servers stopped loading.
Return a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pub>`
URL instead. The OkHttp request goes to the local cache normally; the
new redirect interceptor sees it's already on localhost and passes
through unchanged.
Pictures hosted on Blossom servers (URLs with a 64-char sha256 hex in
the path) bypassed the bridge whenever they were rendered through a
composable that wasn't explicitly updated to call toCoilModel — link
previews, video thumbnails, badge images, audio room covers, etc.
Add LocalBlossomCacheRedirectInterceptor as an app-level OkHttp
interceptor that catches every HTTP request transparently:
- Detects a 64-char hex segment in any path segment
- Rewrites the request URL to http://127.0.0.1:24242/<sha>.<ext>?xs=<host>
- Coil's disk cache continues to key by the original URL so cached
blobs survive toggling the bridge off
Activates only when the master toggle is on, the profile-pictures-only
restriction is off, and the probe sees localhost up. Profile-only mode
still routes profile pics via the existing composable bridge.
Aligns the toggle with its Security Filters siblings (warnAboutPostsWithReports,
filterSpamFromStrangers, etc.): stored in AccountSecurityPreferences{Internal},
synced across devices via the app-specific data event, and updated through
account.updateDisableClientTag -> sendNewAppSpecificData.
The signer wrapper now reads the live value from synced settings, so changes
still apply immediately to the next signed event.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
- `as=<authorPubKey>`: when converting plain http(s) imeta URLs into
`blossom:` URIs we now include the note author's pubkey, letting the
local cache consult their kind:10063 BUD-03 server list on miss.
Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/
ExpandableRichTextViewer/TranslatableRichTextViewer.
- Profile-pictures-only mode: a new per-account toggle that restricts
the bridge to profile pictures (RobohashFallbackAsyncImage). When on,
feed images and videos skip the bridge entirely. Profile pictures
recover their sha256 from the URL path when present (covers
Blossom-hosted avatars like https://nostr.build/i/<sha>.jpg).
Adds a "Don't add client tag to my events" switch under Security Filters.
NostrSignerWithClientTag now consults a runtime predicate at sign-time, so
the toggle takes effect immediately on the live session without rewrapping
the signer or rebuilding Account.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.
Wrap the error text in `SelectionContainer` at the canonical sites:
- `commons.ui.components.LoadingState`: wrap the description in
`EmptyState` and the message in `ErrorState`. `EmptyState` is reused
as the in-feed error renderer (e.g. 'Error loading feed' with the
underlying error in `description`), so this covers feed/loading
errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
message under the nsec input field can be copied.
No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
Replace manual relay subscribe + Channel approach with the proven
SearchBarState + rememberSubscription pattern (same as NewDmDialog).
This properly handles relay connection lifecycle and NIP-50 search,
returning results from all connected relays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist author search state to FeedsDrawerTab (AlertDialog can't run LaunchedEffect)
- NIP-50 relay search via connected relays with Channel-based result streaming
- 28dp UserAvatar in author suggestion rows
- "No users found" empty state
- 32dp dialog margins, 300dp max results height, 30 result limit
- FindUsersTest: 8 tests verifying cache search with/without metadata
- Fix: use connectedRelays instead of unconnected DEFAULT_SEARCH_RELAYS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brazilian Portuguese's 'one' quantity matches both 0 and 1, so a
hardcoded '1' is wrong when the count is 0. Switch the 'one' branch
of scheduled_posts_subtitle_due_suffix and scheduled_posts_relay_count
to use the %d argument like the 'other' branch.
https://claude.ai/code/session_01UmYZfFy8QqaDaXbcXguu8f
Lint flagged the notify() call as MissingPermission because there was no
visible check that POST_NOTIFICATIONS was granted. Gate the call behind
areNotificationsEnabled() — matching the pattern used in
EventNotificationConsumer — and catch SecurityException for the
narrow window where permission could be revoked between the check and
the notify().
The mid-stream resub pattern (resub1 at events.size==1, resub2 at
events.size==5) has four receive() calls between the two subscribe
calls. On a slow CI worker that's enough time for resub1
(filtersShouldIgnore, kind 10002 limit 500) to actually reach the
relay and start streaming events before resub2 replaces it, so the
loop's second EOSE can come from filtersShouldIgnore — which can
deliver up to 50 events (the preloaded count for that kind), well
past the previous 1..11 bound.
Drop the phase-specific count asserts and verify only structural
invariants: exactly two EOSEs, the loop stopped on the second, every
non-EOSE entry is a valid 64-char id, and the total event count
stays within the worst-case envelope.
https://claude.ai/code/session_01L4qS7BhX3L7ApiS3HsCozn
Three small follow-ups from the audit pass:
1. Drop redundant `challengeData.copyOf()` in
`queuePathResponseLocked` — the parser produces a fresh
ByteArray per PATH_CHALLENGE via `QuicReader.readBytes`'s
`copyOfRange`, so the defensive copy was a wasted allocation.
One-line fix.
2. Rename `pendingPathResponses` → `pendingPathChallengePayloads`.
The queue holds inbound CHALLENGE payloads we owe RESPONSES
for — old name conflated the two. Pure rename across
QuicConnection / Parser / Writer / PathValidationTest.
3. Extract shared `newConnectedClient(...)` test fixture
(`ConnectedClientFixture.kt`). The 6 test files each repeated
~40 lines of identical handshake-pipe boilerplate; folded into
one parameterized helper accepting transport-cap overrides.
Net −164 lines across the test tree; per-test helper is now a
one-liner that documents the cap shape.
No behavior change. Full quic suite + amethyst hook test green.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Conflicts:
- quartz/.../LiveEventStore.kt: main added IngestQueue (group-commit)
and a fire-and-forget submit() path; this branch replaced the
SharedFlow live fanout with FilterIndex-driven dispatch. Resolved
by keeping main's submit/insert pipeline shape (IngestQueue ingest
ctor param, submit() callback, insert() wrapping submit via
CompletableDeferred) but routing the on-Accepted fanout through
FilterIndex.candidatesFor instead of newEventStream.tryEmit.
Added private fanout(event) helper. Kept main's
snapshotIdsForNegentropy method.
- geode/.../LoadBenchmark.kt: both sides added a new @Test method.
Kept fanoutScaling (this branch) and publishPipelinedSingleClient
(main).
Soak target #4 — minimum viable path validation. Lands the
spec-required peer-initiated case so a server probing the path
(e.g. after a NAT rebind, or post-CID rotation) sees a matching
PATH_RESPONSE and doesn't declare the path dead.
Pre-fix the parser decoded PATH_CHALLENGE / PATH_RESPONSE bytes
but threw the result away — a peer's challenge went silently
into the void. After ~3 RTT of no response, a strict peer would
mark the path dead and tear the connection down (visible to
audio-rooms users as a sudden cut on a phone that briefly
switched cells).
Implementation:
- Add PathChallengeFrame / PathResponseFrame data classes;
wire decode and encode (was decode-and-discard previously).
- Add `pendingPathResponses` queue on QuicConnection (bounded at
MAX_PENDING_PATH_RESPONSES = 64 to defend against challenge
flood; excess silently dropped — peer retries on PTO).
- Parser handler queues a response on inbound PATH_CHALLENGE.
- Writer drains the queue in buildApplicationPacket. RFC 9000
§13.3 doesn't list PATH_RESPONSE as ack-eliciting-and-
retransmittable; if a response is lost, the peer's next
PATH_CHALLENGE re-queues it and we respond again.
Out of scope for this landing (multi-day each, parked unless
production evidence requires):
- Client-initiated migration: requires UdpSocket replacement,
new-CID acquisition tracking, validating new path BEFORE
moving traffic to it.
- Anti-amplification on unvalidated paths (RFC 9000 §8.1).
Tests (PathValidationTest, 6 cases):
- PATH_CHALLENGE / PATH_RESPONSE codec round-trip + 8-byte
length validation.
- End-to-end: peer PATH_CHALLENGE → client PATH_RESPONSE
with byte-equal payload.
- Multi-challenge fan-in: 3 challenges → 3 distinct responses
(in any order; matched by content).
- Flood cap: 256 challenges → ≤ MAX_PENDING_PATH_RESPONSES
responses, connection stays CONNECTED.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Working through the punch list from the prior "what's left?" status.
#2 close-under-load (CloseUnderLoadTest, 3 cases)
==================================================
Pins three races between connection close and active stream
traffic that the existing idle-driver close test doesn't cover:
- closeWhileBulkStreamRetirementIsRunning — server ACKs 100
in-flight client-bidi streams in one shot, retire pass + close
fire concurrently. Asserts CLOSED status and no Flow leak.
- closeWhileAppCoroutinesAreOpeningStreamsDoesNotDeadlock —
pins the lock-ordering invariant: streamsLock (openers) and
lifecycleLock (close) don't fight.
- closeWhilePeerStreamsAreInFlight — close fires mid-stream of
50 server-uni group streams (half FIN'd, half not). Every
incoming Flow terminates promptly with whatever bytes the
parser had already delivered.
#3 PN-gate / try-previous-fall-through-to-next for key updates
==============================================================
Closes the KNOWN-LIMITATION I documented in the prior round.
QuicConnectionParser previously routed mismatched-KEY_PHASE
packets unconditionally to previousReceiveProtection if non-null,
which silently dropped consecutive-rotation packets (KEY_PHASE
wraps back to its prior value, prior keys are now wrong, AEAD
fails, connection wedges).
Fix follows neqo's shape: try previous keys; on AEAD failure fall
through to next-phase derivation. Two AEAD attempts on a
mismatched-phase packet are cheap; KEY_PHASE mismatch is rare.
The previously-disabled twoConsecutiveRotationsCommitCorrectly
test now passes.
#4 loss harness depth (MoqLiteLossHarnessTest, 3 added cases)
=============================================================
First-pass harness from the previous round was a single 5%-loss
moq-lite shape. Added:
- listenerToleratesPacketReorderingOnGroupStreams — random
permutation of 50 group-stream datagrams, asserts 100%
delivery. Pins the reorder contract for moq-lite.
- listenerSurvivesExtremeTwentyPercentLoss — 200 streams at
20% loss, asserts ≥ 60% delivery and connection stays
CONNECTED. Catches catastrophic-collapse regressions in
flow-control / ACK-tracker / retired-id ring under stress.
- reliableBidiStreamRecoversFromMidStreamPacketLoss — drops
the middle two of four STREAM frames on a reliable bidi
stream, retransmits, asserts the consumer surfaces the full
contiguous payload. Pins the reliability contract distinct
from the best-effort moq-lite path.
#1 foreground-resume recycle (AppForegroundRecycleHook, 5 tests)
================================================================
Closes the user-visible production gap. ReconnectingNestsListener
already orchestrates retry on terminal state and observes
NestNetworkChangeBus for network-handover recycles. The missing
piece was a foregrounding signal: when Android reclaims the app's
UDP socket FD after backgrounding (typical at ~30 s+, network
itself still up so the connectivity callback doesn't fire), the
QUIC connection sits dead until the next send-loop throw — which
landed last round.
This hook publishes a NestNetworkChangeBus event when the app
returns to foreground after spending ≥ 5 s in background. The
pre-existing wiring observes that event and calls
recycleSession() on every active listener / speaker. Pure-state
core (AppForegroundCounter) is testable without Robolectric;
JUnit-4 unit tests pin the threshold logic, multi-activity
counter behaviour (e.g. PIP), and consecutive-cycle correctness.
Wired into Amethyst.Application.onCreate via
registerActivityLifecycleCallbacks.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
#2 KEY UPDATE VERIFICATION (soak target #2)
Added KeyUpdatePeerInitiatedTest pinning the RFC 9001 §6 peer-initiated
1-RTT key update path against InMemoryQuicPipe:
- peerInitiatedRotationCommitsAndMirrorsOnSend — single rotation
flips currentReceiveKeyPhase, mirrors currentSendKeyPhase, retains
pre-rotation keys as previousReceiveProtection, installs new
receive+send protections; connection stays CONNECTED.
- reorderedPacketOnPriorKeysStillDecryptsAfterRotation — packet
sent before peer rotated but arriving after the rotation
triggering packet decrypts via previousReceiveProtection (RFC
9001 §6.1 reorder window).
- postRotationOutboundPacketCarriesNewKeyPhaseAndDecryptsForPeer —
writer stamps currentSendKeyPhase into the short header AND
encrypts with the rolled-forward send keys.
Test infrastructure: InMemoryQuicPipe grows rotateServerApplicationKeys
(walks the same HKDF-Expand-Label "quic ku" dance the production peer
would) plus buildServerApplicationDatagramWithPriorKeys (re-emits via
the stashed pre-rotation TX, exercising the reorder-window path).
Documented limitation: consecutive rotations within the reorder window
mis-route via previousReceiveProtection. The spec-correct fix is to
gate previousReceiveProtection on a packet-number threshold (neqo /
picoquic shape); for the audio-rooms 3-hour scenario, a single
rotation is the realistic case so this is a follow-on rather than a
blocker. No test asserts the broken behaviour.
#3 RECONNECT-ON-FOREGROUND (soak target #3)
ReconnectingNestsListener already has all the orchestration
(exponential-backoff retry, JWT-refresh recycle, recycleSession()
hook for platform network-change events). What was missing at the
QUIC level: when the OS reclaims the UDP socket FD while the app is
backgrounded, socket.send() throws and the bare exception escapes
the SupervisorJob silently. The connection sits in HANDSHAKING /
CONNECTED indefinitely and the orchestrator's terminal-state
listener never fires — the room screen shows "live" while audio
is dead.
Wrapped sendLoop in try/catch mirroring the existing readLoop's
finally block: any uncaught Throwable (CancellationException
excepted, since close() is already driving teardown) calls
markClosedExternally with the cause. Also wired markClosedExternally
to record closeReason on first-call so observability surfaces the
human-readable cause through to NestsListenerState.Failed.reason.
Pinned by socketDeathMidSessionFlipsConnectionToClosed —
runs the driver, tears the UDP socket out from under it, asserts
status flips to CLOSED within 5 s and the close reason mentions the
loop death. Pre-fix this would loop forever waiting for status to
move.
Tests:
- KeyUpdatePeerInitiatedTest (3 cases)
- QuicConnectionDriverLifecycleTest::socketDeathMidSessionFlipsConnectionToClosed
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Follow-up to b8c6e080 addressing the gaps I called out in the
"is this the best we can do?" reply.
1. **Heap-sampling soak test** (`QuicHeapSoakTest`). Long-form, default-
skipped via `-PquicSoakSeconds=N` propagated by `quic/build.gradle.kts`
to the jvmTest task. Without the property the test early-returns with
a printed SKIPPED line, so `./gradlew test` stays fast for CI. With
the property, drives moq-lite-shaped peer-uni churn at ~50 streams/s
for N seconds, samples `totalMemory - freeMemory` six times across
the run, and fails if the post-warmup → final delta exceeds 10 MB
(the acceptance threshold from the audio-rooms soak prompt).
Production use: `-PquicSoakSeconds=1800` for the 30-minute soak.
2. **FD-leak canary** added to `QuicConnectionDriverLifecycleTest`. On
Linux, samples `/proc/self/fd` size before / after the 100-session
loop; banded at +16 entries for ambient JVM noise. macOS / Windows
silently no-op because /proc isn't there. Catches socket / pipe
leaks the thread-count check would miss.
3. **Phantom-stream guard.** Added `retiredStreamIdSet` (capped FIFO
ring at 4 096 entries, ~80 s of moq-lite churn) plus
`isStreamIdRetiredLocked` on the connection. Parser checks before
`getOrCreatePeerStreamLocked` and drops STREAM frames the peer
retransmits on already-retired streams. Eliminates the
"duplicate ACK lost → peer retransmits FIN → we mint a phantom
QuicStream" edge case I papered over in the previous commit.
Pinned by `phantomGuardDropsRetransmitOnRetiredPeerStream`.
4. **moq-lite loss harness** (`MoqLiteLossHarnessTest`). First pass at
soak target #5: drive 50 best-effort group streams with 5%
uniform packet loss, assert the listener surfaces ≥ 90% with
payloads intact and the connection stays CONNECTED. Out of scope
here: reorder injection, latency-under-loss measurement, full
end-to-end with a real moq-lite publisher.
Tests:
- `QuicHeapSoakTest` — gated, validates 10MB heap acceptance band.
- `QuicConnectionDriverLifecycleTest::repeatedSessionLifecycleDoesNotLeakThreads`
— now also enforces /proc/self/fd bound.
- `StreamRetirementSoakTest::phantomGuardDropsRetransmitOnRetiredPeerStream`
— pins the duplicate-frame drop semantics.
- `MoqLiteLossHarnessTest` — 2 cases (lossy + lossRate=0 baseline).
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec