- All chip FlowRows on the Send Payment screen (Receive on, Pay from,
zap types, amount presets, fee tiers) now declare an 8dp vertical
arrangement so wrapped lines get the same gap as the in-row spacing —
the custom Receive-on pills had no intrinsic padding and touched when
the row broke.
- The cashu rail chip and the cashu 'Pay from' wallet chip use the Cashu
vector mark (tinted with the chip content color) instead of the
generic wallet symbol.
Delivery metadata no longer lives on quartz event classes. The mutable
host var on @Immutable events (with its dual @Transient annotations) is
gone, and any event kind can now be a rumor without subclassing anything
— kind-14 chats and kind-1 private replies use one mechanism.
- commons RumorHosts: rumor id → delivering envelope (the kind-1059
wrap normally, a bare kind-13 seal otherwise), populated by the
gift-wrap ingestion pipeline from the publicNote threaded through the
handlers (the seal's host pointer was never needed)
- Note.toNEvent cites the envelope for ANY rumor — this also fixes
kind-1 private replies, whose nevent previously exposed the private
rumor id (kind-14s were already wrap-cited)
- Account: rumorHost() reads the index; relay computation refuses
seals, inner DM messages, and unsigned rumors explicitly
- LocalCache: deleteWraps → deleteEnvelopes (also removes the seal
layer the old host-chain walk missed); removeIfWrap and chat-history
pruning read the index; index entries are dropped with their rumor
- quartz: SealedRumorEvent and BaseDMGroupEvent extend Event directly;
GiftWrapEvent.unwrap no longer injects host stubs; WrappedEvent
deleted
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
The detail text under the Send Payment rail selector (e.g. the lightning
address, already visible in the recipient header) is gone. Instead every
chip carries its destination via long-press copy:
- Send Payment 'Receive on' chips copy the lightning address, the
noffer pointer, the on-chain destination (announced target address or
the pubkey-derived Taproot address — otherwise invisible), and the
shared cashu mint URL. The chips are now custom selectable pills since
M3 FilterChip has no long-press support.
- Profile rail chips gain the same copy values (clink noffer, derived
taproot address, mint URL) alongside the existing lightning and
payment-target copies.
- The announced bitcoin address moved from the detail line into the
on-chain receipt note so it stays visible when it differs from the
derived address.
The wallet-rail chips and the NIP-A3 payment-target chips were two
separately padded FlowRows, so the gap between the two rows was about
double the in-row spacing. The target chips now render inside the same
FlowRow as the rail chips, wrapping together with uniform 6dp spacing.
DisplayPaymentTargets is gone; PaymentTargetChip is rendered by
DisplayPaymentRailChips directly.
- Replace the NewWorkoutDialog with a Route.NewWorkout full screen
(NewGoalScreen pattern: NewWorkoutViewModel + PostingTopBar), which
also fixes the audit-found race where the dialog closed before the
signer finished — the screen now pops back only after a successful
sign/broadcast, keeping external-signer (Amber) flows alive.
- Move template building from Account.sendWorkout into the ViewModel.
- Render workout cards in thread view (ThreadFeedView fall-through gap).
- Snapshot parsed workout tags once per note (remember + WorkoutInfo)
instead of re-scanning the tag array on every recomposition.
- Fix Double.trimmed() Int overflow on absurd distances.
- Flatten the distance-unit chip layout.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
The Cashu vector is a monochrome black outline meant to be tinted like a
Material Symbol — with Color.Unspecified it rendered black and vanished
on dark backgrounds. Tint it with the chip color and lift the purple
from 0xFF7E57C2 to 0xFFA855F7 so the chip reads on both themes.
Replaces the lightning-address icon+text row with a chip and completes
the rail set, so every way to pay a profile reads as one chip row:
- Lightning chip shows the lud16 (long-press still copies it) and opens
the Send Payment screen on the Lightning rail.
- CLINK Offer chip moves into the same row (same look as before).
- New On-chain chip (when the chain backend is configured) and Cashu
chip (when the logged-in user's cashu wallet shares a mint the
recipient accepts), each opening their rail on the Send Payment
screen.
- All four render through a shared ProfilePaymentChip pill that matches
the NIP-A3 payment-target chips, replacing the old DisplayLNAddress
row and the one-off clink chip in DrawAdditionalInfo.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Quartz: new experimental/fitness/workout package shaped like nip88Polls —
WorkoutRecordEvent with per-tag classes (exercise, duration, distance,
elevation, calories, steps, heart rate, splits, strength sets/reps/weight,
source, workout_start_time), TagArrayBuilder/TagArray extensions, lax
RUNSTR-dialect parsing (unit defaults, HH:MM:SS or raw seconds), and
EventFactory + LocalCache registration. Covered by fixture tests.
Amethyst: new Workouts feed (drawer entry, route, follow-list top bar,
per-relay filter assemblers mirroring the Pictures feed) with a + FAB
opening a manual workout composer that publishes canonical kind-1301
events. Workout cards render stats chips and also display inside threads
via NoteCompose. Adds fitness Material Symbols glyphs and regenerates the
subset font.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
Pinned chatrooms were stored local-only in encrypted SharedPreferences, so
they were lost on uninstall and never reached other devices. Move them into
AccountSyncedSettings as a new 'chats' group in the encrypted settings blob
(each room serialized as its member pubkeys sorted ascending), publishing a
new AppSpecificData event on every pin/unpin like the other synced settings.
Local persistence now comes from the existing latestAppSpecificData event
backup, so the dedicated pinned_chatrooms preference is removed (the local
format never shipped, so no migration is needed). Pins arriving from another
device flow through AccountSyncedSettings.updateFrom and re-sort the chat
list via the existing pinnedChatrooms feed invalidation collector.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
All CLINK tests failed on iosSimulatorArm64 with IllegalArgumentException
because OptimizedJsonMapper on native dispatches through
KotlinSerializationMapper, whose fromJsonTo/toJson type lists did not
include the CLINK payload DTOs (Jackson handles them reflectively on
JVM/Android, which is why only iOS failed).
Adds hand-written kotlinx serializers for OfferRequest/OfferResponse/
OfferReceipt, DebitRequest/DebitResponse, and ManageRequest/ManageResponse,
mirroring Jackson behavior: ManageResponse.details coerces a lone object
into a one-element list (ACCEPT_SINGLE_VALUE_AS_ARRAY) and
OfferRequest.payer_data round-trips as a free-form JSON object.
Covered by a JVM test driving KotlinSerializationMapper directly and
cross-checking against Jackson, since the native path shares this code.
https://claude.ai/code/session_01SevV4fUCumKZ1UscSz85vS
Reading pinnedChatrooms.value in the UserRoomCompose body invalidated the
whole function scope on every pin toggle. Keep the single subscription but
read the set only inside the firstRow slot (pin icon) and the dropdown
menu-item text, so those two small scopes are the only ones that recompose.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
The screen now shows which wallet the payment will come from and lets
the user switch before paying:
- Lightning and CLINK-offer rails list every configured wallet (NWC +
CLINK debit, via PaymentSourceResolver.all) plus an 'Another wallet
app' entry that hands the invoice to the system via intent. The
selection defaults to the account's default payment source and
re-resolves if the picked wallet is removed while the screen is open.
- On-chain and cashu rails show a fixed, disabled chip naming their
intrinsic wallet so the money's origin is always visible.
- payBolt11 now charges the picked source instead of silently using the
account default.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Lightning payment targets already route into the Send Payment screen;
this extends the same treatment to bitcoin targets. Tapping a profile's
bitcoin payment-target chip (or its pay action in the wallet-button
dialog) now opens the Send Payment screen with the on-chain rail locked
to that announced address, paid directly from the user's NIP-BC Taproot
wallet — falling back to the external bitcoin: URI when the chain
backend is missing or the address isn't a payable native-segwit mainnet
address.
- quartz: SegwitAddress.scriptPubKeyFor/isPayableMainnetAddress;
OnchainZapBuilder.buildToScripts core shared by the pubkey paths.
- commons: OnchainZapSender.sendToAddress — plain wallet send with the
same fund-safety signing contract but no kind:8333 receipt (the
destination isn't pubkey-derived, so none is possible); the signing
block is now a single shared helper across send/sendSplit/sendToAddress
and Success.receiptEventId is nullable for receipt-less sends.
- amethyst: Account.sendOnchainToAddress; Route.SendPayment gains
btcAddressOverride; a shared inAppPaymentRouteFor() decides which
payment targets the user's wallets can pay in-app (used by both the
target chips and the payment-targets dialog).
- Send Payment screen: with an address override the on-chain rail shows
the target address, hides the message field (no receipt to carry it),
explains that no zap receipt is published, and dispatches the plain
address send.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Brings DM-style broadcast to kind-1 private replies. Kind-14 chats carry
the kind-1059 host pointer on the event (WrappedEvent); other rumor
kinds can't, so LocalCache gains a rumorHosts index (rumor id → wrap
HostStub) populated when seals are unsealed and on seal replays after a
cache rebuild.
- Account.rumorHost(event): host from the event (WrappedEvent) or the
index (other rumor kinds)
- Account.broadcast: any rumor with a known host re-downloads the wrap
by id and republishes it — the unsigned rumor itself is never sent;
rumors with no known wrap stay non-broadcastable
- Broadcast menu rows reappear for rumors when the wrap is known
(AccountViewModel.canBroadcast), restoring the DM behavior the
earlier blanket isPrivateRumor gate had also hidden for kind-14s
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Merge-readiness audit follow-ups:
- Account.report(note): reporting a private rumor now reports the AUTHOR
(p-tag only) instead of publishing a kind-1984 that e-tags the private
rumor id onto public relays (the one confirmed leak)
- Defense-in-depth guards at the model layer so the invariant no longer
relies on UI gating alone: RepostAction.repost returns null / throws
for empty-sig targets (covers Account.boost, createBoostEvent, and the
desktop call path), ReactionAction.reactTo (simple overload) throws,
and Account.broadcast no-ops for unsigned non-wrapped events — without
the guard it would disclose the rumor JSON to relays even though they
reject the signature
- Hide remaining actions that can't work on private rumors, per review:
share buttons (action row, both note menus) and all bookmark/playlist/
emoji-list rows (their lists reference an id other devices can't
resolve; public lists would also leak it)
- ZapCustomDialog: remember(accountViewModel, baseNote) so the
preselected zap type can't go stale on lazy-list slot reuse
Broadcast of the gift wrap itself (like DMs do via WrappedEvent.host)
needs host tracking for non-WrappedEvent rumor kinds in quartz — left
as a follow-up; the broadcast row stays hidden for kind-1 rumors.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Gift-wrapped un-react:
- NIP17Factory.createDeletionNIP17 wraps a NIP-09 deletion to explicit
recipients + self-copy, so the retracted rumor id never reaches public
relays; DeletionIndex keys by (id, pubkey) and rumor pubkeys are forced
to the seal's, so wrapped deletions are authenticated on receive
- Account.deletePrivately sends the wrapped deletion to the target
rumor's participants (author + tagged users)
- AccountViewModel.reactToOrDelete now partitions reactions: public ones
get a public NIP-09, rumor reactions get a wrapped one — un-react on
private notes and NIP-17 chats works instead of no-op
Force-private zaps on private rumors:
- AccountViewModel.zap forces ZapType.PRIVATE for empty-sig targets
(NONZAP kept: no receipt at all is even more private)
- ZapCustomDialog only offers Private/None for private targets
- Zap button re-enabled on private rumors; nutzap (public kind 9321) is
refused with an explanatory error and the onchain rail is hidden, as
both would e-tag the rumor id publicly
- Note: the LN provider's public 9735 receipt still carries the e-tag —
the private zap type protects sender identity and comment, not the
zapped id itself
Also verified: ReactionEvent consume counts empty-sig rumors (wasVerified
path) so wrapped reactions tally correctly.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Phase 3 of the private-notes plan: the composer's Notify row gains an
'+ Add' chip backed by the existing user-suggestion search, so users can
p-tag people who aren't cited in the text — for any post, public or
private. While the private toggle is ON the row is always visible,
relabeled 'Visible to' (the p-tags ARE the audience of the wrap), and an
empty list shows a 'only you will see this' hint for self-only notes.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Phase 2 of the private-notes plan: the short-note composer gains a
private (lock) toggle that gift-wraps the kind-1 to its p-tagged users
plus a self-copy instead of publishing it.
- NIP17Factory.createNoteNIP17: wraps a TextNoteEvent template to its
taggedUserIds + the sender (only the unsigned rumor form travels)
- Account.sendPrivateNote: signs, wraps, and routes each wrap to the
recipient's DM relays via the existing broadcastPrivately path
- ShortNotePostViewModel: wantsPrivateNote/privateNoteLocked state;
forced ON and locked when replying to an unsealed rumor (and when
reloading a drafted private reply); private wins over anonymous and
scheduled modes so a locked reply can never fall through to a public
publish path
- ShortNotePostScreen: lock toggle in the bottom action row; mutually
exclusive with polls; schedule and anonymous hidden while private
- ReactionsRow: reply re-enabled on private rumors now that the
composer locks privacy for them
Drafts stay enabled: TextNoteEvent does not implement ExposeInDraft, so
draft wrappers carry no anchor e-tags — the parent rumor id only exists
inside the NIP-44 encrypted draft content.
Verified by PrivateNoteFactoryTest: wraps cover p-tags + self, and the
recipient's unwrap yields a rumor with the same id and an empty sig
(the Note.isPrivateRumor() discriminator).
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Unsealed NIP-59 rumors (private replies/posts arriving in gift wraps
from other clients) were indexed as ordinary notes: public reactions,
reposts, edits, pins, OTS timestamps, labels, public bookmarks, and
deletion requests could all e-tag the private rumor id onto public
relays.
- Note.isPrivateRumor(): empty-signature discriminator (rumors are the
only notes materialized with an empty sig; draft inners are never
indexed as standalone notes)
- ReactionAction: reactions inherit the target's privacy — empty-sig
targets get gift-wrapped kind-7s fanned to the rumor author, every
tagged user, and the sender's self-copy (add-only; un-react would
need a public NIP-09 deletion that leaks the rumor id)
- AccountViewModel.reactToOrDelete: never NIP-09-delete rumor reactions
(also fixes the same leak for existing NIP-17 chat reactions),
tracked-broadcast mode excluded for rumor targets
- ReactionsRow: hide reply/boost/zap on private rumors (each publishes
a public e-tag of the target); like stays, now wrapped
- DropDownMenu/NoteQuickActionMenu: hide broadcast, edit, timestamp,
pin, hashtag label, public bookmarks, deletion request for rumors;
private bookmarks and block/report stay available
- Lock badge in the note header (reuses existing Lock glyph, no font
regen needed)
Covered by ReactionActionTest (public vs rumor fan-out, jvmTest green).
Plan: commons/plans/2026-06-10-private-replies-reactions-posts.md
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
The profile_thumbnails_v2 dir was only created in ThumbnailDiskCache's
constructor, but Android can delete cache subdirectories while the app
runs (system cache trim under storage pressure, or the user tapping
Clear cache in Settings). After that, every generateFromFile call
failed with ENOENT on the temp-file write until process restart,
silently disabling thumbnail caching.
Recreate the dir right before the write, and add instrumented
regression tests covering the cleared-at-runtime path.
https://claude.ai/code/session_01RQinCw5QKpaYXqtyb4gf3h
Audit fixes for the unified payment screen:
- Re-peek cashu nutzap funding when the profile data refreshes so a
late-arriving kind:10019 doesn't keep the Cashu rail hidden, and read
the on-chain backend availability live instead of freezing it at first
composition.
- Seed the active CLINK offer only while unset so a kind:0 refresh
mid-flow can't discard an expired-or-moved redirect; drop the !!
derefs in the offer range check.
- Marshal payment-callback stage updates to the Main scope (matching the
app's progress-callback convention) and run the on-chain send off the
Main thread since the sender signs the PSBT on the calling thread.
- Launch the external-wallet intent from the Main scope instead of the
invoice fetcher's IO callback.
- Restore the old LN-address error affordance: payment failures now
offer 'Message the recipient about this', opening a DM prefilled with
the failure detail.
- Reuse the wallet sheet's FeeTier instead of a duplicated enum, memoize
the zap-type options, and hoist the lightning target-type set.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Catalogs every Nostr kind the RUNSTR app publishes/consumes, the exact
1301 tag dialect, the Supabase-migration caveats, and a phased plan for
Quartz event classes and Amethyst fitness screens.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
- Rename Nip05Test backticked test name to drop parentheses, which are
illegal identifier characters on Kotlin/Native (iosSimulatorArm64).
- Resolve CLINK budget toast strings at composition time via stringRes
instead of context.getString inside the async callback, fixing the
LocalContextGetResourceValueCall lint errors in WalletScreen.
https://claude.ai/code/session_01UgP8ErzBbQYkTDtkJx5nrt
- Clamp the seeded kind:445 subscription since at wall-clock now: the
inner createdAt is sender-controlled, so a single future-dated message
could push since past the present and silently skip genuinely new
events on every restart. Covered by a new regression test.
- Drop the remember() around the group-list unread count: the chatroom's
message set can shrink without newestMessage or lastReadTime changing
(pruning, kind:5 deletion of an older message), which left the cached
count stale. The set is pruned to ~100 entries, so counting per
recomposition is cheap.
- Extract marmotGroupLastReadRoute(): the "MarmotGroup/<id>" last-read
key was inlined at three call sites; a prefix drift between the
mark-as-read side and the unread checks would silently reintroduce
the bug this branch fixes.
- Derive GROUP_EVENT_REFETCH_OVERLAP_SEC from TimeUtils.ONE_DAY instead
of re-deriving 24*60*60.
Restores the non-null zappedEvent on NutzapEvent.build and adds a
separate buildToUser builder (p tag only, no e/k tags) for nutzaps that
target a profile instead of an event — mirroring NIP-57's profile zap
convention. CashuWalletOps.sendNutzap dispatches between the two.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Replaces the click-to-expand payment cards on the profile page with a
dedicated Send Payment screen that collects amount, optional message and
zap type, pays on the spot through the selected rail, and shows the
invoice-request + payment progress in the screen itself before closing.
- New Route.SendPayment(userHex, method, lnAddressOverride) with a
stateless SendPaymentContent (previews for editing, fixed-price clink,
in-progress, success and failure states).
- Rails offered per profile: Lightning (lud16/lud06 or a lightning
payment target), CLINK offer (kind-0 / NIP-05, with expired-or-moved
redirect), on-chain NIP-BC (fee tier selector), and NIP-61 cashu
nutzaps gated on a shared funded mint.
- Lightning rail keeps the Public/Private/Anonymous zap types and adds
the Non-Zap (plain payment) option; clink is a direct payment; cashu
and on-chain receipts are inherent to their protocols and noted as such.
- Paying from this screen skips the extra in-app wallet confirmation
dialog: the explicit amount + Pay tap is the confirmation.
- Profile LN-address row, CLINK chip, lightning payment-target chips and
the wallet button's pay action now navigate to the new screen; other
target types keep their external payto/URI behavior.
- NutzapEvent.build / CashuWalletState.sendNutzap now accept a null
zapped event so nutzaps can target a profile (p-tag only), and
AccountViewModel gains sendNutzapToUser + a zapType override on
sendSats.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
A public kind 1111 reply can never reach a private zapper: tagging the
decrypted sender would publicly expose them, so the composer correctly
refuses to — leaving the reply addressed to no one. Since only the zap
recipient can decrypt the sender, long-pressing a private-zap chip now
opens the DM room with that sender instead of the public comment
composer. Public and anonymous zaps keep the public reply path, and a
private zap we could not decrypt falls back to it as well.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Two bugs kept CLINK offer/debit round-trips from completing over the shared
account relay client:
- The offer relay was treated as a generic "new" relay, so with Tor on it
was dialed through the proxy and failed on services that block Tor exits.
Register the offer/debit relays as money-operation relays for the duration
of the round-trip; the subscribe()-triggered reconnect plus the
BasicRelayClient wrong-transport rebuild then move the socket to clearnet.
- The subscription id "clink-offer-<event id>" was 76 chars; relays cap REQ
subscription ids at 64 (NIP-01) and reject the over-long REQ outright, so
the reply never arrived. Use newSubId(); the reply is matched by request
id in the listener, not by subscription id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets,
so a wallet or payment-service relay fell through to newRelaysViaTor and
got forced over Tor regardless of the "Money operations via Tor" toggle
(which previously governed only HTTP clients). On services that block Tor
exits this silently broke NIP-47 and CLINK payments.
Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay
bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after
the onion reachability check). TorRelayState gains a persistent money-op
relay set — fed across all accounts from NIP-47 wallet relays and saved
CLINK debit relays via AccountsTorStateConnector — plus a reference-counted
ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The
websocket builder resolves the per-relay decision from live source values
so ad-hoc registration takes effect on the next connect with no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
connectAndSyncFiltersIfDisconnected() bailed whenever a socket already
existed, so a still-connecting socket built for the wrong transport (e.g.
a relay whose Tor classification changed since the dial started) could
never be preempted — it blocked until the hung dial timed out. The
connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds
ready sockets via needsToReconnect(); this covers the connecting state it
cannot see (isConnectionStarted() true but isConnected() false).
Now: if a socket exists but reports needsReconnect() (transport/proxy
mismatch against the current builder decision), drop it and redial on the
correct transport; otherwise leave it. Disconnected relays still honor
their reconnect backoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
which only exits the lambda; the collector kept running and each
subsequent playVideo() call accumulated a live collector that would
re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
volume=100/isMuted=false on a new URL, but the kdroidFilter player
retains its `volume` across openUri(); muting one track and starting a
new one left the engine silent while the UI showed unmuted. Reset
`player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
synchronously from the Compose getter; if native init throws (missing
GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
window would crash. Wrapped in runCatching and changed
activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
and GlobalFullscreenOverlay handle the null path by rendering the
thumbnail / blank backdrop respectively; playVideo()/playAudio()
surface "Video playback unavailable" through the existing
errorReason -> PlaybackErrorMessage path.
Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
the same VideoPlayerState while the feed card was already mounting
one, doubling the draw rate against the shared frame bitmap and
widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
path. Mini-preview now renders the cached thumbnail (or the music
icon fallback). 0.10.1 contains an upstream fix
("recover video playback after composition removal") but is not yet
on Maven Central — single-surface mounting is the only mitigation
we can ship today.
VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
Range: header and returned HTTP 200 with the full body, we capped
the copy at MAX_THUMB_BYTES and persisted the truncated file
forever. Subsequent thumbnail attempts hit the broken cache file
and re-failed JCodec/ffmpeg every time. Tag download results with
whether the server actually returned 206; on 200, extract from the
temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
DesktopHttpClient.currentClient() so thumbnail fetches respect the
user's Tor preference (fail-closed when Tor is expected but
bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
pipe: redirectError(DISCARD) so we never wait on stderr; a finally
block destroys the process if anything leaked through the timeout.
CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
build.yml and smoke-test-desktop.yml. They were targeting an
ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
~minutes of CI time per leg and tied the build to videolan.org
reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
the linuxdeploy-vs-appimagetool comment.
.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
reached the commit. Refined the ignore rules to keep stale vlc/ workspace
trees out of git (still ignored) while explicitly tracking the
ffmpeg/README.md drop-in slot under each OS. The READMEs document the
recommended LGPL build source per OS for the bundled-FFmpeg packaging
path.
Verified on macOS arm64:
./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
./gradlew :desktopApp:test BUILD SUCCESSFUL
./gradlew :desktopApp:spotlessApply clean
Refs PR #3175 review by @davotoula.