GitHub's macos-13 runner image is being retired. Drop the x64 macOS
legs from both the desktop and CLI release matrices; macos-14 arm64
continues to ship the macOS builds.
- ktlint 1.8.0 changed how rule providers are loaded, and spotless 8.4.0 ends up handing the engine an empty ruleProviders set — so every file fails with IllegalArgumentException: A non-empty set of 'ruleProviders' need to be provided.
Measured the actual Kotlin daemon peak RSS during a full cold compile
of :amethyst:compilePlayDebugKotlin (which transitively compiles
:quartz, :commons, :quic, :nestsClient as well, in parallel after the
earlier parallel-mode change). Peak RSS was ~6.3 GB at the previous
12g/4g setting, meaning heap usage peaked around 4-5 GB plus
metaspace and native memory.
Drops to -Xmx8g -XX:MaxMetaspaceSize=2g, which:
- Cuts committed-virtual-memory ask from 18 GB (Gradle 6g + Kotlin 12g)
to 14 GB, comfortably under typical CI runner RAM and well under the
15 GB available in this dev environment.
- Frees ~4 GB of headroom for the OS file cache, which speeds up
classpath snapshot I/O on incremental builds.
- Leaves clear headroom over peak usage so GC pressure stays low; we
re-measured cold compile after the change at 3m30s vs 3m34s before,
well within run-to-run noise.
- Peak Kotlin daemon RSS at the new setting: 4.96 GB, confirming the
daemon right-sizes itself rather than pinning the ceiling.
If a much larger codebase target lands later (e.g. iOS framework
compilation in this same daemon), bump back up — the value is not
sacred, it just shouldn't request more than the host can afford.
The :ammolite module contained no production Kotlin/Java sources (just
a manifest, build.gradle, and proguard stubs) and no module in the
codebase imports com.vitorpamplona.ammolite.*.
Removes:
- ammolite/ directory (5 files)
- :ammolite project include in settings.gradle
- implementation project(':ammolite') from :amethyst
- androidTestImplementation project(':ammolite') from :benchmark
- :ammolite:testDebugUnitTest from CI workflow and pre-push hook
- -keep class com.vitorpamplona.ammolite.** rules from
:amethyst, :commons, and :desktopApp proguard files
- Stale references in CONTRIBUTING.md, CLAUDE.md, and the
gradle-expert skill dependency-graph doc
Small build-graph win: one fewer module to configure, compile, lint,
and spotless-check on every build, and one fewer unit-test target in
both CI and the local pre-push hook.
Auto-generated equals/hashCode would compare the ByteArray-list of
addresses by reference identity — a footgun no caller needs. No code
uses ==, copy, componentN, or hashing on records, so the class is now
plain with no synthesized methods.
The legacy `.json` reclaim was running in the constructor, which fires
on Application#onCreate (main thread) — a strict-mode regression vs the
prior no-op constructor. Moved into load(), which is documented as
background-only.
Also wraps the tmp-file write/rename in a try/finally so a writeRecords
crash partway (corrupt record, disk-full mid-write) or a copyTo
fallback can't leave an orphaned `.tmp` sibling behind.
Replaces Jackson-based JSON serialization with a compact big-endian
binary format (magic + version + per-record host/ip bytes) so cold-start
load/save is ~5-10x faster and the blob shrinks from ~55 KB to ~25 KB
for the ~700-host workload.
DnsCacheRecord now carries raw `ByteArray` addresses so the persistence
boundary uses `InetAddress.address` / `InetAddress.getByAddress(byte[])`
on both sides — no string formatting or literal re-parsing on the hot
path. `SurgeDnsStore` validates magic, version, and length-bounded
counters; corrupt or truncated blobs are deleted and ignored. The
constructor reclaims the legacy `dns_cache_v1.json` sibling on first
run.
Kotlin 2.x removed the classpath-snapshot incremental-compilation
strategy in favor of ABI snapshots, which are always on. The flag is
now a deprecation warning at configuration time. Drop it.
Parallel + build-cache + bumped Kotlin daemon metaspace remain.
Halves cold compile time for compilePlayDebugKotlin on this machine
(6m25s -> 3m26s, 47% reduction):
- org.gradle.parallel=true lets quic/nestsClient/commons compile
concurrently once quartz is done, instead of serially.
- org.gradle.caching=true lets warm cross-clean builds reuse task
outputs.
- kotlin.incremental.useClasspathSnapshot=true narrows the incremental
recompile blast radius after dependency-jar changes.
- Bumps Kotlin daemon MaxMetaspaceSize 3g -> 4g for headroom under
the Compose IR phase across ~2.8K @Composable functions.
Up-to-date and same-file incremental times are unchanged (~3s and ~4s)
since those weren't bottlenecked by these settings.
Configuration cache is NOT enabled: amethyst/build.gradle:12 runs
'git rev-parse --abbrev-ref HEAD' at configuration time, which is
incompatible with the configuration cache. Migrating that to a
ValueSource is a separate change.
RFC 1034: `localhost.` and `localhost` are the same name — the trailing
dot just marks the FQDN form. Without this, an upstream answer of
127.0.0.1 for `localhost.` (or `relay.localhost.`) would get filtered as
poison, breaking user-configured local relays addressed in FQDN form.
SurgeDns was faithfully caching whatever the system resolver returned —
including 127.0.0.1 / ::1 / 0.0.0.0 — for 24-48h plus an on-disk
snapshot. A single bad answer (captive portal, ad-blocker DNS, transient
VPN hiccup) could leave the user unable to reach any non-loopback relay
for days: connection attempts go to their own loopback, fail, and the
next lookup re-resolves through the same source.
- Filter loopback/any-local addresses out of lookupAndCache and restore,
unless the hostname is itself a loopback name (localhost, .localhost
subdomains, or 127.0.0.1 / ::1 literals) so user-configured local
relays keep working.
- Mark cache dirty when restore drops poisoned on-disk entries so the
next save rewrites the snapshot without them — otherwise the bad
entries would be re-restored on every cold start.
Moves the SurgeDns persisted snapshot from a SharedPreferences blob
under /data/data/.../shared_prefs to a plain JSON file under cacheDir.
The snapshot is pure perf data — if the OS evicts it under storage
pressure, the resolver just falls back to sync getaddrinfo and rebuilds
as lookups happen, which is the correct trade-off for a cache.
Writes go through a sibling .tmp + rename so a crash mid-write can't
leave a half-written blob behind. AppModules also deletes the legacy
amethyst_dns_cache SharedPreferences on next launch so the old store
doesn't linger.
The join/leave-request buttons launched a raw scope.launch(Dispatchers.IO)
and called account.signer.sign() directly. When the signer threw —
TimedOutException from a Samsung-A53 / Android-16 Amber prompt the user
didn't respond to in 30s, or any other SignerException — the failure
escaped the composable's scope and was reported as an uncaught crash
instead of being toasted/logged like every other signing operation.
Route both buttons through accountViewModel.launchSigner so the same
SignerExceptions handling that every other signing entry point uses
applies here too.
ChatroomListKnownFeedFilter.feed() iterated account.publicChatList.flow,
a Set<ChannelTag>. ChannelTag uses identity equality (it's not a data
class) and the set is built from raw tag parsing, so a channel list
that names the same channel id twice (e.g., the same channel appearing
in the public section and the encrypted private section, or repeated
with different relay hints) produces multiple ChannelTag entries for
the same channel. Each one resolved to the same newest Note via
getOrCreatePublicChatChannel(it.eventId), so the feed contained the
same Note twice and the chat list LazyColumn crashed with "Key
PublicChannelLazyKey(channelId=...) was already used".
Use the sibling flowSet (Set<HexKey>, already deduped by event id),
which is the same source filterRelevantPublicMessages reads from.
rememberForeverPagerState persists currentPage across tab-count changes.
When the user toggles off one of the conditional Home tabs (New Threads,
Conversations, Everything), tabs.size shrinks but pagerState.currentPage
is still pointing at the removed slot, so Material3's
TabIndicatorOffsetNode reads tabPositions[currentPage] out of bounds and
crashes with IndexOutOfBoundsException.
Clamp the index for the TabRow and use getOrNull for the bottom-bar
re-tap callback.
ChatroomListKnownFeedFilter.updateListWith only recognized
ChannelMessageEvent when checking the old list for an existing row to
replace. When a public channel's row was first populated from a
ChannelCreateEvent or ChannelMetadataEvent (no message had arrived
yet), the next incoming ChannelMessageEvent failed to match, so the
filter appended a second note for the same channel. Both rows then
produced the same PublicChannelLazyKey, crashing the LazyColumn with
"Key was already used".
Resolve the channel id from any IsInPublicChatChannel event (covers
ChannelMessageEvent and ChannelMetadataEvent) and fall back to the
event id for ChannelCreateEvent, mirroring how the lazy key is built
in ChatroomListFeedView.
Re-introduces the lifecycle observer that was dropped in the Feb 24
flow refactor of GetVideoController (f2410a69 "removing most of the
little hacks to get the controller to work in the lifecycle"). Without
it, the scroll-position mutex is the only thing that pauses a video —
and the window's visible rect doesn't change on ON_PAUSE, so the
currently-playing video kept playing forever behind other apps.
Pause on ON_PAUSE, resume on ON_RESUME if the video is still the
visible/active one. The explicit BackgroundMedia (PiP) instance is
preserved as the opt-in to keep playing.
Compose Multiplatform 1.11.0 wired ProGuard 7.7.0 into the release
build for the first time. With optimize + shrink + obfuscate all on,
the desktop v1.09.1 DMG hit four distinct runtime failures:
A. Jackson's SerializationFeature.values()/valueOf() stripped, so
Class.getEnumConstants() returned null and ObjectMapper failed
to initialise (app didn't launch). Fixed in #2921.
B. fr.acinq.secp256k1.* JNI bridge classes renamed by obfuscate;
bundled libsecp256k1-jni.dylib could not FindClass the original
names, so KeyPair / sign / verify crashed on first use.
C. androidx.sqlite-bundled native declarations (nativeThreadSafeMode,
nativeOpen) shrunk away because no Kotlin caller referenced them
directly; libsqliteJni.dylib raised NoSuchMethodError on the first
EventStore query.
D. ProGuard's method/specialization/returntype optimize sub-pass
generated a synthetic okio bridge (Okio__OkioKt.buffer$<hash>)
whose declared return type was RealBufferedSource but whose body
returned the BufferedSource super-interface. The JVM verifier
rejected the bridge, killing every OkHttp connection.
The Android (mobile) module solved the same class of problems in
amethyst/proguard-rules.pro with a single global strategy:
-dontobfuscate
-keepnames class ** { *; }
-keep enum ** { *; }
+ per-library -keep rules for JNA / libsodium / libscrypt
+ first-party -keep com.vitorpamplona.**
This commit replays that strategy in desktopApp/compose-rules.pro
and drops the previous optimize.set(false)/obfuscate.set(false)
escape hatch in desktopApp/build.gradle.kts. Shrink and optimize
both stay on; only the one optimize sub-pass that produced invalid
okio bytecode (method/specialization/returntype) is disabled.
Additional desktop-only keep rules (Jackson, full JNA, VLCj,
SLF4J, OkHttp / Conscrypt / BouncyCastle / OpenJSSE / Graal
dontwarns) stay in place. Two libraries shipped only on desktop
also get explicit -keep rules so their native callbacks survive
shrink:
- androidx.sqlite.** + native <methods>; (nativeThreadSafeMode
was previously stripped even with -keepnames, because shrink
removes members the global rule doesn't cover).
- pt.davidafsilva.apple.** + native <methods>; for the macOS
Keychain JNI bridge.
Verified on macOS arm64 packageReleaseDmg:
- javap on the post-ProGuard jars confirms:
* fr.acinq.secp256k1.NativeSecp256k1 keeps its FQCN.
* SerializationFeature.values() / valueOf() present.
* androidx.sqlite.driver.bundled.BundledSQLiteDriverKt
retains native nativeThreadSafeMode() + nativeOpen().
* Okio__OkioKt only exposes buffer(Source): BufferedSource
and buffer(Sink): BufferedSink — no specialized
buffer$<hash> bridge.
- Bundled app launches and reaches VLC MediaPlayerFactory init
with zero VerifyError / NoSuchMethodError / UnsatisfiedLinkError
in the log.
The ImportFollowListDialog composable was already implemented but never
rendered. The File menu item set a boolean state that no observer
consumed.
Render the dialog from MainContent inside the CompositionLocalProvider
that supplies LocalNamecoinService, so Namecoin (.bit, d/, id/)
identifier resolution works in addition to npub/hex/NIP-05.
Also add a left-side launcher in both layouts so the feature is
discoverable without using the File menu:
- single-pane: NavigationRailItem (PersonAdd icon, 'Import' label)
- deck: IconButton in the DeckSidebar next to 'Add Column'
The desktop client already ships DesktopNamecoinPreferences, the
DesktopNamecoinNameService that consumes them, and the NamecoinSettingsSection
composable that's a port of the Android UI. The section just wasn't surfaced
in the desktop Settings screen.
This wires NamecoinSettingsSection into RelaySettingsScreen, between the Tor
section and the Developer / Relay sections. Preferences come from the
namecoinPreferences parameter that the deck container already passes in, and
fall back to LocalNamecoinPreferences when called from another caller.
User-visible behaviour: the .bit / d/ / id/ ElectrumX server settings (master
toggle, custom servers, defaults indicator, add/remove, reset) are now
reachable from desktop Settings and persist via java.util.prefs.Preferences.
The v1.09.1 desktop release (DMG/MSI/DEB/RPM) fails to launch on every
platform with:
Exception in thread "main" java.lang.ExceptionInInitializerError
at com.fasterxml.jackson.databind.ObjectMapper.<init>(ObjectMapper.java:700)
at com.vitorpamplona.amethyst.desktop.ui.deck.DeckState.<clinit>
Caused by: java.lang.NullPointerException:
Cannot read the array length because the return value of
"java.lang.Class.getEnumConstants()" is null
at com.fasterxml.jackson.databind.cfg.MapperConfig
.collectFeatureDefaults(MapperConfig.java:107)
Failed to launch JVM
Root cause: PR #2914 wired Compose 1.11.0's new ProGuard pass into the
release build but added only `-dontwarn` rules. ProGuard then optimized
Jackson's enum classes (`SerializationFeature`, `MapperFeature`, etc.) and
stripped their synthetic `values()` / `valueOf()` methods because nothing
in Amethyst's own bytecode calls them. Jackson does call them — but
reflectively, at `ObjectMapper` construction time, long after ProGuard
finished. The result is `Class.getEnumConstants()` returning null and the
JVM giving up.
The same regression silently broke VLC media playback (VLCj's
`ServiceLoader` lookup of `DiscoveryDirectoryProvider` impls printed
`ConfigDirConfigFileDiscoveryDirectoryProvider not found` because
ProGuard stripped both the META-INF/services file and the impl class).
Fix: add explicit `-keep` rules for the four reflection-heavy libraries
on the desktop classpath:
- Jackson (databind, core, annotations, module-kotlin): keep classes
intact and keep `values()`/`valueOf()` on every Jackson enum.
- JNA (com.sun.jna.\*\*): used by VLCj and kmp-tor — its `Structure`
field-order reflection requires field metadata to survive.
- VLCj (uk.co.caprica.vlcj.\*\*): `ServiceLoader`-based discovery providers
and reflective binding classes throughout.
- SLF4J: detected by Jackson via `Class.forName`.
Verified by building `:desktopApp:createReleaseDistributable` and
launching the resulting bundle on macOS arm64:
- No `ExceptionInInitializerError`
- `VLC: bundled discovery succeeded`
- `VLC: MediaPlayerFactory created successfully`
The same ProGuard pass runs on all four target formats (Dmg, Msi, Deb,
Rpm) so this single change fixes the regression on every desktop OS.