The original "ProGuard strips the keychain backend" hypothesis turned out
to be wrong twice (PR 3260 comments document the binary PoW that refuted
both H1 strip-of-classes and H1b strip-of-native-resource). The full
117 KB osxkeychain.so resource ships intact in the proguarded
jkeychain-1.1.0-*.jar today, and Keyring.create() round-trips fine
against the proguarded classpath on macOS.
But the user-reported bug pattern (every cold boot, keychain key missing
→ forced re-login) maps so cleanly onto a hypothetical future
strip-of-native-resource that the guard is worth keeping. Cheap to run
(one unzip scan after proguardReleaseJars), wired onto every release
packaging task (DMG, MSI, DEB, RPM, current-OS distributable, runRelease)
so a regression can't slip past. Fails the build with a self-contained
explanation pointing at the next person who has to debug it.
The actual root cause of the reported bug remains unidentified after
three refuted hypotheses (see plan doc PoW table); needs the affected
user's Console.app logs + ~/.amethyst state to make further progress.
The LoginScreen "keychain-unavailable" diagnostic banner from the
earlier commit is unchanged and still earns its keep regardless of
which failure mode eventually turns out to be the cause.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Built ./gradlew :desktopApp:proguardReleaseJars on both main and this
branch and inspected the shrunk java-keyring-1.0.4-*.jar in
desktopApp/build/compose/tmp/main-release/proguard/. Both branches
contain byte-identical macOS Keychain backend bytecode:
OsxKeychainBackend, ModernOsxKeychainBackend,
pt/davidafsilva/apple/OSXKeychain, plus all _addGenericPassword /
_findGenericPassword / _deleteGenericPassword / loadSharedObject native
methods. ProGuard is NOT stripping the macOS backend.
The compose-rules.pro comment had misled me. pt.davidafsilva.apple IS a
real transitive runtime dep of com.github.javakeyring:java-keyring —
ModernOsxKeychainBackend has a private pt.davidafsilva.apple.OSXKeychain
field. The original keep rule was correct; restore it and clarify the
comment about the transitive relationship so the next person to read
this code doesn't repeat the same mistake.
The AccountManager keychain-unavailable diagnostic + LoginScreen banner
introduced earlier in this branch are kept — they're useful for any
future failure mode in this area, not just the (refuted) ProGuard one.
See https://github.com/vitorpamplona/amethyst/pull/3260#issuecomment-4740073787
for the full PoW jar inspection. Remaining hypotheses (H2 hardened-runtime
unsigned-dylib block, H4 jpackage stripping the bundled libosxkeychain.dylib,
H5 v1.11.0 migration gap) are documented in the plan doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProGuard in the release DMG (compose-rules.pro) was keeping
pt.davidafsilva.apple.** — a library no longer in the dependency graph.
The actual macOS-keychain dependency is com.github.javakeyring:java-keyring,
which reflection-loads its OS-specific backend (OSXKeychainBackend /
SecretServiceBackend / WinCredentialStoreBackend) at Keyring.create()
time. The shrinker stripped the backend classes, Keyring.create() threw
BackendNotSupportedException on every cold boot, SecureKeyStorage's
fallback silently returned null (no password prompt in a GUI cold-boot),
and every account whose key lived in the OS keychain (nsec, NIP-46
bunker ephemeral, NWC secret) was forced back to the login screen on
each launch of the release DMG. Dev/Gradle runs skip ProGuard, which is
why this never surfaced in development.
Primary fix:
- Replace dead pt.davidafsilva.apple.** keep rules with
com.github.javakeyring.** and keep native methods + constructors on
internal.** backends.
Defense in depth (so a future regression is visible, not silent):
- AccountManager._keychainUnavailable: StateFlow<Boolean> mirrors the
existing _storageCorruption / _forceLogoutReason channels.
- loadInternalAccount / loadBunkerAccount raise the signal when
accounts.json.enc points at a key the keychain cannot return.
- LoginScreen shows a one-line error banner when the signal is set;
cleared on any successful login.
Tests:
- AccountManagerLoadAccountTest gains four cases: Internal-no-privkey
signals, Bunker-no-ephemeral signals, clearKeychainUnavailable
resets, happy path does NOT signal.
See docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md for
brainstorm + plan + deferred follow-ups (Linux/Windows DMG verification,
signed-DMG smoke test, ProGuard mapping regression guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
- Make birdex_species_preview_more a <plurals> keyed on the remaining count
- Bound the species preview with maxLines=2
- Hoist the joined-names remember out of the conditional (stable slot).
- Drop the unused accountViewModel parameter
- BirdexEvent.speciesCount() derives from speciesNames().size instead of re-scanning tags
- remember() the joined species-name string so it is not rebuilt on every recomposition.
Two correctness bugs in `RemoteSignerManager` (NIP-46) and its NIP-55
sibling `IntentRequestManager`:
1. **Double-resume crash** — `awaitingRequests.get(id)?.resume(value)`
was non-atomic. Multi-relay delivery, bunker echo/retry, and
late-after-timeout responses could call `resume` twice for the same
continuation, throwing `IllegalStateException: Already resumed` on a
`Dispatchers.Default` worker.
2. **Retry id-reuse → wrong data** (NIP-46 only) —
`launchWaitAndParse` built the request and event once, then re-used
the same `request.id` across retry attempts. A late response from
attempt N could resume attempt N+1's continuation with stale data.
Replace the cached-`Continuation` map with the in-house Channel-per-request
correlation pattern already used in `quartz/.../accessories/NostrClientPublishExt.kt`
(`LargeCache<id, Channel<Response>(capacity=1)>` + atomic `remove` +
`trySend` + `withTimeoutOrNull { receive() }`). Each retry attempt now
builds a fresh request with a new id; the builder is still called only
once. `finally`-block cleanup removes the cache entry on every path,
incidentally fixing a slow leak on the success path.
Adds three regression tests:
- duplicate responses → no crash + single resume (fails on \`main\`
with \`IllegalStateException\`)
- late response after timeout → silently discarded
- late attempt-1 response does not corrupt attempt-2 result (fails on
\`main\`: the two attempts share an id)
Design + review notes: \`quartz/plans/2026-06-03-fix-nip46-bunker-double-resume-plan.md\`
Two follow-ups to the reply-context PR.
1) Parent-author metadata wasn't reaching the embed / "Replying to @X"
label, so they rendered the truncated hex indefinitely.
- FeedScreen.missingNoteIds: also fetch the immediate parent EVENT
for visible replies (was only repost originals + bech32 quotes).
- FeedScreen.missingAuthorPubkeys: also include the parent AUTHOR
hex, extracted DIRECTLY from each reply's tags
(CommentEvent.replyAuthor() for NIP-22; taggedUsers().lastOrNull()
for NIP-10) so the kind 0 request fires even before the parent
event itself arrives in cache.
- NoteCard.QuotedNoteEmbed + FeedScreen.rememberReplyContext:
produceState observation of the parent author's
metadata().flow so the embed and label recompose to display name
+ avatar once kind 0 lands.
2) Embedded parent appeared clickable but did nothing — the outer
NoteCard's OutlinedCard onClick was catching the click and
re-navigating to the reply's own thread (the current view). Make
the wrapping Box itself clickable, route it to the parent thread,
and drop the inner OutlinedCard's onClick so there's a single
explicit click surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RichTextParser splits each paragraph on ' ' so every segment is one
space-delimited token; the source space lives BETWEEN segments, not
within them. When a paragraph contains only RegularTextSegments the
parser collapses them back to one segment rejoined with " ". When the
paragraph also contains a mention/hashtag/link the segments stay split
and DesktopRichTextViewer rendered them in a FlowRow with no horizontal
gap — every word glued together.
Set the FlowRow's horizontalArrangement to Arrangement.spacedBy(4.dp)
(the same constant the file already uses for ImageGalleryParagraph),
preserving the RTL alignment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Replies tab predicate used `!note.isNewThread()`, which returns true
whenever Note.replyTo is non-empty. The cache populates replyTo from
event.tagsWithoutCitations(), and that includes unmarked positional
NIP-10 e-tags — which modern clients use for QUOTES and MENTIONS, not
replies. Posts that merely quoted another note were therefore appearing
in the Replies tab.
Tighten the signal: a reply is now either a NIP-22 CommentEvent, or a
NIP-10 TextNoteEvent carrying an explicit `reply`/`root` marker tag
(`markedReply()` / `markedRoot()`). Unmarked e-tags no longer qualify.
Adds 6 regression tests including the unmarked-e-tag false-positive
case the user reported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.
- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
param. Default keeps Notes-tab behavior unchanged; when true, the
predicate becomes `event is TextNoteEvent && !note.isNewThread()`
(excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
new tab at index 1, body branch mirroring the Notes Loading/Empty/
Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.
NIP-22 kind 1111 deferred — most replies today are kind 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an
embedded parent card plus a "Replying to @displayName" label above the
reply body, matching Android's home-feed behavior. Extracts the shared
ReplyToLabel composable + ReplyContext data class to commons so Android
switches over to the shared version.
- commons/.../ui/note/ReplyContext.kt: data class + from(event, cache)
detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism.
- commons/.../ui/note/ReplyToLabel.kt: shared composable.
- commons/strings.xml: new "Notes & Replies" section + replying_to key.
- desktopApp NoteCard: replyContext param + render branch (bordered
QuotedNoteEmbed + ReplyToLabel). Recursion impossible because
QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext.
- desktopApp FeedScreen: rememberReplyContext() observes parent
metadata flow so embed/label pop in once the parent arrives via
relay subscription. Wired into both regular and reposted-inner paths.
- amethyst ReplyInformation.kt: removed local ReplyToLabel definition.
- amethyst Text.kt: calls shared commons ReplyToLabel; resolves author
display name at the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agora (a crowdfunding client on the Ditto stack) publishes fundraising
campaigns as kind 33863 — an app-specific addressable kind with no NIP.
Amethyst had no parser or renderer, so it hit the "Event Not Supported"
path and was dropped; reposts of one rendered as a permanently blank card.
Add first-class support, modelled on NIP-99 Classifieds (title/image/body)
plus NIP-75 zap goals (goal/deadline/progress).
Move isRenderableRepost() (and its test) from amethyst ui/dal into
commons/ui/feeds so both platforms use one implementation, then point
desktop's isFeedNote() at it.
Three names didn't describe their behavior:
- removeFromCache → unlinkAndRemove: the method's main job is unlinking the
note from every referrer (parents, channels, the report/card/status/poll
indexes), not just evicting it from the map; the old name only captured
the last step.
- removeAllChildNotes → clearChildLinks: it clears only THIS note's forward
child collections and returns them — it does not touch the children's
replyTo and does not remove anything from the cache. The old name sounded
more aggressive than detachFromChildren(), which is actually the
both-directions op.
- Note.removeOnchainZap(source) → removeOnchainZapBySource(source): too easy
to confuse with removeOnchainZapForSource(txid, pubkey), which is the
verification-verdict removal with anti-spoof guards. The new name matches
its inner helper (innerRemoveOnchainZapBySource) and disambiguates the two.
Pure rename: no behavior change. Test names/comments updated to match.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
deleteNote() (NIP-09) and removeFromCache() (prune) had drifted into two
near-duplicate "detach a note from the cache" routines. Removal really has
two halves — (1) unlink the note from everything that points AT it, and
(2) handle the note's OWN children — and only the second half differs
between the paths. removeFromCache() already implemented half (1)
completely, so deleteNote() now delegates to it and keeps only its two
delete-specific responsibilities: tearing down gift-wrap hosts and
severing (but keeping) its children via detachFromChildren().
This also fixes a real leak the duplication was hiding. computeReplyTo()
has no ReportEvent branch, so a report note's replyTo is empty and the
report→target link lives only in the explicit reported* index handling.
The old deleteNote() only undid reportedAuthor(), so deleting an
event-level report (reportedPost / reportedAddresses) left the reported
note's `.reports` map holding the removed report note — a partial deletion
that leaked the shell and risked a duplicate Note for the same id.
Delegating to removeFromCache() (author + post + addresses, all
idempotent) closes that gap.
Net behavior change is the report-leak fix only; the redundant
TorrentCommentEvent case is dropped because the torrent target is already
in replyTo (and removed via removeNote), and its @Suppress("DEPRECATION")
goes with it. Adds KDoc to both methods documenting the two-halves model.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Reframe from a forward-looking plan diff to the current architecture, and fix
the onAuthenticated signature to (event): Boolean (pubKey was dropped). All
structural elements already matched the merged code.
The auth set is already a per-RelaySession instance field (one session per
connect(), fresh policy per connection), so two connections never share auth
state. Add a regression test: authenticate different pubkeys on two connections
of the same server and assert each scope holds only its own — no union leak.
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.
Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
verified pubkey (default false, so blind-accept policies never record an
unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
whole chain approves and a verifying policy votes to record.
FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
removeFromCache() relied solely on note.inGatherers to detach a pruned
note from its channels, while deleteNote() additionally resolved the
channel via getAnyChannel() and removed the note there too. inGatherers
is normally authoritative (Channel.addNote always calls addGatherer), so
this is defensive rather than a confirmed live leak — but it closes the
divergence so both removal paths detach channels identically. Guards
against any future consume path that adds a note to a getAnyChannel-
resolvable channel without the gatherer link: otherwise the note would
linger in the channel's notes map after leaving the cache, leaking it and
letting a relay echo mint a duplicate Note with the same id.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s