Merge branch 'main' into claude/improve-desktop-design-iOokB

This commit is contained in:
Vitor Pamplona
2026-04-24 17:35:13 -04:00
committed by GitHub
16 changed files with 1019 additions and 120 deletions
+26 -85
View File
@@ -31,14 +31,26 @@ jobs:
- name: Linter (gradle)
run: ./gradlew spotlessCheck
test:
test-and-build:
needs: lint
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
desktop-task: packageDeb
desktop-artifact-name: Desktop Linux DEB
desktop-artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
- os: macos-latest
desktop-task: packageDmg
desktop-artifact-name: Desktop macOS DMG
desktop-artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
- os: windows-latest
desktop-task: packageMsi
desktop-artifact-name: Desktop Windows MSI
desktop-artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
runs-on: ${{ matrix.os }}
timeout-minutes: 30
timeout-minutes: 60
defaults:
run:
shell: bash
@@ -53,12 +65,12 @@ jobs:
java-version: 21
cache: gradle
- name: Test (gradle)
run: ./gradlew test --no-daemon
- name: Test + Build Desktop (gradle)
run: ./gradlew test :desktopApp:${{ matrix.desktop-task }} --no-daemon
- name: Stop Gradle Daemon
if: always()
run: ./gradlew --stop
- name: Build Android Benchmark APKs (gradle)
if: matrix.os == 'ubuntu-latest'
run: ./gradlew assembleBenchmark --no-daemon
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
@@ -71,100 +83,29 @@ jobs:
name: Test Reports
path: amethyst/build/reports
build-android:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
cache: gradle
- name: Build APK (gradle)
run: ./gradlew assembleDebug
- name: Upload Play APK
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v7
with:
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v7
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Build Benchmark APK (gradle)
run: ./gradlew assembleBenchmark
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
- name: Upload Play APK Benchmark
uses: actions/upload-artifact@v7
if: matrix.os == 'ubuntu-latest'
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v7
if: matrix.os == 'ubuntu-latest'
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v7
if: matrix.os == 'ubuntu-latest'
with:
name: Compose Reports
path: amethyst/build/compose_compiler
build-desktop:
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
artifact-name: Desktop Linux DEB
artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
- os: macos-latest
task: packageDmg
artifact-name: Desktop macOS DMG
artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
- os: windows-latest
task: packageMsi
artifact-name: Desktop Windows MSI
artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
cache: gradle
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Stop Gradle Daemon
if: always()
run: ./gradlew --stop
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-path }}
+136
View File
@@ -173,6 +173,142 @@ jobs:
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Amy CLI build matrix. Each leg produces a self-contained amy bundle with a
# minimal jlink'd JRE — no system Java required on the user's machine.
#
# amyImage task (all legs): cli/build/amy-image/amy/ → amy-*.tar.gz
# jpackageDeb / jpackageRpm: cli/build/jpackage/amy_*.deb + amy-*.rpm
#
# macOS legs ship only the tarball. We deliberately avoid jpackage --type
# app-image on macOS because it produces an .app bundle (burying the binary
# at Contents/MacOS/amy) — wrong UX for a CLI.
#
# Windows is intentionally deferred — cli/ has not been validated on Windows
# yet (data-dir path handling, file locking on groups/<gid>.mls, line endings
# in identity.json).
#
# Asset naming: amy-<version>-<family>-<arch>.<ext>. See scripts/asset-name.sh.
# ---------------------------------------------------------------------------
build-cli:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "amyImage" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Resolve tag + version
id: ver
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TAG="${TEST_TAG:-v0.0.0-dryrun}"
else
TAG="${GITHUB_REF_NAME}"
fi
VER="${TAG#v}"
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
# On dry-run we only require that TOML has a version; on real tag push we require exact match.
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
if [[ "$TOML_VER" != "$VER" ]]; then
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
exit 1
fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Install RPM tooling (linux only)
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Build amy artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :cli:${{ matrix.tasks }}
- name: Collect + rename assets
run: |
set -euo pipefail
# shellcheck source=scripts/asset-name.sh
source scripts/asset-name.sh
collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
- name: Enforce CLI size budget (200 MB per asset)
run: |
set -euo pipefail
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
# targets < 80 MB, but :commons currently leaks Compose + Skiko as
# transitive deps (~40 MB of unused UI jars). Budget is set to
# 200 MB until commons is split into core + ui modules — track that
# as a follow-up. Until then, this gate just catches pathological
# regressions (e.g. accidental :amethyst dep pulling Android libs).
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget"
fail=1
else
echo "OK: $f — ${mb} MB"
fi
fi
done
[[ "$fail" == 0 ]]
- name: Classify release
id: classify
run: |
TAG="${{ steps.ver.outputs.tag }}"
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: false # Android job writes release notes (last-writer-wins race)
- name: Dry-run summary
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "### Dry-run: amy ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Android build + sign + direct-upload. Logic preserved from previous workflow;
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
@@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.Text
@@ -64,22 +63,6 @@ fun SwipeToDeleteContainer(
) {
val dismissState =
rememberSwipeToDismissBoxState(
confirmValueChange = {
when (it) {
StartToEnd -> {
onStartToEnd()
}
EndToStart -> {
return@rememberSwipeToDismissBoxState false
}
Settled -> {
return@rememberSwipeToDismissBoxState false
}
}
return@rememberSwipeToDismissBoxState true
},
positionalThreshold = { it * .40f },
)
@@ -88,6 +71,7 @@ fun SwipeToDeleteContainer(
modifier = modifier,
backgroundContent = { DismissBackground(dismissState) },
enableDismissFromEndToStart = false,
onDismiss = { if (it == StartToEnd) onStartToEnd() },
content = content,
)
}
@@ -49,6 +49,7 @@
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów</string>
<string name="login_with_a_private_key_to_like_posts">Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty</string>
<string name="no_zap_amount_setup_long_press_to_change">Nie ustawiono kwoty zap-a. Naciśnij i przytrzymaj, aby zmienić</string>
<string name="chat_zap_amount_suffix">przesłał %1$s satoszy</string>
<string name="chat_zap_anonymous">Anonimowo</string>
<string name="chat_raid_is_raiding">prowadzi nalot</string>
<string name="chat_clip_created_a_clip">stworzył klip</string>
@@ -338,6 +339,7 @@
<string name="drawer_accounts">Konta</string>
<string name="drawer_section_navigate">Nawigacja</string>
<string name="drawer_section_you">Ty</string>
<string name="drawer_section_feeds">Kanały</string>
<string name="drawer_section_create">Utwórz</string>
<string name="drawer_section_system">System</string>
<string name="account_switch_select_account">Wybierz Konto</string>
@@ -778,6 +780,8 @@
<string name="app_notification_replies_summary">Nowe odpowiedzi</string>
<string name="app_notification_mentions_channel_name">Wzmianki</string>
<string name="app_notification_mentions_channel_description">Powiadamia Cię, gdy ktoś wspomni o Tobie lub zacytuje Cię w poście</string>
<string name="app_notification_mentions_channel_message">%1$s wspomniał o Tobie</string>
<string name="app_notification_mentions_summary">Nowe wzmianki</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Połączenia przychodzące</string>
<string name="app_notification_calls_channel_description">Powiadomienia o przychodzących połączeniach głosowych i wideo</string>
@@ -1000,6 +1004,12 @@
<string name="account_settings">Ustawienia konta</string>
<string name="app_settings">Ustawienia aplikacji</string>
<string name="danger_zone">Niebezpieczna strefa</string>
<string name="reset_marmot_state">Zresetuj stan marmota</string>
<string name="reset_marmot_confirm_title">Zresetować stan Marmota?</string>
<string name="reset_marmot_confirm_body">Spowoduje to trwałe usunięcie każdego czatu grupowego Marmot, historii wiadomości i klucza MLS na tym urządzeniu dla bieżącego konta. Uczestnicy nie będą powiadamiani i mogą widzieć Cię w grupach do czasu następnej aktualizacji. Tej operacji nie można cofnąć. Nowy pakiet kluczowy zostanie opublikowany przy następnej synchronizacji aplikacji.</string>
<string name="reset_marmot_confirm_action">Zresetuj</string>
<string name="reset_marmot_success">Reset stanu Marmota.</string>
<string name="reset_marmot_failure">Nie można zresetować stanu Marmota: %1$s</string>
<string name="connectivity_type_always">Zawsze</string>
<string name="connectivity_type_wifi_only">Tylko WiFi</string>
<string name="connectivity_type_unmetered_wifi_only">Nielimitowany Wi-Fi</string>
@@ -1345,6 +1355,7 @@
<string name="new_exclusive_geo_note">Nowy GEO-ekskluzywny Wpis</string>
<string name="new_long_form_post">Nowy artykuł</string>
<string name="new_poll">Nowa ankieta</string>
<string name="new_zap_poll">Nowa ankieta</string>
<string name="new_regular_poll">Nowa standardowa ankieta</string>
<string name="new_picture">Nowe zdjęcie</string>
<string name="new_short_video">Nowy krótki filmik</string>
@@ -1608,7 +1619,7 @@
<string name="my_lists_and_sets">Moje Listy/Zestawy</string>
<string name="my_lists">Moje listy</string>
<string name="people_list_label">Użytkownicy</string>
<string name="select_list_to_filter">Wybierz listę profili, aby filtrować aktualności</string>
<string name="select_list_to_filter">Wybierz opcję, aby filtrować aktualności</string>
<string name="feed_group_feeds">Kanały</string>
<string name="feed_group_hashtags">Hashtagi</string>
<string name="feed_group_interest_sets">Grupy zainteresowań</string>
@@ -1812,6 +1823,7 @@
<string name="kind_follow_packs">Pakiety subskrybentów</string>
<string name="kind_reposts_16">Udostępnienia (16)</string>
<string name="kind_geohash_follows">Obserwacja geohashów</string>
<string name="kind_gift_wraps">GiftWraps</string>
<string name="kind_git_issue">Problem z Git</string>
<string name="kind_git_patch">Łatka Git</string>
<string name="kind_git_repo">Repozytorium Git</string>
@@ -2231,4 +2243,5 @@
<string name="emoji_private_badge">Prywatna emotikona</string>
<string name="emoji_public_explainer">Publiczne emotikony są widoczne dla wszystkich i pojawiają się w menu odezw oraz w oknie podpowiedzi \":\", jeśli ten pakiet znajduje się na liście emotikonów.</string>
<string name="emoji_private_explainer">Prywatne emotikony są przechowywane zaszyfrowane na transmiterach i widoczne tylko dla Ciebie. Znajdują się w menu odzewów oraz w autouzupełnianiu po wpisaniu znaku „:” – tak samo jak emotikony publiczne.</string>
<string name="gif">Gif</string>
</resources>
@@ -780,6 +780,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="app_notification_chess_challenge_accepted">%1$s je sprejel vaš šahovski izziv</string>
<string name="app_notification_chess_your_turn">Poteza uporabnika %1$s — vi ste na vrsti</string>
<string name="app_notification_chess_summary">Šahovske novosti</string>
<string name="app_notification_mentions_summary">Nove omembe</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Dohodni klici</string>
<string name="app_notification_calls_channel_description">Obvestila o dohodnih glasovnih in video klicih</string>
+1 -1
View File
@@ -196,7 +196,7 @@ Amy-specific layer still needs its own coverage:
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
| File layout on disk (`identity.json`, `relays.json`, `groups/*.mls`, `keypackages.bundle`) | Structural assertions after a command sequence. |
| File layout on disk (`identity.json`, `relays.json`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
| Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. |
+10 -9
View File
@@ -40,7 +40,7 @@ What every caller — user, script, agent, CI — can rely on:
- **Data-dir is the whole world.** All state (identity, relays, MLS
epochs, message archives, run cursors) lives under `--data-dir PATH`.
Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides
the default `./amethyst-cli-data`.
the default `./amy`.
The rationale behind each of these lives in
[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking
@@ -152,7 +152,7 @@ itself crashed".
### Global flags
- `--data-dir PATH` — defaults to `./amethyst-cli-data` or
- `--data-dir PATH` — defaults to `./amy` or
`$AMETHYST_CLI_DATA`. Always an absolute path after resolution.
- `--help` / `-h` — usage summary.
@@ -189,13 +189,14 @@ events.
```
<data-dir>/
├── identity.json # nsec/npub/hex — the account
├── relays.json # nip65 / inbox / key_package buckets
├── state.json # sync cursors (giftWrapSince, groupSince)
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
└── groups/
├── <gid>.mls # MLS group state per group
└── <gid>.log # decrypted inner events (one JSON per line)
├── identity.json # nsec/npub/hex — the account
├── relays.json # nip65 / inbox / key_package buckets
├── state.json # sync cursors (giftWrapSince, groupSince)
└── marmot/
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
└── groups/
├── <gid>.mls # MLS group state per group
└── <gid>.log # decrypted inner events (one JSON per line)
```
All files are plain JSON or framed binary — human-inspectable, easy to
+184
View File
@@ -34,3 +34,187 @@ application {
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
applicationName = "amy"
}
// ---------------------------------------------------------------------------
// Native distribution (jlink + jpackage)
//
// Produces a self-contained `amy` bundle with a minimal jlink'd JRE embedded —
// no JDK required on the user machine. Outputs land under cli/build/:
// - amy-image/amy/ portable, flat directory (bin/ + lib/ + runtime/)
// tar this up on every OS → amy-<ver>-<fam>-<arch>.tar.gz
// - jpackage/amy_*.deb Debian/Ubuntu package (Linux runners only)
// - jpackage/amy-*.rpm Fedora/RHEL package (Linux runners only)
//
// We deliberately build our own app-image instead of using `jpackage --type
// app-image`, because on macOS jpackage produces an `.app` bundle (with the
// binary buried at Contents/MacOS/amy) — awful UX for a CLI. The flat tree we
// build matches the Linux jpackage layout on every OS.
//
// Packaging for release is wired in .github/workflows/create-release.yml.
// See cli/plans/2026-04-21-cli-distribution.md for the overall plan.
// ---------------------------------------------------------------------------
val appVersion: String = project.version.toString()
// RPM rejects dashes in version strings — replace with tilde (~), which RPM
// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0.
val rpmVersion: String = appVersion.replace("-", "~")
val mainJarName: String = "cli-$appVersion.jar"
val mainClassName: String = "com.vitorpamplona.amethyst.cli.MainKt"
// Minimal JDK 21 module set for amy. Keep this tight — every module adds
// megabytes to the bundle. If a transitive dep needs more, `jlink` fails loudly
// at build time with "module X not found".
val jlinkModules: String = listOf(
"java.base",
"java.logging",
"java.naming",
"java.net.http",
"java.sql",
"java.xml",
"jdk.crypto.ec",
"jdk.unsupported",
).joinToString(",")
fun javaToolBin(name: String): Provider<String> =
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.map {
val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name
it.metadata.installationPath.file("bin/$exe").asFile.absolutePath
}
val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime")
val amyImageRoot = layout.buildDirectory.dir("amy-image")
val amyImageDir = layout.buildDirectory.dir("amy-image/amy")
val installLibDir = layout.buildDirectory.dir("install/amy/lib")
val jpackageOutDir = layout.buildDirectory.dir("jpackage")
val jlinkRuntime =
tasks.register<Exec>("jlinkRuntime") {
group = "distribution"
description = "Build a minimal JRE for amy via jlink."
outputs.dir(jlinkRuntimeDir)
val jlinkBin = javaToolBin("jlink")
val outDir = jlinkRuntimeDir
val modules = jlinkModules
doFirst {
// jlink refuses to write into an existing directory.
outDir.get().asFile.deleteRecursively()
executable = jlinkBin.get()
args(
"--add-modules", modules,
"--no-header-files",
"--no-man-pages",
"--strip-debug",
// JDK 21+: --compress <int> is deprecated; use zip-<level>.
"--compress", "zip-6",
"--output", outDir.get().asFile.absolutePath,
)
}
}
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
// Cross-platform — the release workflow tars this up on every OS.
val amyImage =
tasks.register<Sync>("amyImage") {
group = "distribution"
description = "Assemble a portable amy app-image (bin/ + lib/ + runtime/)."
dependsOn(tasks.named("installDist"), jlinkRuntime)
into(amyImageDir)
// jars from installDist
from(installLibDir) {
into("lib")
}
// jlink'd JRE
from(jlinkRuntimeDir) {
into("runtime")
}
val mainJar = mainJarName
val mainClass = mainClassName
val unixLauncher =
"""
#!/bin/sh
# amy launcher — uses the bundled jlink'd JRE so no system Java is required.
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
doLast {
val binDir = amyImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val launcher = binDir.resolve("amy")
launcher.writeText(unixLauncher)
launcher.setExecutable(true, false)
}
}
fun registerJpackage(
taskName: String,
type: String,
extraArgs: List<String> = emptyList(),
) = tasks.register<Exec>(taskName) {
group = "distribution"
description = "Run jpackage --type $type for amy."
dependsOn(tasks.named("installDist"), jlinkRuntime)
inputs.dir(installLibDir)
inputs.dir(jlinkRuntimeDir)
outputs.dir(jpackageOutDir)
val jpackageBin = javaToolBin("jpackage")
val inDir = installLibDir
val runtimeDir = jlinkRuntimeDir
val outDir = jpackageOutDir
val versionArg = if (type == "rpm") rpmVersion else appVersion
val extra = extraArgs
doFirst {
outDir.get().asFile.mkdirs()
executable = jpackageBin.get()
args(
"--type", type,
"--name", "amy",
"--app-version", versionArg,
"--vendor", "Amethyst Contributors",
"--description", "Amethyst CLI — a non-interactive Nostr client.",
"--input", inDir.get().asFile.absolutePath,
"--runtime-image", runtimeDir.get().asFile.absolutePath,
"--main-jar", mainJarName,
"--main-class", mainClassName,
"--dest", outDir.get().asFile.absolutePath,
)
args(extra)
}
}
// .deb for Debian/Ubuntu. Installs under /opt/amy/ with /opt/amy/bin/amy as
// the launcher. We intentionally do NOT request --linux-shortcut (no .desktop
// entry for a CLI).
registerJpackage(
"jpackageDeb",
"deb",
extraArgs = listOf(
"--linux-package-name", "amy",
"--linux-deb-maintainer", "vitor@vitorpamplona.com",
),
)
// .rpm for Fedora/RHEL/openSUSE.
registerJpackage(
"jpackageRpm",
"rpm",
extraArgs = listOf(
"--linux-package-name", "amy",
"--linux-rpm-license-type", "MIT",
),
)
@@ -132,7 +132,7 @@ data class RunState(
/**
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
* `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`.
* `$AMETHYST_CLI_DATA`) — defaults to `./amy`.
*/
class DataDir(
val root: File,
@@ -140,8 +140,9 @@ class DataDir(
val identityFile = File(root, "identity.json")
val relaysFile = File(root, "relays.json")
val stateFile = File(root, "state.json")
val groupsDir = File(root, "groups")
val keyPackageBundleFile = File(root, "keypackages.bundle")
val marmotDir = File(root, "marmot")
val groupsDir = File(marmotDir, "groups")
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
init {
root.mkdirs()
@@ -169,7 +170,7 @@ class DataDir(
companion object {
fun resolve(flag: String?): DataDir {
val envPath = System.getenv("AMETHYST_CLI_DATA")
val path = flag ?: envPath ?: "./amethyst-cli-data"
val path = flag ?: envPath ?: "./amy"
return DataDir(File(path).absoluteFile)
}
}
@@ -131,6 +131,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.dm(dataDir, tail)
}
"profile" -> {
Commands.profile(dataDir, tail)
}
"notes" -> {
Commands.notes(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -197,6 +205,28 @@ private fun printUsage() {
| relay list print configured relays
| relay publish-lists publish kind:10002 + kind:10050
|
|Profile (NIP-01 kind:0):
| profile show [USER] [--timeout SECS] fetch latest kind:0 metadata
| (USER: npub|nprofile|hex|name@domain)
| profile edit [--name NAME] patch kind:0; unset flags keep prior values,
| [--display-name N] blank values delete the field
| [--about TEXT]
| [--picture URL] [--banner URL]
| [--website URL] [--nip05 ID]
| [--lud16 X] [--lud06 X]
| [--pronouns P]
| [--twitter H] [--mastodon H] [--github H]
| [--timeout SECS]
|
|Notes (NIP-10 kind:1):
| notes post TEXT [--relay URL] publish a kind:1 short text note
| (--relay accepts comma-separated extras)
| notes feed [--author USER] fetch kind:1 notes
| [--following] (default: own; --author: one user;
| [--limit N] --following: every contact-list pubkey)
| [--since TS] [--until TS]
| [--timeout SECS]
|
|Direct messages (NIP-17):
| dm send RECIPIENT TEXT send a gift-wrapped DM
| [--allow-fallback] (default: only deliver to recipient's kind:10050)
@@ -80,4 +80,14 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = DmCommands.dispatch(dataDir, tail)
suspend fun profile(
dataDir: DataDir,
tail: Array<String>,
): Int = ProfileCommands.dispatch(dataDir, tail)
suspend fun notes(
dataDir: DataDir,
tail: Array<String>,
): Int = NotesCommands.dispatch(dataDir, tail)
}
@@ -0,0 +1,175 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
/**
* `amy feed [--author USER] [--following] [--limit N] [--since TS] [--until TS]`
*
* Read-side counterpart to `amy post`. Three modes selected by flags:
*
* - default: kind:1 by the current account.
* - `--author USER`: kind:1 by the given user (npub/nprofile/64-hex/NIP-05).
* - `--following`: fetch the current account's NIP-02 contact list and pull
* kind:1 from every author in it.
*
* Events are deduplicated by id, sorted newest-first, and capped at `--limit`
* (default 50). The output is a JSON object with a `notes` array of
* `{id, pubkey, created_at, content, tags}`.
*/
object FeedCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val author = args.flag("author")
val following = args.bool("following")
if (author != null && following) {
return Json.error("bad_args", "feed: pass either --author or --following, not both")
}
val limit = args.intFlag("limit", 50)
if (limit <= 0) return Json.error("bad_args", "feed: --limit must be > 0")
val since = args.flag("since")?.toLongOrNull()
val until = args.flag("until")?.toLongOrNull()
val timeoutSecs = args.longFlag("timeout", 8L)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val (authors, mode) =
when {
following -> resolveFollowing(ctx, timeoutSecs * 1000) to "following"
author != null -> listOf(ctx.requireUserHex(author)) to "author"
else -> listOf(ctx.identity.pubKeyHex) to "self"
}
if (authors.isEmpty()) {
Json.writeLine(
mapOf(
"mode" to mode,
"authors" to emptyList<String>(),
"notes" to emptyList<Any>(),
),
)
return 0
}
val relays = relaysForReadingFeed(ctx, mode)
if (relays.isEmpty()) {
return Json.error("no_relays", "no relays available; run `amy relay add` first")
}
val filter =
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = authors,
since = since,
until = until,
// Ask for some headroom over the requested limit because
// each relay applies the cap independently and we then
// merge + dedup across the union.
limit = (limit * 2).coerceAtMost(500),
)
val filterMap = relays.associateWith { listOf(filter) }
val received = ctx.drain(filterMap, timeoutSecs * 1000)
val notes =
received
.asSequence()
.map { it.second }
.filter { it.kind == TextNoteEvent.KIND }
.filter { it.pubKey in authors.toSet() }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.map { ev ->
mapOf(
"id" to ev.id,
"pubkey" to ev.pubKey,
"created_at" to ev.createdAt,
"content" to ev.content,
"tags" to ev.tags.map { it.toList() },
)
}.toList()
Json.writeLine(
mapOf(
"mode" to mode,
"authors" to authors,
"queried_relays" to relays.map { it.url },
"count" to notes.size,
"notes" to notes,
),
)
return 0
} finally {
ctx.close()
}
}
/**
* Read-side relay strategy: own / following feeds use our outbox set
* (where we expect our follows to also publish, in NIP-65 spirit). For
* an arbitrary `--author` we have no idea where they publish, so we
* widen to the bootstrap union.
*/
private fun relaysForReadingFeed(
ctx: Context,
mode: String,
): Set<NormalizedRelayUrl> =
when (mode) {
"author" -> ctx.bootstrapRelays()
else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
}
private suspend fun resolveFollowing(
ctx: Context,
timeoutMs: Long,
): List<HexKey> {
val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
if (relays.isEmpty()) return emptyList()
val filter =
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(ctx.identity.pubKeyHex),
limit = 1,
)
val filterMap = relays.associateWith { listOf(filter) }
val received = ctx.drain(filterMap, timeoutMs)
val latest =
received
.mapNotNull { (_, ev) -> ev as? ContactListEvent }
.filter { it.pubKey == ctx.identity.pubKeyHex }
.maxByOrNull { it.createdAt }
return latest?.verifiedFollowKeySet()?.toList() ?: emptyList()
}
}
@@ -0,0 +1,44 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
/**
* `amy notes <post|feed>` — NIP-10 kind:1 short text notes. Sits alongside
* `profile` (kind:0) and `dm` (NIP-17) as a top-level verb group so the CLI
* shape mirrors the Amethyst UI: one subcommand group per event family.
*/
object NotesCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "notes <post|feed> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"post" -> PostCommand.run(dataDir, rest)
"feed" -> FeedCommand.run(dataDir, rest)
else -> Json.error("bad_args", "notes ${tail[0]}")
}
}
}
@@ -0,0 +1,85 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
/**
* `amy post <text> [--relay URL …]` — publish a NIP-10 kind:1 short text note
* to the user's outbox relays.
*
* Threading is intentionally out of scope here — `amy post` only handles new
* top-level notes. Replies/quotes need richer event-hint plumbing and will get
* their own verb when needed.
*/
object PostCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "post <text> [--relay URL …]")
val text = rest[0]
if (text.isBlank()) return Json.error("bad_args", "post text must not be blank")
val args = Args(rest.drop(1).toTypedArray())
val extraRelays =
args.flags["relay"]
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val outbox = ctx.outboxRelays()
val extraNormalized =
extraRelays.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}
val targets = (outbox + extraNormalized).toSet()
if (targets.isEmpty()) {
return Json.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
}
val signed = ctx.signer.sign(TextNoteEvent.build(text))
val ack = ctx.publish(signed, targets)
Json.writeLine(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"created_at" to signed.createdAt,
"content" to signed.content,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,233 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* `amy profile <show|edit>` — read and update the current user's
* NIP-01 kind:0 metadata event.
*
* `show` accepts an optional user identifier (npub/nprofile/64-hex/NIP-05); when
* omitted, it reports the current account's profile. `edit` only mutates the
* caller's own profile and patches whichever fields are supplied — unset flags
* leave the existing values untouched, blank values delete the field (mirroring
* [MetadataEvent.updateFromPast]).
*/
object ProfileCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "profile <show|edit> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"show" -> show(dataDir, rest)
"edit" -> edit(dataDir, rest)
else -> Json.error("bad_args", "profile ${tail[0]}")
}
}
private suspend fun show(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutSecs = args.longFlag("timeout", 8L)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val pubKey =
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
?: ctx.identity.pubKeyHex
val isSelf = pubKey == ctx.identity.pubKeyHex
val relays = relaysForReadingProfile(ctx, isSelf)
val event = fetchLatestMetadata(ctx, pubKey, relays, timeoutSecs * 1000)
if (event == null) {
Json.writeLine(
mapOf(
"pubkey" to pubKey,
"found" to false,
"queried_relays" to relays.map { it.url },
),
)
return 0
}
val metadata =
try {
Json.mapper.readTree(event.content)
} catch (_: Exception) {
null
}
Json.writeLine(
mapOf(
"pubkey" to pubKey,
"found" to true,
"event_id" to event.id,
"created_at" to event.createdAt,
"metadata" to (metadata ?: emptyMap<String, Any?>()),
"queried_relays" to relays.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun edit(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val name = args.flag("name")
val displayName = args.flag("display-name")
val about = args.flag("about")
val picture = args.flag("picture")
val banner = args.flag("banner")
val website = args.flag("website")
val nip05 = args.flag("nip05")
val lud16 = args.flag("lud16")
val lud06 = args.flag("lud06")
val pronouns = args.flag("pronouns")
val twitter = args.flag("twitter")
val mastodon = args.flag("mastodon")
val github = args.flag("github")
val timeoutSecs = args.longFlag("timeout", 8L)
val touched =
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github)
.any { it != null }
if (!touched) {
return Json.error(
"bad_args",
"profile edit needs at least one of " +
"--name --display-name --about --picture --banner --website " +
"--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github",
)
}
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val targets = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
val latest =
fetchLatestMetadata(
ctx,
ctx.identity.pubKeyHex,
targets,
timeoutSecs * 1000,
)
val template =
if (latest != null) {
MetadataEvent.updateFromPast(
latest = latest,
name = name,
displayName = displayName,
picture = picture,
banner = banner,
website = website,
about = about,
nip05 = nip05,
lnAddress = lud16,
lnURL = lud06,
pronouns = pronouns,
twitter = twitter,
mastodon = mastodon,
github = github,
)
} else {
MetadataEvent.createNew(
name = name,
displayName = displayName,
picture = picture,
banner = banner,
website = website,
about = about,
nip05 = nip05,
lnAddress = lud16,
lnURL = lud06,
pronouns = pronouns,
twitter = twitter,
mastodon = mastodon,
github = github,
)
}
val signed = ctx.signer.sign(template)
val ack = ctx.publish(signed, targets)
Json.writeLine(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"created_at" to signed.createdAt,
"based_on" to latest?.id,
"metadata" to Json.mapper.readTree(signed.content),
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
/**
* For our own profile, our outbox relays are authoritative; for someone
* else's profile, fall back to the bootstrap union so we still find a
* kind:0 even when our relay set and theirs are disjoint.
*/
private fun relaysForReadingProfile(
ctx: Context,
isSelf: Boolean,
): Set<NormalizedRelayUrl> =
if (isSelf) {
ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
} else {
ctx.bootstrapRelays()
}
private suspend fun fetchLatestMetadata(
ctx: Context,
pubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): MetadataEvent? {
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1)
val filterMap = relays.associateWith { listOf(filter) }
val received = ctx.drain(filterMap, timeoutMs)
return received
.mapNotNull { (_, ev) -> ev as? MetadataEvent }
.filter { it.pubKey == pubKey }
.maxByOrNull { it.createdAt }
}
}
+64 -3
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env bash
# Single source of truth for Amethyst Desktop release asset naming.
# Single source of truth for Amethyst release asset naming.
#
# Two product lines share this contract:
# amethyst-desktop-<version>-<family>-<arch>.<ext> (Compose Desktop app)
# amy-<version>-<family>-<arch>.<ext> (Amy CLI — cli/ module)
#
# Contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# <version> = tag stripped of leading 'v' (e.g. "1.08.0")
# <family> = macos | windows | linux
# <arch> = x64 | arm64
@@ -12,7 +15,8 @@
#
# Usage:
# source scripts/asset-name.sh
# collect_assets <family> <arch> <version> <dest_dir>
# collect_assets <family> <arch> <version> <dest_dir> # desktop
# collect_cli_assets <family> <arch> <version> <dest_dir> # amy CLI
#
# Expected examples:
# amethyst-desktop-1.08.0-macos-x64.dmg
@@ -23,6 +27,11 @@
# amethyst-desktop-1.08.0-linux-x64.rpm
# amethyst-desktop-1.08.0-linux-x64.AppImage
# amethyst-desktop-1.08.0-linux-x64.tar.gz
# amy-1.08.0-macos-arm64.tar.gz
# amy-1.08.0-macos-x64.tar.gz
# amy-1.08.0-linux-x64.tar.gz
# amy-1.08.0-linux-x64.deb
# amy-1.08.0-linux-x64.rpm
set -euo pipefail
@@ -33,6 +42,13 @@ asset_name() {
printf 'amethyst-desktop-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
}
# CLI variant of asset_name — same shape, different product prefix.
# Usage: cli_asset_name <family> <arch> <version> <ext>
cli_asset_name() {
local family="$1" arch="$2" version="$3" ext="$4"
printf 'amy-%s-%s-%s.%s' "$version" "$family" "$arch" "$ext"
}
# Copy + rename build outputs into <dest_dir> using the canonical naming scheme.
# Usage: collect_assets <family> <arch> <version> <dest_dir>
# Expects build outputs under desktopApp/build/... (Compose binaries + custom tasks + portable archives).
@@ -66,3 +82,48 @@ collect_assets() {
done
shopt -u nullglob
}
# Copy + rename amy CLI build outputs into <dest_dir> using the canonical
# amy-<version>-<family>-<arch>.<ext> scheme.
#
# Expected inputs:
# cli/build/amy-image/amy/ flat app-image built by :cli:amyImage
# (bin/amy + lib/*.jar + runtime/)
# cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only)
# cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only)
#
# Usage: collect_cli_assets <family> <arch> <version> <dest_dir>
collect_cli_assets() {
local family="$1" arch="$2" version="$3" dest="$4"
mkdir -p "$dest"
local abs_dest
abs_dest="$(cd "$dest" && pwd)"
shopt -s nullglob
# 1. Tar the flat app-image into amy-<version>-<family>-<arch>.tar.gz.
# This is the portable-across-OS asset — macOS runners produce only
# this one.
local app_image="cli/build/amy-image/amy"
if [ -d "$app_image" ]; then
local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)"
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
echo "Collected: $tarball"
fi
# 2. Linux native installers (.deb, .rpm). jpackage writes them directly
# to cli/build/jpackage/ with its own naming, so we re-copy under the
# canonical scheme.
local src base ext dst
for src in \
cli/build/jpackage/*.deb \
cli/build/jpackage/*.rpm \
; do
[ -f "$src" ] || continue
base="$(basename "$src")"
ext="${base##*.}"
dst="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" "$ext")"
cp "$src" "$dst"
echo "Collected: $dst"
done
shopt -u nullglob
}