mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts: # cli/tests/.gitignore
This commit is contained in:
@@ -160,6 +160,17 @@ Summarize the survey in your plan: for each component, note whether it's
|
||||
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
|
||||
(platform-specific only), or a duplicate of an existing pattern to avoid.
|
||||
|
||||
**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.**
|
||||
One-shot and high-level relay operations (fetch a set, fetch one, page past the
|
||||
relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are
|
||||
`INostrClient` **extension functions** in
|
||||
`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe
|
||||
helpers). Because they're extensions, they don't surface under "usages of
|
||||
`NostrClient`" or in completion — grep that package (or read its `README.md`, which
|
||||
catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`,
|
||||
`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`,
|
||||
etc. instead of re-implementing them.
|
||||
|
||||
**Share vs keep platform-native:**
|
||||
|
||||
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
|
||||
|
||||
@@ -123,6 +123,12 @@ Each subscription tracks "End of Stored Events" per relay. The eose manager in `
|
||||
|
||||
## Related
|
||||
|
||||
- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go
|
||||
through `Subscribable` — use the `INostrClient` extension functions in
|
||||
`quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`,
|
||||
`fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`,
|
||||
…). They're extensions, so they don't show up under "usages of `NostrClient`" — see
|
||||
that package's `README.md` for the catalog before writing a raw subscribe/collect loop.
|
||||
- `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for.
|
||||
- `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers).
|
||||
- `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions).
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
name: Bump Homebrew Formula (amy CLI)
|
||||
|
||||
# Sibling of bump-homebrew.yml, but for a DIFFERENT Homebrew artifact:
|
||||
# - bump-homebrew.yml -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
|
||||
# - this workflow -> Formula `amy` (the headless CLI jar bundle)
|
||||
#
|
||||
# What it does today: after a stable release, download the published
|
||||
# `amy-<version>-jvm.tar.gz` bundle, compute its sha256, and open a PR that
|
||||
# syncs `cli/packaging/homebrew/amy.rb`'s url + sha256 to that release. That is
|
||||
# exactly the manual step the formula header calls out ("replace the version in
|
||||
# the url and the sha256 with the values for the actual published release
|
||||
# asset"), so keeping the in-repo reference formula accurate makes the eventual
|
||||
# homebrew-core submission a copy-paste.
|
||||
#
|
||||
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
|
||||
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
|
||||
# and `amy` has never been submitted there — that first submission is a manual,
|
||||
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
|
||||
# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the
|
||||
# "TODO(bootstrap)" note at the bottom of this file.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to sync (for manual recovery)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
# The "Report failure" step opens a [release-ops] issue via
|
||||
# github.rest.issues.create, which needs issues:write.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# Serialize per tag; do not cancel in-progress runs.
|
||||
group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-formula:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Re-assert stable release
|
||||
uses: ./.github/actions/assert-stable-release
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
|
||||
is_draft: ${{ github.event.release.draft || 'false' }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
VER="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download jvm bundle and compute sha256
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
VER="${{ steps.ver.outputs.ver }}"
|
||||
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
|
||||
echo "Fetching $URL"
|
||||
# The `released` event can fire a hair before every matrix leg finishes
|
||||
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
|
||||
ok=0
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi
|
||||
wait=$(( 2 ** i ))
|
||||
echo "attempt $i failed; retrying in ${wait}s"
|
||||
sleep "$wait"
|
||||
done
|
||||
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
|
||||
test -s amy-jvm.tar.gz
|
||||
SHA=$(shasum -a 256 amy-jvm.tar.gz | awk '{print $1}')
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "amy-${VER}-jvm.tar.gz -> $SHA"
|
||||
|
||||
- name: Update reference formula
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FORMULA=cli/packaging/homebrew/amy.rb
|
||||
URL="${{ steps.asset.outputs.url }}"
|
||||
SHA="${{ steps.asset.outputs.sha256 }}"
|
||||
# Rewrite the two indented lines in the formula block. Anchoring on the
|
||||
# 2-space indent avoids touching the header comment's example curl url.
|
||||
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
|
||||
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
|
||||
echo "----- $FORMULA -----"
|
||||
grep -E "^ (url|sha256) " "$FORMULA"
|
||||
|
||||
- name: Open or update the formula-sync PR
|
||||
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
|
||||
# linked into any shipped artifact). It no-ops when there is no diff.
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }}
|
||||
add-paths: cli/packaging/homebrew/amy.rb
|
||||
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
body: |
|
||||
Auto-synced `cli/packaging/homebrew/amy.rb` to the
|
||||
`${{ steps.ver.outputs.tag }}` release:
|
||||
|
||||
- `url` -> `${{ steps.asset.outputs.url }}`
|
||||
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
|
||||
|
||||
Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep
|
||||
the reference formula ready for the homebrew-core submission/bump.
|
||||
|
||||
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a
|
||||
# step here that opens the homebrew-core bump PR automatically — symmetric to
|
||||
# the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump-
|
||||
# formula, or `brew bump-formula-pr amy --url=<url> --sha256=<sha>` with a
|
||||
# HOMEBREW_TOKEN). It is intentionally omitted until then because
|
||||
# bump-formula-pr errors on a formula that is not yet in the tap.
|
||||
|
||||
- name: Report failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `[release-ops] bump-homebrew-formula failed for ${tag}`,
|
||||
body: [
|
||||
`amy Homebrew formula sync failed for release \`${tag}\`.`,
|
||||
``,
|
||||
`- Run: ${runUrl}`,
|
||||
`- Channel: Homebrew Formula (\`amy\` CLI)`,
|
||||
``,
|
||||
`Recovery options:`,
|
||||
`1. Re-run the workflow once the underlying issue is fixed`,
|
||||
`2. Manually update \`cli/packaging/homebrew/amy.rb\` (url + sha256) from the release asset`,
|
||||
`3. Check the release actually published \`amy-${tag.replace(/^v/, '')}-jvm.tar.gz\``
|
||||
].join('\n'),
|
||||
labels: ['release-ops', 'bug']
|
||||
});
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The "Report failure" step below opens a [release-ops] issue via
|
||||
# github.rest.issues.create; that needs issues:write. Without it the failure
|
||||
# reporter itself 403s and no alert is ever filed.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# Serialize bumps per tag; do not cancel in-progress bumps.
|
||||
|
||||
@@ -12,6 +12,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The "Report failure" step below opens a [release-ops] issue via
|
||||
# github.rest.issues.create; that needs issues:write. Without it the failure
|
||||
# reporter itself 403s and no alert is ever filed.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
|
||||
|
||||
+79
@@ -213,6 +213,85 @@ toolchain drifted — file it before publishing.
|
||||
|
||||
---
|
||||
|
||||
## Local SonarQube analysis (opt-in)
|
||||
|
||||
The build supports running a [SonarQube](https://www.sonarsource.com/products/sonarqube/)
|
||||
analysis against a locally hosted server. It is **off by default**: unless you
|
||||
opt in, the scanner plugin is neither downloaded nor applied and the build is
|
||||
unaffected.
|
||||
|
||||
### 1. Install and start a local SonarQube server
|
||||
|
||||
Either run the official Docker image:
|
||||
|
||||
```bash
|
||||
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
|
||||
```
|
||||
|
||||
or download the [Community Build zip](https://www.sonarsource.com/products/sonarqube/downloads/),
|
||||
unzip it, and start it (requires a JDK 17+ on `PATH`):
|
||||
|
||||
```bash
|
||||
cd sonarqube-<version>
|
||||
bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS
|
||||
```
|
||||
|
||||
Once it reports up, open <http://localhost:9000> (first login `admin`/`admin`,
|
||||
you'll be asked to change it), create a **local project** named `Amethyst` with
|
||||
project key `Amethyst`, and generate a **project analysis token** for it
|
||||
(*Project Settings → Analysis Method → With Gradle*, or
|
||||
*My Account → Security → Generate token*). The token looks like `sqp_…`.
|
||||
|
||||
### 2. Point the build at your server
|
||||
|
||||
Add the server and token to `local.properties` (gitignored — the token never
|
||||
lands in the repo):
|
||||
|
||||
```properties
|
||||
sonar.host.url=http://localhost:9000
|
||||
sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### 3. Run the analysis
|
||||
|
||||
```bash
|
||||
./gradlew sonar
|
||||
```
|
||||
|
||||
When it finishes, browse the results at
|
||||
<http://localhost:9000/dashboard?id=Amethyst>.
|
||||
|
||||
### 4. Optional: include Android Lint results
|
||||
|
||||
The scanner auto-imports each Android module's lint report and shows the
|
||||
findings as external issues alongside Sonar's own. It only *imports* — it never
|
||||
runs lint itself — so without the reports on disk the analysis warns
|
||||
`Unable to import Android Lint report file(s)`. Generate them first, then run
|
||||
the scan as a **separate** invocation (chaining lint and `sonar` in one Gradle
|
||||
call does not guarantee lint finishes first):
|
||||
|
||||
```bash
|
||||
./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug
|
||||
./gradlew sonar
|
||||
```
|
||||
|
||||
The reports persist under each module's `build/reports/`, so re-run lint only
|
||||
when you want fresh lint data in the next scan.
|
||||
|
||||
Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any
|
||||
[analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/)
|
||||
can be set there. `sonar.projectKey` / `sonar.projectName` default to the root
|
||||
project name (`Amethyst`).
|
||||
|
||||
Even when opted in, the scanner plugin only loads on invocations that actually
|
||||
request the `sonar` task — ordinary builds and IDE syncs are unaffected (which
|
||||
is also why `./gradlew tasks` doesn't list it).
|
||||
|
||||
Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only
|
||||
tool fetched after explicit opt-in; it is never linked into shipped artifacts.
|
||||
|
||||
---
|
||||
|
||||
## Release runbook
|
||||
|
||||
The release flow is driven by a tag push. Every cut ships Android + Desktop +
|
||||
|
||||
@@ -87,8 +87,10 @@ android {
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
@Suppress("UnstableApiUsage")
|
||||
resourceConfigurations +=
|
||||
}
|
||||
|
||||
androidResources {
|
||||
localeFilters +=
|
||||
listOf(
|
||||
"ar",
|
||||
"ar-rSA",
|
||||
|
||||
@@ -155,13 +155,11 @@ class Amethyst : Application() {
|
||||
if (isNappletSandbox) return
|
||||
instance.trim(level)
|
||||
// Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the
|
||||
// user or Android reclaims them). Deliberately NOT on UI_HIDDEN/BACKGROUND — those fire on every
|
||||
// backgrounding, and a pinned tab should survive that. Only on real pressure levels, R+ only.
|
||||
val pressure =
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_MODERATE ||
|
||||
level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE
|
||||
// user or Android reclaims them). Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND:
|
||||
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
|
||||
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
|
||||
// a plain backgrounding. R+ only.
|
||||
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
|
||||
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
EmbeddedTabHost.evictAll()
|
||||
}
|
||||
|
||||
@@ -912,61 +912,36 @@ class AppModules(
|
||||
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
|
||||
// Trim in-process caches proportional to OS memory pressure.
|
||||
//
|
||||
// Background levels (app not visible, ordered highest-first so the when
|
||||
// chain short-circuits at the right tier):
|
||||
// COMPLETE (80) — at the bottom of the LRU list, kill imminent
|
||||
// MODERATE (60) — system is hurting, neighbouring apps being killed
|
||||
// BACKGROUND(40) — backgrounded, mild system pressure
|
||||
// UI_HIDDEN (20) — just backgrounded, no pressure yet
|
||||
// Since API 34 the OS only ever delivers two trim levels (the foreground
|
||||
// RUNNING_* levels and the deeper MODERATE/COMPLETE background tiers were
|
||||
// deprecated because apps are no longer notified of them):
|
||||
// BACKGROUND(40) — process is on the system LRU list: real reclaim
|
||||
// pressure, and the strongest signal we still get.
|
||||
// UI_HIDDEN (20) — just backgrounded, no pressure yet. Fires on EVERY
|
||||
// app switch.
|
||||
//
|
||||
// Foreground levels (app is active but system is low):
|
||||
// RUNNING_CRITICAL (15), RUNNING_LOW (10)
|
||||
//
|
||||
// UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches
|
||||
// (Robohash SVG assembly, rich-text parsing) there — clearing them
|
||||
// forces a full rebuild on every resume and causes visible jank.
|
||||
// So we key off exactly those two. UI_HIDDEN is frequent, so it only trims
|
||||
// images (bitmaps are the largest allocations) and keeps the CPU-heavy
|
||||
// caches (Robohash SVG assembly, rich-text parsing) warm — clearing them
|
||||
// would force a full rebuild on every resume and cause visible jank.
|
||||
// BACKGROUND trims hard but keeps a small working set: it means "on the LRU
|
||||
// list" (real reclaim pressure), not the imminent kill that COMPLETE used to
|
||||
// signal — so leave just enough warm to redraw the screen the user left on.
|
||||
when {
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
|
||||
// Kill imminent: free everything.
|
||||
memoryCache.trimToSize(0)
|
||||
CachedRichTextParser.trimToSize(0)
|
||||
CachedRobohash.trimToSize(0)
|
||||
nip11Cache.trimToSize(0)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> {
|
||||
// System under real pressure: clear images and most parsed state.
|
||||
memoryCache.trimToSize(0)
|
||||
CachedRichTextParser.trimToSize(50)
|
||||
CachedRobohash.trimToSize(10)
|
||||
nip11Cache.trimToSize(100)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
|
||||
// Backgrounded with mild pressure: trim significantly.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 4)
|
||||
CachedRichTextParser.trimToSize(100)
|
||||
// On the LRU list under real pressure: trim hard, but keep a small
|
||||
// working set so a returning user doesn't rebuild the visible screen
|
||||
// from scratch. memoryCache is byte-sized (Coil), the rest are entry counts.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 10)
|
||||
CachedRichTextParser.trimToSize(10)
|
||||
CachedRobohash.trimToSize(20)
|
||||
nip11Cache.trimToSize(200)
|
||||
nip11Cache.trimToSize(10)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
|
||||
// Just backgrounded, no pressure yet: trim images (bitmaps are the
|
||||
// largest allocations) but keep parsed-text and avatar caches warm
|
||||
// so resuming is instant.
|
||||
// Just backgrounded, no pressure yet: trim images but keep the
|
||||
// parsed-text and avatar caches warm so resuming is instant.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 2)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
|
||||
// Foreground, critically low memory.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 4)
|
||||
CachedRichTextParser.trimToSize(100)
|
||||
CachedRobohash.trimToSize(20)
|
||||
nip11Cache.trimToSize(200)
|
||||
}
|
||||
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
|
||||
// Foreground, low memory.
|
||||
memoryCache.trimToSize(memoryCache.maxSize / 2)
|
||||
CachedRichTextParser.trimToSize(250)
|
||||
CachedRobohash.trimToSize(50)
|
||||
nip11Cache.trimToSize(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2664,7 +2664,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
return liveChatChannels.filter { _, channel ->
|
||||
|
||||
+12
-16
@@ -34,19 +34,18 @@ class MemoryTrimmingService(
|
||||
var isTrimmingMemoryMutex = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Tiered pruning scaled to the OS memory-pressure level.
|
||||
* Two-tier pruning keyed to the OS trim levels still delivered since API 34
|
||||
* (the foreground RUNNING_* and deeper MODERATE/COMPLETE levels were deprecated
|
||||
* because apps are no longer notified of them).
|
||||
*
|
||||
* Tier 1 — mild pressure (UI hidden, running-moderate):
|
||||
* Tier 1 — UI hidden (fires on every app switch):
|
||||
* Sweep stale WeakRefs, drop expired and superseded-replaceable events.
|
||||
* Safe to run frequently; no UI-visible side effects.
|
||||
*
|
||||
* Tier 2 — low memory (running-low, background):
|
||||
* Tier 1 + old chat messages + unobserved thread replies / reactions.
|
||||
* May cause feeds to re-fetch content that was scrolled past.
|
||||
*
|
||||
* Tier 3 — critical / imminent kill (running-critical, moderate, complete):
|
||||
* Tier 2 + sever all observer links + drop every event from muted/blocked users.
|
||||
* Aggressive; triggers recomposition wherever StateFlows were cleared.
|
||||
* Tier 2 — background / real reclaim pressure (process on the LRU list):
|
||||
* Tier 1 + drop events from muted/blocked users + old chat messages +
|
||||
* unobserved thread replies / reactions. May cause feeds to re-fetch content
|
||||
* that was scrolled past; triggers recomposition wherever StateFlows cleared.
|
||||
*/
|
||||
private fun doTrim(
|
||||
account: Collection<Account>,
|
||||
@@ -60,16 +59,13 @@ class MemoryTrimmingService(
|
||||
cache.pruneExpiredEvents()
|
||||
cache.prunePastVersionsOfReplaceables()
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
|
||||
// Tier 2: medium pressure — drop events from muted/blocked users
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
|
||||
// messages, and unobserved reactions.
|
||||
account.forEach {
|
||||
cache.pruneHiddenEvents(it)
|
||||
cache.pruneHiddenMessages(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
|
||||
// Tier 3: critical pressure — drop old messages and unobserved reactions
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
@@ -79,7 +75,7 @@ class MemoryTrimmingService(
|
||||
suspend fun run(
|
||||
account: Collection<Account>,
|
||||
otherAccounts: List<AccountInfo>,
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
|
||||
) {
|
||||
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
|
||||
Log.d("ServiceManager", "Trimming Memory (level=$level)")
|
||||
|
||||
+1
-3
@@ -108,9 +108,7 @@ fun ControlWhenPlayerIsActive(
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SpectrumAudioBufferSink(
|
||||
private var channels = 1
|
||||
private var encoding = C.ENCODING_PCM_16BIT
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@kotlin.OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun flush(
|
||||
sampleRateHz: Int,
|
||||
channelCount: Int,
|
||||
|
||||
+3
-1
@@ -166,7 +166,9 @@ class PlaybackService : MediaSessionService() {
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
|
||||
// Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND; BACKGROUND (process on
|
||||
// the system LRU list) is the real reclaim-pressure signal, so release the warm pool then.
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
poolNoProxy?.exoPlayerPool?.releaseWarmPool()
|
||||
poolWithProxy?.exoPlayerPool?.releaseWarmPool()
|
||||
}
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ object PodcastRemoteContent {
|
||||
.build()
|
||||
okHttpClient.newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
val body = response.body ?: return@use null
|
||||
val body = response.body
|
||||
// Reject an oversized declared length outright; cap the read for chunked bodies.
|
||||
if (body.contentLength() > MAX_BYTES) return@use null
|
||||
body.string().take(MAX_BYTES.toInt())
|
||||
|
||||
-2
@@ -285,8 +285,6 @@ fun ShareNoteAsImageScreen(
|
||||
*finalState.params,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -145,11 +145,12 @@ class AccountFeedContentStates(
|
||||
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
|
||||
|
||||
init {
|
||||
// Under critical memory pressure, trim every feed down to 50 items to release
|
||||
// the strong Note references that would otherwise keep pruned cache objects alive.
|
||||
// Under real memory pressure (process on the system LRU list — the strongest trim
|
||||
// level the OS still delivers since API 34), trim every feed down to release the
|
||||
// strong Note references that would otherwise keep pruned cache objects alive.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Amethyst.instance.trimLevelEvents.collect { level ->
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
trimFeedsToSize(200)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ fun CalendarEventDetailScreen(
|
||||
}
|
||||
// The Edit affordance is only meaningful when the current account is the
|
||||
// author — relays will reject a signed-by-stranger replacement.
|
||||
if (isOwnEvent && event != null) {
|
||||
if (isOwnEvent) {
|
||||
IconButton(onClick = {
|
||||
nav.nav(
|
||||
Route.EditCalendarEvent(
|
||||
|
||||
-1
@@ -559,7 +559,6 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
"Copy" to {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("selection", pageSel.text))
|
||||
Unit
|
||||
},
|
||||
),
|
||||
onMagnify = onMagnify,
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ fun GitRepositoryPullsScreen(
|
||||
internal class GitRepositoryBrowserViewModelFactory(
|
||||
private val okHttpClient: (String) -> OkHttpClient,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = GitRepositoryBrowserViewModel(okHttpClient) as T
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -460,10 +460,10 @@ fun DisplayLiveBubbles(
|
||||
val feedState by liveSection.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = feedState) {
|
||||
is ChannelFeedState.Empty -> null
|
||||
is ChannelFeedState.FeedError -> null
|
||||
is ChannelFeedState.Empty -> {}
|
||||
is ChannelFeedState.FeedError -> {}
|
||||
is ChannelFeedState.Loaded -> DisplayLiveBubbles(state, accountViewModel, nav)
|
||||
is ChannelFeedState.Loading -> null
|
||||
is ChannelFeedState.Loading -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -199,9 +199,7 @@ internal fun ParticipantHostActionsSheet(
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -255,9 +255,7 @@ private fun StartCluster(
|
||||
|
||||
// On-stage controls live in [StageControlsBar]; audience
|
||||
// has nothing to do here (system volume keys are enough).
|
||||
is ConnectionUiState.Connected -> {
|
||||
Unit
|
||||
}
|
||||
is ConnectionUiState.Connected -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -75,6 +75,9 @@ fun PictureCardCompose(
|
||||
// Image content
|
||||
PictureCardImage(baseNote, event, backgroundColor, accountViewModel)
|
||||
|
||||
// Title and content
|
||||
PictureCardCaption(event)
|
||||
|
||||
// Reactions row
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
@@ -84,9 +87,6 @@ fun PictureCardCompose(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
// Title and content
|
||||
PictureCardCaption(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ fun rememberProfileClinkOffer(
|
||||
// Fall back to the NIP-05 .well-known clink_offer (cached per address).
|
||||
val id = nip05?.let { Nip05Id.parse(it) }
|
||||
offer =
|
||||
if (id != null && nip05 != null) {
|
||||
if (nip05 != null && id != null) {
|
||||
// Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch.
|
||||
val cacheKey = nip05.lowercase()
|
||||
val cached = clinkOfferNip05Cache.get(cacheKey)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<item quantity="other">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d asset egybecsomagolva</item>
|
||||
<item quantity="one">%1$d összetevő egybecsomagolva</item>
|
||||
<item quantity="other">%1$d asset egybecsomagolva</item>
|
||||
</plurals>
|
||||
<plurals name="thread_collapsed_reply_count">
|
||||
@@ -56,12 +56,12 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy válaszolni tudjon</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Nincs beállítva zapösszeg. Koppintson hosszan a beállításhoz</string>
|
||||
<string name="chat_zap_amount_suffix">%1$s satot küldött</string>
|
||||
<string name="chat_zap_anonymous">Névtelen</string>
|
||||
<string name="chat_raid_is_raiding">raidet indít</string>
|
||||
<string name="chat_clip_created_a_clip">létrehozott egy klippet</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni</string>
|
||||
<string name="chat_clip_created_a_clip">létrehozott egy klipet</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy zapet tudjon küldeni</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_hide_word">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni</string>
|
||||
@@ -104,7 +104,7 @@
|
||||
<string name="clink_debit_no_response">A terhelési szolgáltató nem teljesítette a fizetést.</string>
|
||||
<string name="clink_confirm_payment_title">Kifizetés megerősítése</string>
|
||||
<string name="clink_confirm_pay_amount_via_source">%1$s kifizetése ezzel: %2$s?</string>
|
||||
<string name="clink_confirm_pay_via_source">Kifizeti ezt számlát a(z) %1$s használatával?</string>
|
||||
<string name="clink_confirm_pay_via_source">Kifizeti ezt a számlát a(z) %1$s használatával?</string>
|
||||
<string name="clink_offer_amount_sats">Összeg (satoshiban)</string>
|
||||
<string name="clink_offer_invalid_amount">Adjon meg egy érvényes összeget ehhez az ajánlathoz.</string>
|
||||
<string name="clink_offer_amount_range">Engedélyezett tartomány: %1$s-%2$s satoshi</string>
|
||||
@@ -120,7 +120,7 @@
|
||||
<string name="clink_budget_monthly">Havonta</string>
|
||||
<string name="clink_debit_pay_only">Csak fizetés</string>
|
||||
<string name="wallet_add_clink_title">CLINK terhelés</string>
|
||||
<string name="wallet_add_clink_description">Fizetés és zappelés olyan tárcából, amely előzetesen engedélyezte az Ön fiókját. Csak költés — nincs egyenleg vagy előzmény.</string>
|
||||
<string name="wallet_add_clink_description">Fizetés és zapelés olyan tárcából, amely előzetesen engedélyezte az Ön fiókját. Csak költés — nincs egyenleg vagy előzmény.</string>
|
||||
<string name="wallet_add_clink_invalid">Érvénytelen CLINK terhelési mutató. Várt mutató: egy ndebit1… karakterlánc.</string>
|
||||
<string name="wallet_paste_ndebit">Ndebit-mutató beillesztése</string>
|
||||
<string name="pay">Fizetés</string>
|
||||
@@ -137,10 +137,10 @@
|
||||
<string name="send_payment_amount">Összeg</string>
|
||||
<string name="send_payment_fixed_price">Ajánlat által meghatározott ár</string>
|
||||
<string name="send_payment_receipt_section">Zap-igazolás</string>
|
||||
<string name="send_payment_receipt_clink">Közvetlen Lightning fizetés - nem lesz Zap-igazolás közzétéve a Nostr-on.</string>
|
||||
<string name="send_payment_receipt_onchain">Egy láncon bebelüli Zap-igazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést.</string>
|
||||
<string name="send_payment_receipt_clink">Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on.</string>
|
||||
<string name="send_payment_receipt_onchain">Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést.</string>
|
||||
<string name="send_payment_receipt_onchain_address">Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve.</string>
|
||||
<string name="send_payment_receipt_cashu">A nutzap-esemény magát az ecash-t kézbesíti, így az mindig közzé lesz téve.</string>
|
||||
<string name="send_payment_receipt_cashu">A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve.</string>
|
||||
<string name="send_payment_requesting_invoice">Számla kérése…</string>
|
||||
<string name="send_payment_requesting_invoice_nostr">Számla kérése a Nostr hálózaton keresztül…</string>
|
||||
<string name="send_payment_paying_via">Kifizetés ezzel: %1$s…</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="send_payment_pay_button">%1$s satoshi kifizetése</string>
|
||||
<string name="send_payment_pay_button_empty">Fizetés</string>
|
||||
<string name="send_payment_no_methods">Ennek a profilnak nincsenek olyan fizetési módjai, amelyeket az Amethyst közvetlenül ki tudna fizetni.</string>
|
||||
<string name="send_payment_min_onchain">A láncon belüli zap-ekhez legalább %1$s satoshi szükséges</string>
|
||||
<string name="send_payment_min_onchain">A láncon belüli zapekhez legalább %1$s satoshi szükséges</string>
|
||||
<string name="send_payment_cashu_insufficient">Nincs elegendő egyenleg egy olyan pénzverdében, amit ez a címzett elfogad. Előbb fel kell tölteni a cashu-pénztárcát.</string>
|
||||
<string name="send_payment_cashu_balance">A címzett számára elküldhető összeg: %1$s satoshi</string>
|
||||
<string name="send_payment_onchain_fee">Hálózati díj</string>
|
||||
@@ -211,7 +211,7 @@
|
||||
<string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string>
|
||||
<string name="media_download_has_started_toast">A média letöltése megkezdődött…</string>
|
||||
<string name="pin_conversation">Kitűzés felülre</string>
|
||||
<string name="unpin_conversation">Kitűzés megszűntetése</string>
|
||||
<string name="unpin_conversation">Kitűzés megszüntetése</string>
|
||||
<string name="pinned_to_top">Kitűzve felülre</string>
|
||||
<string name="failed_to_save_the_image">Nem sikerült menteni a képet</string>
|
||||
<string name="video_saved_to_the_gallery">Videó mentve a videógalériába</string>
|
||||
@@ -300,7 +300,7 @@
|
||||
<!-- A reply whose target was searched for across all reachable history and never found. -->
|
||||
<string name="chats_reply_not_found">Nem sikerült megtalálni ezt az üzenetet</string>
|
||||
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
|
||||
<string name="chats_reply_searched">Minden átjátszó le lett kérve · érintse meg a megtekintéshez</string>
|
||||
<string name="chats_reply_searched">Minden átjátszó le lett kérve · koppintson ide a megtekintéshez</string>
|
||||
<string name="error_loading_replies">"Hiba a válaszok betöltésekor: "</string>
|
||||
<string name="try_again">Próbálja újra</string>
|
||||
<string name="notification_feed_is_empty">Még nincsenek értesítések.</string>
|
||||
@@ -430,7 +430,7 @@
|
||||
<string name="report_dialog_post_report_btn">Bejelentés közzététele</string>
|
||||
<string name="report_dialog_title">Letiltás és bejelentés</string>
|
||||
<string name="block_only">Letiltás</string>
|
||||
<string name="manual_zaps">Kézi Zap-megosztás</string>
|
||||
<string name="manual_zaps">Kézi zapmegosztás</string>
|
||||
<string name="bookmarks">Könyvjelző</string>
|
||||
<string name="bookmarks_title">Könyvjelzők</string>
|
||||
<string name="bookmarks_explainer">Saját könyvjelzők</string>
|
||||
@@ -457,7 +457,7 @@
|
||||
<string name="schedule_post_preset_next_monday_morning">Következő hétfő délelőtt 9 órakor</string>
|
||||
<string name="schedule_post_always_on_prompt_title">Bekapcsolja a folyamatos értesítési szolgáltatást?</string>
|
||||
<string name="schedule_post_always_on_prompt_message">Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg.</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Beállítások menyitása</string>
|
||||
<string name="schedule_post_always_on_prompt_open_settings">Beállítások megnyitása</string>
|
||||
<string name="schedule_post_always_on_prompt_continue">Folytatás mindenképpen</string>
|
||||
<string name="scheduled_posts_at_time">%1$s → %2$s</string>
|
||||
<string name="scheduled_posts_at_time_past">%1$s · %2$s ezelőtt</string>
|
||||
@@ -479,7 +479,7 @@
|
||||
<string name="scheduled_posts_status_publishing">Küldés…</string>
|
||||
<string name="scheduled_posts_status_failed">Sikertelen</string>
|
||||
<string name="scheduled_posts_status_sent">Elküldve</string>
|
||||
<string name="scheduled_posts_status_cancelled">Megszakitva</string>
|
||||
<string name="scheduled_posts_status_cancelled">Megszakítva</string>
|
||||
<plurals name="scheduled_posts_logout_warning">
|
||||
<item quantity="one">Önnek %d ütemezett bejegyzése van, amely még nem lett közzétéve. A kijelentkezéssel véglegesen törli azt.</item>
|
||||
<item quantity="other">Önnek %d ütemezett bejegyzése van, amelyek még nem lettek közzétéve. A kijelentkezéssel véglegesen törli azokat.</item>
|
||||
@@ -515,7 +515,7 @@
|
||||
<string name="new_community_add_moderator_placeholder">Név, npub vagy NIP-05</string>
|
||||
<string name="new_community_owner">Tulajdonos</string>
|
||||
<string name="new_community_relays_section">Átjátszók</string>
|
||||
<string name="new_community_relays_hint">Válasszon átjátzsókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni.</string>
|
||||
<string name="new_community_relays_hint">Válasszon átjátszókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni.</string>
|
||||
<string name="new_community_relay_marker_none">Bármelyik</string>
|
||||
<string name="new_community_relay_marker_author">Szerző</string>
|
||||
<string name="new_community_relay_marker_requests">Kérések</string>
|
||||
@@ -698,7 +698,7 @@
|
||||
<string name="napplet_cap_keys_desc">Billentyűkötések hozzárendelése</string>
|
||||
<string name="napplet_cap_relay_desc">Események olvasása, aláírása és közzététele</string>
|
||||
<string name="napplet_cap_storage_desc">Saját privát tárhely</string>
|
||||
<string name="napplet_cap_value_desc">Lightning számlák kifizetése</string>
|
||||
<string name="napplet_cap_value_desc">Lightning-számlák kifizetése</string>
|
||||
<string name="napplet_cap_resource_desc">Webes és Blossom erőforrások lekérése</string>
|
||||
<string name="napplet_cap_upload_desc">Fájlok feltöltése a saját médiakiszolgálóra</string>
|
||||
<string name="napplet_cap_theme">Téma</string>
|
||||
@@ -892,7 +892,7 @@
|
||||
<string name="nest_hush_local_restore">Ezen előadó visszaállítása</string>
|
||||
<string name="nest_participant_view_profile">Profil megtekintése</string>
|
||||
<string name="nest_participant_zap">Zap küldése</string>
|
||||
<string name="nest_participant_zap_split_unsupported">A Zap-megosztás nem támogatott hangszobán belül. Nyissa meg a profilképernyőt a küldéshez.</string>
|
||||
<string name="nest_participant_zap_split_unsupported">A zapmegosztás nem támogatott hangszobán belül. Nyissa meg a profilképernyőt a küldéshez.</string>
|
||||
<string name="nest_participant_follow">Követés</string>
|
||||
<string name="nest_participant_unfollow">Követés megszüntetése</string>
|
||||
<string name="nest_participant_mute">Némítás</string>
|
||||
@@ -941,7 +941,7 @@
|
||||
<string name="nests_servers_my_section">Saját kiszolgálók</string>
|
||||
<string name="nests_servers_my_explainer">Kind-10112 típusú cserélhető eseményként mentve, így más kliensek is olvashatják az Ön beállítását.</string>
|
||||
<string name="nests_servers_add_relay_field">Átjátszó (WebTransport) webcíme</string>
|
||||
<string name="nests_servers_add_auth_field">Hitelesítési (JWT mint) webcím</string>
|
||||
<string name="nests_servers_add_auth_field">Hitelesítési (JWT-pénzverde) webcím</string>
|
||||
<string name="nests_servers_add_pair_button">Hozzáadás</string>
|
||||
<string name="nests_servers_relay_label">Átjátszó</string>
|
||||
<string name="nests_servers_auth_label">Hitelesítés</string>
|
||||
@@ -1172,13 +1172,13 @@
|
||||
<string name="bookmark_list_explainer">A könyvjelzőlisták metaadatai a Nostr-on bárki számára láthatók. Csak a privát tagok vannak titkosítva.</string>
|
||||
<string name="move_bookmark_to_public_label">Áthelyezés a nyilvános könyvjelzőkbe</string>
|
||||
<string name="move_bookmark_to_private_label">Áthelyezés a privát könyvjelzőkbe</string>
|
||||
<string name="quick_zap_amounts">Gyors Zap-összegek</string>
|
||||
<string name="quick_zap_amounts_explainer">A „Zap” gomb megnyomásakor jelenik meg. Az egyes összegeket a címzett által támogatott bármely fizetési csatornán keresztül ki lehet fizetni - Lightning, Cashu vagy láncon belül (láncon belül csak nagyobb összegek esetén). Érintse meg az összeget annak eltávolításához. Ha üresen hagyja, a rendszer minden alkalommal megnyitja az összeg megadására szolgáló párbeszédpanelt.</string>
|
||||
<string name="quick_zap_amounts">Gyors zapösszegek</string>
|
||||
<string name="quick_zap_amounts_explainer">A „Zap” gomb megnyomásakor jelenik meg. Az egyes összegeket a címzett által támogatott bármely fizetési csatornán keresztül ki lehet fizetni - Lightning, Cashu vagy láncon belül (láncon belül csak nagyobb összegek esetén). Koppintson az összegre annak eltávolításához. Ha üresen hagyja, a rendszer minden alkalommal megnyitja az összeg megadására szolgáló párbeszédpanelt.</string>
|
||||
<string name="send_onchain_instead">Küldés inkább láncon belül</string>
|
||||
<string name="reload_mint_title">Mint feltöltése</string>
|
||||
<string name="reload_mint_title">Pénzverde feltöltése</string>
|
||||
<string name="reload_mint_sats_amount">%1$s satoshi</string>
|
||||
<string name="reload_mint_topup_label">Feltöltés összege</string>
|
||||
<string name="reload_mint_section_to">Feltöltendő mint</string>
|
||||
<string name="reload_mint_section_to">Feltöltendő pénzverde</string>
|
||||
<string name="reload_mint_section_from">Fedezet innen</string>
|
||||
<string name="reload_mint_pay_lightning">Kifizetés Lightninggal</string>
|
||||
<string name="reload_mint_lightning_desc">Új ecash verése a saját Lightning tárcából</string>
|
||||
@@ -1194,8 +1194,8 @@
|
||||
<string name="reload_mint_needs_more">további %1$s satoshi szükséges</string>
|
||||
<string name="reload_mint_funded">feltöltve</string>
|
||||
<string name="reload_mint_copy_invoice">Számla másolása</string>
|
||||
<string name="topup_mint_title">Mint feltöltése</string>
|
||||
<string name="topup_mint_action">Ezen mint feltöltése</string>
|
||||
<string name="topup_mint_title">Pénzverde feltöltése</string>
|
||||
<string name="topup_mint_action">Ezen pénzverde feltöltése</string>
|
||||
<string name="topup_mint_amount_label">Hozzáadandó összeg</string>
|
||||
<string name="topup_mint_confirm">Feltöltés</string>
|
||||
<string name="zap_privacy_section">Zap adatvédelem</string>
|
||||
@@ -1240,7 +1240,7 @@
|
||||
<string name="ai_suggested_alt_text_hint">LLM által javasolt, szerkessze bátran</string>
|
||||
<string name="ai_suggested_alt_text_dismiss">LLM-javaslatok eltüntetése</string>
|
||||
<string name="zap_type">Zaptípus</string>
|
||||
<string name="zap_type_explainer">Zap-típus minden lehetőséghez</string>
|
||||
<string name="zap_type_explainer">Zaptípus minden lehetőséghez</string>
|
||||
<string name="zap_type_public">Nyilvános</string>
|
||||
<string name="zap_type_public_explainer">Mindenki láthatja a tranzakciót és az üzenetet</string>
|
||||
<string name="zap_type_private">Privát</string>
|
||||
@@ -1280,7 +1280,7 @@
|
||||
<string name="uploading_state_compressing">Tömörítés…</string>
|
||||
<string name="uploading_state_uploading">Feltöltés…</string>
|
||||
<string name="uploading_state_server_processing">Feldolgozás…</string>
|
||||
<string name="uploading_state_downloading">Letöltés…</string>
|
||||
<string name="uploading_state_downloading">Letöltés</string>
|
||||
<string name="uploading_state_hashing">Kivonatolás</string>
|
||||
<string name="uploading_state_finished">Kész</string>
|
||||
<string name="uploading_state_error">Hiba</string>
|
||||
@@ -1373,14 +1373,14 @@
|
||||
<string name="app_notification_dms_channel_name">Privát üzenetek</string>
|
||||
<string name="app_notification_dms_channel_description">Értesítés, ha privát üzenet érkezik</string>
|
||||
<string name="app_notification_zaps_channel_name">Zapet kapott</string>
|
||||
<string name="app_notification_zaps_channel_description">Értesítés, amikor valaki Zap-et küld Önnek</string>
|
||||
<string name="app_notification_zaps_channel_description">Értesítés, amikor valaki zapet küld Önnek</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s satoshi</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Tőle: %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">neki: %1$s</string>
|
||||
<string name="app_notification_reply_label">Válasz</string>
|
||||
<string name="app_notification_mark_read_label">Megjelölés olvasottként</string>
|
||||
<string name="app_notification_dms_summary">Új üzenetek</string>
|
||||
<string name="app_notification_zaps_summary">Új zap-ek</string>
|
||||
<string name="app_notification_zaps_summary">Új zapek</string>
|
||||
<string name="app_notification_reactions_channel_name">Reakciók</string>
|
||||
<string name="app_notification_reactions_channel_description">Értesítés, amikor valaki reagál az egyik bejegyzésre</string>
|
||||
<string name="app_notification_reactions_channel_message">%1$s reagált az Ön bejegyzésére</string>
|
||||
@@ -1431,7 +1431,7 @@
|
||||
<string name="call_permission_denied_title">Engedély szükséges</string>
|
||||
<string name="call_permission_denied_voice">Az Amethyst alkalmazásnak hozzáférésre van szüksége a mikrofonhoz a hanghívások kezdeményezéséhez. Engedélyezze ezt az alkalmazás beállításaiban.</string>
|
||||
<string name="call_permission_denied_video">Az Amethyst alkalmazásnak hozzáférésre van szüksége a kamerához és a mikrofonhoz a videohívások kezdeményezéséhez. Engedélyezze ezeket az alkalmazás beállításaiban.</string>
|
||||
<string name="call_permission_denied_open_settings">Beállítások menyitása</string>
|
||||
<string name="call_permission_denied_open_settings">Beállítások megnyitása</string>
|
||||
<string name="call_permission_denied_cancel">Mégse</string>
|
||||
<string name="call_settings">Hívásbeállítások</string>
|
||||
<string name="call_settings_enable_calls">Hang- és videóhívások engedélyezése</string>
|
||||
@@ -1440,7 +1440,7 @@
|
||||
<string name="call_settings_max_bitrate">Legmagasabb videó-bitsebesség</string>
|
||||
<string name="call_settings_turn_servers">TURN-/ STUN-kiszolgálók</string>
|
||||
<string name="call_settings_turn_description">Az alapértelmezett STUN- és TURN-kiszolgálók minden esetben biztosítottak. Korlátozó hálózatok esetén adjon hozzá egyéni TURN kiszolgálókat.</string>
|
||||
<string name="call_settings_default_servers">Alapértelmezett kiszolgálók (mindíg aktívak)</string>
|
||||
<string name="call_settings_default_servers">Alapértelmezett kiszolgálók (mindig aktívak)</string>
|
||||
<string name="call_settings_custom_turn">Egyéni TURN-kiszolgálók</string>
|
||||
<string name="call_settings_no_custom_turn">Nincsenek egyéni TURN-kiszolgálók beállítva.</string>
|
||||
<string name="call_settings_add_turn">TURN-kiszolgáló hozzáadása</string>
|
||||
@@ -1500,10 +1500,10 @@
|
||||
<string name="security_hidden_words_empty">Nincsenek rejtett szavak. Adjon hozzá egy szót alább, hogy elrejtse az azt tartalmazó bejegyzéseket.</string>
|
||||
<string name="new_reaction_symbol">Új reakció-szimbólum</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">A felhasználó számára nincsenek előre kiválasztott reakciótípusok. Hosszan nyomja meg a szív gombot a módosításhoz</string>
|
||||
<string name="zapraiser">Zap-gyűjtés</string>
|
||||
<string name="zapraiser">Zapgyűjtés</string>
|
||||
<string name="zapraiser_explainer">Hozzáadja a bejegyzéshez a satoshi célösszeget, hogy megemelje a bejegyzést. Az ezt támogató kliensek ezt egy előrehaladási sávval jeleníthetik meg, hogy adományozásra ösztönözzenek</string>
|
||||
<string name="zapraiser_target_amount_in_sats">Célösszeg satoshiban</string>
|
||||
<string name="sats_to_complete">A Zap-gyűjtés jelenleg: %1$s. %2$s satoshi kell még a célig</string>
|
||||
<string name="sats_to_complete">A zapgyűjtés jelenleg: %1$s. %2$s satoshi kell még a célig</string>
|
||||
<string name="read_from_relay">Olvasás az átjátszóról</string>
|
||||
<string name="write_to_relay">Írás az átjátszóra</string>
|
||||
<string name="write_to_relay_description">Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is</string>
|
||||
@@ -1785,13 +1785,13 @@
|
||||
<string name="moderators">Moderátorok</string>
|
||||
<string name="login_with_external_signer">Bejelentkezés Amberrel</string>
|
||||
<string name="status_update">Állapot frissítése</string>
|
||||
<string name="poll_zap_value_min_max_explainer">A szavazatok egy Zap összeggel vannak súlyozva. Beállíthat egy minimális összeget, hogy elkerülje a spamelőket, és egy maximális összeget, hogy elkerülje, hogy a nagy Zap-elők átvegyék a szavazást. Használja ugyanazt az összeget mindkét mezőben, hogy minden szavazatot ugyanannyira értékeljen. Hagyja üresen, hogy bármilyen összeget elfogadjon.</string>
|
||||
<string name="error_dialog_zap_error">Nem sikerült Zap-et küldeni</string>
|
||||
<string name="poll_zap_value_min_max_explainer">A szavazatok egy zapösszeggel vannak súlyozva. Beállíthat egy minimális összeget, hogy elkerülje a spamelőket, és egy maximális összeget, hogy elkerülje, hogy a nagy zapelők átvegyék a szavazást. Használja ugyanazt az összeget mindkét mezőben, hogy minden szavazatot ugyanannyira értékeljen. Hagyja üresen, hogy bármilyen összeget elfogadjon.</string>
|
||||
<string name="error_dialog_zap_error">Nem sikerült zapet küldeni</string>
|
||||
<string name="error_dialog_talk_to_user">Üzenet a felhasználónak</string>
|
||||
<string name="error_dialog_talk_to_user_name">Üzenet: %1$s</string>
|
||||
<string name="error_dialog_button_ok">OK</string>
|
||||
<string name="zap_split_title">Zapek megosztása és továbbítása</string>
|
||||
<string name="zap_split_explainer">A funkciót támogató kliensek megosztják és továbbítják a Zap-eket az itt hozzáadott felhasználóknak az Ön felhasználói helyett</string>
|
||||
<string name="zap_split_explainer">A funkciót támogató kliensek megosztják és továbbítják a zapeket az itt hozzáadott felhasználóknak az Ön felhasználói helyett</string>
|
||||
<string name="zap_split_search_and_add_user">Felhasználó keresése és hozzáadása</string>
|
||||
<string name="zap_split_search_and_add_user_placeholder">Felhasználónév vagy megjelenítendő név</string>
|
||||
<string name="missing_lud16">Hiányzó lightning-beállítás</string>
|
||||
@@ -1805,7 +1805,7 @@
|
||||
<string name="error_video_open_in_browser">Megnyitás böngészőben</string>
|
||||
<string name="sign_request_rejected2">Aláírási kérés elutasítva</string>
|
||||
<string name="sign_request_rejected_description">Győződjön meg arról, hogy ezt a tranzakciót az aláíró-alkalmazás hitelesítette-e</string>
|
||||
<string name="no_wallet_found_with_error">Nem található pénztárca a Lighning-számla kifizetéséhez (Hiba: %1$s). A Zap-ek használatához telepítsen egy Lightning-pénztárcát</string>
|
||||
<string name="no_wallet_found_with_error">Nem található pénztárca a lighning-számla kifizetéséhez (Hiba: %1$s). A zapek használatához telepítsen egy lightning-pénztárcát</string>
|
||||
<string name="no_wallet_found">Nem található pénztárca a Lighning-számla kifizetéséhez. A Zap-ek használatához telepítsen egy Lightning-pénztárcát</string>
|
||||
<string name="no_blossom_apps_found_title">Nem lehet megnyitni a Blossom-hivatkozásokat</string>
|
||||
<string name="no_blossom_apps_found_description">Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez</string>
|
||||
@@ -1835,7 +1835,7 @@
|
||||
<string name="error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user">Hiba történt a(z) %1$s JSON elemzésekor. Ellenőrizze a felhasználó Lightning-beállítását</string>
|
||||
<string name="callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user">Nem található a visszahívási webcím a(z) %1$s válaszából</string>
|
||||
<string name="incorrect_invoice_amount_sats_from_it_should_have_been">Helytelen (%1$s satoshi) számlaösszeg a következőtől: %2$s. A következőnek kellett volna lennie: %3$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nem sikerült a Zap-összeg elküldése előtt lightning-számlát készíteni. A címzett lightning-pénztárcája a következő hibát küldte: %1$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error">Nem sikerült a zapösszeg elküldése előtt lightning-számlát készíteni. A címzett lightning-pénztárcája a következő hibát küldte: %1$s</string>
|
||||
<string name="read_only_user">Csak olvasható felhasználó</string>
|
||||
<string name="no_reactions_setup">Nincs reakcióbeállítás</string>
|
||||
<string name="notification_settings">Értesítések</string>
|
||||
@@ -1923,7 +1923,7 @@
|
||||
<string name="metadata_strip_failed_body">Ez a fájlformátum nem támogatja a metaadatok eltávolítását. Lehet, hogy a fájl tartalmaz személyes adatokat, például hely- és eszközadatokat. Mindenképp fel akarja tölteni?</string>
|
||||
<string name="metadata_strip_failed_upload">Feltöltés mindenképp</string>
|
||||
<string name="metadata_strip_failed_upload_cancelled">Nem sikerült eltávolítani a médiafájlokból a privát metaadatokat. Feltöltés megszakítva.</string>
|
||||
<string name="upload_cancelled">Feltölrés megszakítva</string>
|
||||
<string name="upload_cancelled">Feltöltés megszakítva</string>
|
||||
<string name="avif_metadata_strip_failed">Nem sikerült eltávolítani az AVIF-fájl metaadatait: %1$s</string>
|
||||
<string name="edit_draft">Piszkozat szerkesztése</string>
|
||||
<string name="login_with_qr_code">Bejelentkezés QR-kóddal</string>
|
||||
@@ -1956,8 +1956,8 @@
|
||||
<string name="wallet_outgoing">Elküldött</string>
|
||||
<string name="wallet_refresh">Frissítés</string>
|
||||
<string name="wallet_filter_all">Összes</string>
|
||||
<string name="wallet_filter_zaps">Zap-ek</string>
|
||||
<string name="wallet_filter_non_zaps">Nem-zap-ek</string>
|
||||
<string name="wallet_filter_zaps">Zapek</string>
|
||||
<string name="wallet_filter_non_zaps">Nem-zapek</string>
|
||||
<string name="wallet_add">Pénztárca hozzáadása</string>
|
||||
<string name="wallet_default">Alapértelmezett</string>
|
||||
<string name="wallet_set_default">Beállítás alapértelmezettként</string>
|
||||
@@ -2009,9 +2009,9 @@
|
||||
<string name="cashu_created_pick_mints">Pénzverdék kiválasztása</string>
|
||||
<string name="cashu_balance">Egyenleg</string>
|
||||
<string name="cashu_mints">Pénzverdék</string>
|
||||
<string name="cashu_mint_url">Mint webcíme</string>
|
||||
<string name="cashu_remove_mint">Mint eltávolítása</string>
|
||||
<string name="cashu_add_mint">Mint hozzáadása</string>
|
||||
<string name="cashu_mint_url">Pénzverde webcíme</string>
|
||||
<string name="cashu_remove_mint">Pénzverde eltávolítása</string>
|
||||
<string name="cashu_add_mint">Pénzverde hozzáadása</string>
|
||||
<string name="cashu_history">Előzmények</string>
|
||||
<string name="cashu_wallet_autosaves">A pénztárcája automatikusan mentésre kerül, amikor pénzverdét ad hozzá vagy távolít el. Egy nutzap kulcs jön létre az Ön számára, amikor először ad hozzá egy pénzverdét.</string>
|
||||
<string name="cashu_wallet_saving">Mentés…</string>
|
||||
@@ -2040,7 +2040,7 @@
|
||||
<string name="cashu_amount_sats">Összeg (satoshiban)</string>
|
||||
<string name="cashu_pick_mint">Válasszon pénzverdét</string>
|
||||
<string name="cashu_memo_optional">Megjegyzés (nem kötelező)</string>
|
||||
<string name="cashu_invoice_bolt11">Lightning számla (bolt11)</string>
|
||||
<string name="cashu_invoice_bolt11">Lightning-számla (bolt11)</string>
|
||||
<string name="cashu_token_label">Cashu token</string>
|
||||
<string name="cashu_copy_invoice">Számla másolása</string>
|
||||
<string name="cashu_request_invoice">Számla kérése</string>
|
||||
@@ -2050,13 +2050,13 @@
|
||||
<string name="cashu_settings_title">Cashu pénztárca beállításai</string>
|
||||
<string name="cashu_settings_edit_wallet">Saját pénzverdék</string>
|
||||
<string name="cashu_settings_edit_wallet_subtitle">Adja hozzá vagy távolítsa el a pénztárcájában használt pénzverdéket.</string>
|
||||
<string name="cashu_settings_my_recommendations">Saját mint-ajánlások</string>
|
||||
<string name="cashu_settings_my_recommendations">Saját pénzverde-ajánlások</string>
|
||||
<string name="cashu_settings_recommendations_subtitle">Vállaljon nyilvánosan kezességet az Ön által megbízhatónak tartott pénzverdékért, és vonja vissza az ajánlásokat.</string>
|
||||
<string name="cashu_settings_no_recommendations">Még nem ajánlott egy mintet sem. Koppintson a felfelé mutató hüvelykujjra egy mint mellett, hogy nyilvánosan ajánlja azt.</string>
|
||||
<string name="cashu_settings_no_recommendations">Még nem ajánlott egy pénzverdét sem. Koppintson a felfelé mutató hüvelykujjra egy pénzverde mellett, hogy nyilvánosan ajánlja azt.</string>
|
||||
<string name="cashu_settings_delete_recommendation">Ajánlás törlése</string>
|
||||
<string name="cashu_settings_delete_confirm_title">Visszavonja az ajánlást?</string>
|
||||
<string name="cashu_settings_delete_confirm_body">Törlési kérés közzététele a(z) %1$s pénzverdéről szóló kind:38000 ajánlásához. Azok az átjátszók, amelyek tiszteletben tartják a NIP-09-et, el fogják távolítani.</string>
|
||||
<string name="cashu_settings_add_recommendation">Egy mint ajánlása</string>
|
||||
<string name="cashu_settings_add_recommendation">Egy pénzverde ajánlása</string>
|
||||
<string name="cashu_settings_restore_title">Visszaállítás seed-ből</string>
|
||||
<string name="cashu_settings_restore_subtitle">Minden korábbi titok újralevezetése, és minden mint megkérdezése, hogy visszaadja-e a még nyilvántartásban lévő vak aláírásokat. Hasznos eszközvesztés vagy egy megbízhatatlan átjátszó általi token-esemény törlése esetén.</string>
|
||||
<string name="cashu_settings_restore_running">Pénzverdék vizsgálata…</string>
|
||||
@@ -2065,14 +2065,14 @@
|
||||
<string name="cashu_settings_stop_nutzaps">Nutzapok fogadásának leállítása</string>
|
||||
<string name="cashu_settings_stop_nutzaps_subtitle">Vonja vissza a kind:10019 eseményét, hogy mások ne küldhessenek Önnek több nutzapot. Az Ön pénztárcája és egyenlege változatlan marad.</string>
|
||||
<string name="cashu_settings_stop_nutzaps_confirm_title">Leállítja a nutzapok fogadását?</string>
|
||||
<string name="cashu_settings_stop_nutzaps_confirm_body">A nutzap-info eseményét egy üresre cseréljük, és kérni fogjuk a törlését. A továbbiakban nem fognak tudni nutzapot küldeni Önnek. Ezt a pénztárcája szerkesztésével bármikor visszakapcsolhatja. Ez az egyenlegét nem érinti.</string>
|
||||
<string name="cashu_settings_stop_nutzaps_confirm_body">A nutzap-info eseményét egy üresre cseréljük, és kérni fogjuk a törlését. A továbbiakban nem fognak tudni nutzapet küldeni Önnek. Ezt a pénztárcája szerkesztésével bármikor visszakapcsolhatja. Ez az egyenlegét nem érinti.</string>
|
||||
<string name="cashu_settings_recreate_key">Nutzap-kulcs újraelőállítása</string>
|
||||
<string name="cashu_settings_recreate_key_subtitle">Egy teljesen új kulcs előállítása a nutzapok fogadásához. Ritkán van rá szükség - általában csak akkor, ha a jelenlegi kulcsa kiszivárgott. A régi kulcsára már elküldött, de még be nem váltott nutzapok elvesznek.</string>
|
||||
<string name="cashu_settings_recreate_key_confirm_title">Újraelőállítja a nutzap-kulcsot?</string>
|
||||
<string name="cashu_settings_recreate_key_confirm_body">Egy új P2PK kulcs kerül előállításra és közzétételre a kind:10019 és kind:17375 eseményeiben, megtartva a jelenlegi pénzverdéit. A küldők elkezdenek nutzapokat zárolni az új kulcshoz. A régi kulcsára már elküldött, de még be nem váltott nutzapok helyreállíthatatlanná válnak. Erre ritkán van szükség.</string>
|
||||
<string name="cashu_settings_recreate_key_action">Kulcs újraelőállítása</string>
|
||||
<string name="cashu_settings_import_key">Nutzap-kulcs importálása</string>
|
||||
<string name="cashu_settings_import_key_subtitle">Cserélje le a nutzap-kulcsát egy beillesztett kulcsra - például a pénztárca biztonsági mentésből történő visszaállításához. Ritkán van rá szükség. A régi kulcsához zárolt nutzapok a továbbiakban nem lesznek beválthatók.</string>
|
||||
<string name="cashu_settings_import_key_subtitle">Cserélje le a nutzap-kulcsát egy beillesztett kulcsra - például a pénztárca biztonsági mentésből történő visszaállításához. Ritkán van rá szükség. A régi kulcsához zárolt nutzapek a továbbiakban nem lesznek beválthatók.</string>
|
||||
<string name="cashu_settings_import_key_confirm_title">Importálja a nutzap-kulcsot?</string>
|
||||
<string name="cashu_settings_import_key_confirm_body">Illessze be a nutzapok fogadásához használandó P2PK privát kulcsot (hex). A kind:10019 és kind:17375 eseményei újra közzé lesznek téve ezzel a kulccsal, megtartva a jelenlegi pénzverdéit. A jelenlegi kulcsához zárolt nutzapok többé nem lesznek beválthatók, kivéve, ha a kulcs megegyezik. Erre ritkán van szükség.</string>
|
||||
<string name="cashu_settings_import_key_field">P2PK privát kulcs (hex)</string>
|
||||
@@ -2080,15 +2080,15 @@
|
||||
<string name="cashu_settings_delete_wallet">Pénztárca törlése</string>
|
||||
<string name="cashu_settings_delete_wallet_subtitle">Távolítsa el a pénztárcát és állítsa le a nutzapokat. A fennmaradó egyenleg helyreállíthatatlanná válhat.</string>
|
||||
<string name="cashu_settings_delete_wallet_confirm_title">Törli ezt a pénztárcát?</string>
|
||||
<string name="cashu_settings_delete_wallet_confirm_body">Ez a művelet a kind:17375 pénztárca és a kind:10019 nutzap-információ törlését kéri. Az ecash-igazolások nem törlődnek a Mintekből, de a pénztárca kulcsának eltűnésével a megmaradt egyenleg és a be nem váltott nutzapok helyreállíthatatlanná válhatnak. Győződjön meg arról, hogy először elköltötte vagy áthelyezte a pénzét. Ez a művelet nem vonható vissza.</string>
|
||||
<string name="cashu_settings_delete_wallet_confirm_body">Ez a művelet a kind:17375 pénztárca és a kind:10019 nutzap-információ törlését kéri. Az ecash-igazolások nem törlődnek a pénzverdékből, de a pénztárca kulcsának eltűnésével a megmaradt egyenleg és a be nem váltott nutzapok helyreállíthatatlanná válhatnak. Győződjön meg arról, hogy először elköltötte vagy áthelyezte a pénzét. Ez a művelet nem vonható vissza.</string>
|
||||
<string name="cashu_pay_invoice">Számla kifizetése</string>
|
||||
<string name="cashu_get_quote">Árajánlat kérése</string>
|
||||
<string name="cashu_getting_quote">Árajánlat kérése a pénzverdétől…</string>
|
||||
<string name="cashu_quote_confirm">Kifizetsz %1$s satot + legfeljebb %2$s sat díjat?</string>
|
||||
<string name="cashu_verify">Ellenőrzés</string>
|
||||
<string name="cashu_mint_reachable">✓ A mint elérhető</string>
|
||||
<string name="cashu_mint_reachable">✓ A pénzverde elérhető</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="cashu_mint_unreachable">A mint nem érhető el: %1$s</string>
|
||||
<string name="cashu_mint_unreachable">A pénzverde nem érhető el: %1$s</string>
|
||||
<string name="nutzap">Nutzap</string>
|
||||
<string name="nutzap_failed_title">Nem sikerült a nutzap</string>
|
||||
<string name="nutzap_failed_no_recipient">Nincs megadva a címzett nyilvános kulcsa a bejegyzésen</string>
|
||||
@@ -2099,7 +2099,7 @@
|
||||
<string name="cashu_done">Kész</string>
|
||||
<string name="cashu_requesting_invoice">Számla kérése a pénzverdétől…</string>
|
||||
<string name="cashu_waiting_for_payment">Várakozás a számla kifizetésére…</string>
|
||||
<string name="cashu_checking_mint">Mint ellenőrzése…</string>
|
||||
<string name="cashu_checking_mint">Pénzverde ellenőrzése…</string>
|
||||
<string name="cashu_completing_mint">Bizonyítékok kiállítása…</string>
|
||||
<string name="cashu_paying_invoice">Fizetés a minten keresztül…</string>
|
||||
<string name="cashu_building_token">Bizonyítékok cseréje…</string>
|
||||
@@ -2150,7 +2150,7 @@
|
||||
<item quantity="other">%1$s sat küldése, %2$d felé osztva</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_use_note_split">
|
||||
<item quantity="one">Ennek a bejegyzésnek a(z) %1$d-felé osztott zap-jének használata</item>
|
||||
<item quantity="one">Ennek a bejegyzésnek a(z) %1$d-felé osztott zapjének használata</item>
|
||||
<item quantity="other">Ennek a bejegyzésnek a(z) %1$d-felé osztott zap-jének használata</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_splits_label">
|
||||
@@ -2331,7 +2331,7 @@
|
||||
<string name="boost_or_quote_description">Megtolás vagy Idézés</string>
|
||||
<string name="like_description">Tetszik</string>
|
||||
<string name="zap_description">Zap</string>
|
||||
<string name="onchain_zap_description">Láncon belüli Bitcoin Zap-ke</string>
|
||||
<string name="onchain_zap_description">Láncon belüli Bitcoinzap</string>
|
||||
<string name="onchain_zap_pending">Függőben lévő megerősítés</string>
|
||||
<string name="change_reaction">Gyors reakciók megváltoztatása</string>
|
||||
<string name="bottom_bar_settings">Alsó navigációs sáv</string>
|
||||
@@ -2348,6 +2348,7 @@
|
||||
<string name="profile_ui_setting_app_recommendations">Ajánlott alkalmazások</string>
|
||||
<string name="profile_ui_setting_zap_received_feed">Beérkezett zapek hírfolyama</string>
|
||||
<string name="profile_ui_setting_followers_feed">Követők hírfolyama</string>
|
||||
<string name="profile_ui_setting_onchain_wallet">Bitcoin (láncon belüli) pénztárca</string>
|
||||
<string name="reactions_settings">Reakciósor</string>
|
||||
<string name="reactions_settings_description">Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését.</string>
|
||||
<string name="reactions_settings_enabled">Engedélyezve</string>
|
||||
@@ -2401,8 +2402,8 @@
|
||||
<string name="disable_poll">Szavazás kikapcsolása</string>
|
||||
<string name="add_bitcoin_invoice">Bitcoin-számla</string>
|
||||
<string name="cancel_bitcoin_invoice">Bitcoin-számla visszavonása</string>
|
||||
<string name="add_zapraiser">Zap-gyűjtés</string>
|
||||
<string name="cancel_zapraiser">Zap-gyűjtés visszavonása</string>
|
||||
<string name="add_zapraiser">Zapgyűjtés</string>
|
||||
<string name="cancel_zapraiser">Zapgyűjtés visszavonása</string>
|
||||
<string name="add_location">Helyszín</string>
|
||||
<string name="remove_location">Helyszín eltávolítása</string>
|
||||
<string name="add_content_warning">Tartalmi figyelmeztetés hozzáadása</string>
|
||||
@@ -2447,7 +2448,7 @@
|
||||
<string name="public_home_section_explainer">Ez az átjátszótípus tárolja az összes tartalmat. Az Amethyst ide küldi az Ön bejegyzéseit, és mások ezeket az átjátszókat fogják használni, hogy megtalálják az Ön tartalmát. Adjon hozzá 1–3 átjátszót. Ezek lehetnek személyes-, fizetett- vagy nyilvános átjátszók.</string>
|
||||
<string name="public_notif_section">Nyilvános bejövő átjátszók</string>
|
||||
<string name="public_notif_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja az értesítéseket</string>
|
||||
<string name="public_notif_section_explainer">Ez az átjátszótípus fogadja az összes választ, hozzászólást, kedvelést és Zap-et az Ön bejegyzéseire. Ezek lehetnek fizetős vagy ingyenes átjátszók. Az átjátszó üzemeltetője által beállított korlátok korlátozhatják a jó és a rossz értesítések számát. Ha például a hozzászólásokban kéretlen üzenet-támadások érik, a fizetős átjátszók kiszűrhetik a kéretlen tartalmakat. Vegyen fel 1–3 átjátszót.</string>
|
||||
<string name="public_notif_section_explainer">Ez az átjátszótípus fogadja az összes választ, hozzászólást, kedvelést és zapet az Ön bejegyzéseire. Ezek lehetnek fizetős vagy ingyenes átjátszók. Az átjátszó üzemeltetője által beállított korlátok korlátozhatják a jó és a rossz értesítések számát. Ha például a hozzászólásokban kéretlen üzenet-támadások érik, a fizetős átjátszók kiszűrhetik a kéretlen tartalmakat. Vegyen fel 1–3 átjátszót.</string>
|
||||
<string name="private_inbox_section">Bejövő közvetlen üzenet-átjátszók</string>
|
||||
<string name="private_inbox_section_explainer_profile">A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket</string>
|
||||
<string name="private_inbox_section_explainer">Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős)</string>
|
||||
@@ -2606,7 +2607,7 @@
|
||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">A piszkozatokra nem lehet válaszolni</string>
|
||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">A piszkozatokat nem lehet idézni</string>
|
||||
<string name="it_s_not_possible_to_react_to_a_draft_note">A piszkozatokra nem lehet reagálni</string>
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">A piszkozatokra nem lehet Zap-et küldeni</string>
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">A piszkozatokra nem lehet zapet küldeni</string>
|
||||
<string name="draft_note">Piszkozat</string>
|
||||
<string name="load_from_text">Üzenet tőle</string>
|
||||
<string name="dvm_looking_for_app">Alkalmazás keresése</string>
|
||||
@@ -2873,7 +2874,7 @@
|
||||
<string name="kind_git_reply">Git válasz</string>
|
||||
<string name="kind_git_pr">Beolvasztási kérés</string>
|
||||
<string name="kind_git_pr_update">Beolvasztási kérés frissítése</string>
|
||||
<string name="kind_zap_goals">Zap-célok</string>
|
||||
<string name="kind_zap_goals">Zapcélok</string>
|
||||
<string name="kind_hashtag_follows">Kulcsszókövetések</string>
|
||||
<string name="kind_highlights">Kiemelések</string>
|
||||
<string name="kind_http_auth">Http-hitelesítés</string>
|
||||
@@ -2887,7 +2888,7 @@
|
||||
<string name="kind_zaps">Zap-ek</string>
|
||||
<string name="kind_nwc_request">NWC-kérések</string>
|
||||
<string name="kind_nwc_response">NWC-válasz</string>
|
||||
<string name="kind_private_zaps">Privát zap-ek</string>
|
||||
<string name="kind_private_zaps">Privát zapek</string>
|
||||
<string name="kind_zap_req">Zap-kérés</string>
|
||||
<string name="kind_blogs">Blogok</string>
|
||||
<string name="kind_meeting_room">Tárgyalószoba</string>
|
||||
@@ -2909,7 +2910,7 @@
|
||||
<string name="kind_pictures">Képek</string>
|
||||
<string name="kind_workouts">Edzések</string>
|
||||
<string name="kind_pins">Rögzítettek</string>
|
||||
<string name="kind_zap_poll">Zap-szavazás</string>
|
||||
<string name="kind_zap_poll">Zapszavazás</string>
|
||||
<string name="kind_poll">Szavazás</string>
|
||||
<string name="kind_poll_response">Szavazásválasz</string>
|
||||
<string name="kind_nip04_dms">NIP-04 közvetlen üzenetek</string>
|
||||
@@ -3088,7 +3089,7 @@
|
||||
<string name="hls_title_label">Cím</string>
|
||||
<string name="hls_title_placeholder">Adjon egy címet a videónak</string>
|
||||
<string name="hls_description_label">Leírás</string>
|
||||
<string name="hls_description_placeholder">Miról szól ez a video?</string>
|
||||
<string name="hls_description_placeholder">Miról szól ez a videó?</string>
|
||||
<string name="hls_content_warning_reason_placeholder">Indoklás (nem kötelező)</string>
|
||||
<string name="hls_codec_label">Kodek</string>
|
||||
<string name="hls_codec_h265">H.265 (jobb tömörítés)</string>
|
||||
@@ -3155,6 +3156,7 @@
|
||||
<string name="goal_progress">%1$s gyűlt össze a(z) %2$s satoshis célból</string>
|
||||
<string name="fundraiser_ends">Véget ér ekkor: %1$s</string>
|
||||
<string name="fundraiser_onchain_donation">Láncon belüli adomány</string>
|
||||
<string name="bird_detection_title">Madárfelismerés</string>
|
||||
<plurals name="birdex_species_count">
|
||||
<item quantity="one">Madárnapló · %1$d faj</item>
|
||||
<item quantity="other">Madárnapló · %1$d faj</item>
|
||||
@@ -3164,6 +3166,9 @@
|
||||
<item quantity="other">%1$s +%2$d további</item>
|
||||
</plurals>
|
||||
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
|
||||
<string name="ps1_save_title">PS1 memóriakártya-mentés</string>
|
||||
<string name="ps1_save_block">%1$d blokk</string>
|
||||
<string name="ps1_save_empty_slot">Üres hely</string>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="road_event_police">Rendőrség</string>
|
||||
<string name="road_event_speed_camera">Sebességmérő kamera</string>
|
||||
@@ -3331,7 +3336,7 @@
|
||||
<string name="add_emoji_fab">Emodzsi hozzáadása</string>
|
||||
<string name="emoji_add_dialog_title">Egyéni emodzsi hozzáadása</string>
|
||||
<string name="emoji_remove_dialog_title">%1$s eltávolítása:?</string>
|
||||
<string name="emoji_long_press_hint">Érintsen meg hosszan egy emodzsit az eltávolításhoz</string>
|
||||
<string name="emoji_long_press_hint">Koppintson hosszan egy emodzsira annak eltávolításhoz</string>
|
||||
<string name="emoji_pack_is_in_list">A(z) „%1$s” már az emodzsilistában van</string>
|
||||
<string name="emoji_pack_is_not_in_list">A(z) „%1$s” még nincs az emodzsilistában</string>
|
||||
<string name="emoji_pack_actions_dialog_title">Emodzsicsomag-műveletek</string>
|
||||
|
||||
@@ -2404,6 +2404,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="profile_ui_setting_app_recommendations">Rekomendacje aplikacji</string>
|
||||
<string name="profile_ui_setting_zap_received_feed">Kanał, który otrzymał Zapa</string>
|
||||
<string name="profile_ui_setting_followers_feed">Kanał Obserwowanych</string>
|
||||
<string name="profile_ui_setting_onchain_wallet">Portfel Bitcoin (on-chain)</string>
|
||||
<string name="reactions_settings">Ustawienia reakcji</string>
|
||||
<string name="reactions_settings_description">Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki.</string>
|
||||
<string name="reactions_settings_enabled">Włączone</string>
|
||||
@@ -3246,6 +3247,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<item quantity="other">%1$s +%2$d więcej</item>
|
||||
</plurals>
|
||||
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
|
||||
<string name="ps1_save_title">Zapis karty pamięci PS1</string>
|
||||
<string name="ps1_save_block">blok %1$d</string>
|
||||
<string name="ps1_save_empty_slot">Pusty slot</string>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="road_event_police">Policja</string>
|
||||
<string name="road_event_speed_camera">Fotoradar</string>
|
||||
|
||||
@@ -988,6 +988,122 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="route_podcasts">Podkasti</string>
|
||||
<string name="podcast_view_episodes">Prikaži epizode</string>
|
||||
<string name="podcast_no_episodes">Trenutno ni najdenih epizod</string>
|
||||
<string name="podcast_trailer">Napovednik</string>
|
||||
<string name="podcast_trailer_season">Sezona %1$d</string>
|
||||
<string name="podcast_explicit">Ekspicitno</string>
|
||||
<string name="podcast_completed">Zaključeno</string>
|
||||
<string name="podcast_premium">Premium</string>
|
||||
<string name="podcast_support_show">Podpri tole epizodo</string>
|
||||
<string name="podcast_by_author">od %1$s</string>
|
||||
<string name="podcast_season_episode">S%1$d · E%2$d</string>
|
||||
<string name="podcast_episode_number">Ep %1$d</string>
|
||||
<string name="podcast_season">Sezona %1$d</string>
|
||||
<string name="podcast_video">Video</string>
|
||||
<string name="podcast_transcript">Prepis</string>
|
||||
<string name="podcast_chapters">Poglavja</string>
|
||||
<string name="podcast_value_for_value">Vrednost za vrednost</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_zap_split_hint">Zapi bojo razdeljeni med:</string>
|
||||
<string name="podcast_hosts_and_guests">Gostitelji in Gostje</string>
|
||||
<string name="podcast_play_soundbite">Predvajaj vrhunec</string>
|
||||
<string name="podcast_top_supporters">Naj podporniki</string>
|
||||
<plurals name="podcast_chapters_count">
|
||||
<item quantity="one">%1$d poglavje</item>
|
||||
<item quantity="two">%1$d poglavji</item>
|
||||
<item quantity="few">%1$d poglavja</item>
|
||||
<item quantity="other">%1$d poglavij</item>
|
||||
</plurals>
|
||||
<string name="podcast_role_host">Gostitelj</string>
|
||||
<string name="podcast_role_cohost">So-gostitelj</string>
|
||||
<string name="podcast_role_editor">Urejevalec</string>
|
||||
<string name="podcast_author_verified">Preverjen avtor</string>
|
||||
<string name="podcast_value_error_title">Napaka pri vrednost za vrednost</string>
|
||||
<string name="podcast_value_no_recipients">Ta podcast nima določenih prejemnikov sredstev.</string>
|
||||
<string name="podcast_value_keysend_requires_nwc">Povežite denarnico Nostr Wallet Connect za pošiljanje k Keysend (vozlišča) prejemnikom.</string>
|
||||
<string name="podcast_value_stream">Sprotno plačevanje satov</string>
|
||||
<string name="podcast_value_stream_rate">%1$d satov/min</string>
|
||||
<string name="podcast_value_stream_hint">Samodejno pošiljanje vrednosti med poslušanjem.</string>
|
||||
<string name="podcast_value_streamed_total">V tej seji ste sproti plačali %1$d satov</string>
|
||||
<string name="podcast_value_stream_requires_wallet">Povežite denarnico Nostr Wallet Connect ali debetno denarnico za sprotno plačevanje satov med poslušanjem.</string>
|
||||
<string name="podcast_new_episode">Nova epizoda</string>
|
||||
<string name="podcast_edit_episode">Uredi epizodo</string>
|
||||
<string name="podcast_publishing_banner">Objavljam…</string>
|
||||
<string name="podcast_cover_upload_cta">Dodaj naslovnico</string>
|
||||
<string name="podcast_cover_upload_hint">Kvadratna slika prikazana za epizodo</string>
|
||||
<string name="podcast_episode_title_label">Naslov</string>
|
||||
<string name="podcast_episode_title_placeholder">Naslov epizode</string>
|
||||
<string name="podcast_episode_summary_label">Povzetek</string>
|
||||
<string name="podcast_episode_duration_label">Trajanje (sekunde)</string>
|
||||
<string name="podcast_episode_more_details">Več podrobnosti</string>
|
||||
<string name="podcast_episode_season_label">Sezona</string>
|
||||
<string name="podcast_episode_number_label">Epizoda #</string>
|
||||
<string name="podcast_episode_video_label">URL videa</string>
|
||||
<string name="podcast_episode_transcript_label">URL prepisa</string>
|
||||
<string name="podcast_episode_chapters_label">URL poglavja</string>
|
||||
<string name="podcast_episode_topics_label">Teme</string>
|
||||
<string name="podcast_episode_topics_placeholder">z vejico ločene oznake</string>
|
||||
<string name="podcast_episode_audio_url_label">URL zvočnega posnetka</string>
|
||||
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
|
||||
<string name="podcast_episode_audio_picked">Zvočni posnetek pripravljen</string>
|
||||
<string name="podcast_episode_audio_upload_cta">Dodaj zvočni posnetek</string>
|
||||
<string name="podcast_episode_audio_upload_hint">MP3, M4A, ali ostali zvočni posnetki</string>
|
||||
<string name="podcast_episode_delete">Izbriši epizodo</string>
|
||||
<string name="podcast_episode_delete_confirm">Izbrišem to epizodo? Tega ni mogoče razveljavit</string>
|
||||
<string name="podcast_edit_show">Vaš podkast</string>
|
||||
<string name="podcast_show_cover_cta">Dodaj naslovnico</string>
|
||||
<string name="podcast_show_cover_hint">Kvadratna slika za vašo oddajo</string>
|
||||
<string name="podcast_show_title_label">Prikaži naslov</string>
|
||||
<string name="podcast_show_title_placeholder">Moj podcast</string>
|
||||
<string name="podcast_show_description_label">Opis</string>
|
||||
<string name="podcast_show_author_label">Avtor</string>
|
||||
<string name="podcast_show_email_label">E-pošta za stik</string>
|
||||
<string name="podcast_show_website_label">Spletna stran</string>
|
||||
<string name="podcast_show_categories_label">Kategorije</string>
|
||||
<string name="podcast_show_categories_placeholder">Tehnologija, novice</string>
|
||||
<string name="podcast_show_funding_label">Povezave za financiranje</string>
|
||||
<string name="podcast_show_funding_placeholder">https://…</string>
|
||||
<string name="podcast_show_language_label">Jezik</string>
|
||||
<string name="podcast_show_language_placeholder">en</string>
|
||||
<string name="podcast_show_copyright_label">Avtorske pravice</string>
|
||||
<string name="podcast_show_type_label">Prikaži tip</string>
|
||||
<string name="podcast_show_type_episodic">Epizodno</string>
|
||||
<string name="podcast_show_type_serial">Serijsko</string>
|
||||
<string name="podcast_show_explicit">Eksplicitna vsebina</string>
|
||||
<string name="podcast_show_complete">Oddaja zaključena (ni več novih epizod)</string>
|
||||
<string name="podcast_show_locked">Zaklenjeno (Premijsko)</string>
|
||||
<string name="podcast_new_trailer">Nov napovednik</string>
|
||||
<string name="podcast_trailer_upload_cta">Dodaj napovednik</string>
|
||||
<string name="podcast_trailer_upload_hint">Kratek predogled (zvok ali video)</string>
|
||||
<string name="podcast_trailer_title_placeholder">Naslov napovednika</string>
|
||||
<string name="podcast_trailer_url_label">Medijski URL</string>
|
||||
<string name="podcast_your_podcast">Vaš podkast</string>
|
||||
<string name="podcast_untitled">Brez naslova</string>
|
||||
<string name="podcast_no_episodes_yet">Še ni epizod. Tapnite »Nova epizoda« in objavite svojo prvo.</string>
|
||||
<string name="podcast_create_your_show">Ustvarite svoj podkast</string>
|
||||
<string name="podcast_tap_to_edit_show">Tapnite za urejanje podrobnosti oddaje</string>
|
||||
<string name="podcast_create_show_hint">Nastavite naslov, naslovnico in podrobnosti oddaje</string>
|
||||
<plurals name="podcast_trailer_count">
|
||||
<item quantity="one">%1$d napovednik</item>
|
||||
<item quantity="two">%1$d napovednika</item>
|
||||
<item quantity="few">%1$d napovedniki</item>
|
||||
<item quantity="other">%1$d napovednikov</item>
|
||||
</plurals>
|
||||
<string name="podcast_value_editor_hint">Dodajte prejemnike za delitev prejetih satov po teži. Poslušalci lahko prispevajo (boost) ali sprotno plačujejo (stream) vrednost na te naslove.</string>
|
||||
<string name="podcast_value_add_recipient">Dodaj prejemnika</string>
|
||||
<string name="podcast_value_add_address">Ročno dodaj naslov</string>
|
||||
<string name="podcast_value_search_user">Dodaj Nostr uporabnika</string>
|
||||
<string name="podcast_value_search_user_hint">Išči po imanu ali @uporabniškem imenu</string>
|
||||
<string name="podcast_value_user_no_lnaddress">Ta uporabnik nima lightning naslova</string>
|
||||
<string name="podcast_value_remove_recipient">Odstrani prejemnika</string>
|
||||
<string name="podcast_value_recipient_name">Ime (neobvezno)</string>
|
||||
<string name="podcast_value_type_lnaddress">Lightning naslov</string>
|
||||
<string name="podcast_value_type_node">Vozlišče (keysend)</string>
|
||||
<string name="podcast_value_lnaddress">Lightning naslov</string>
|
||||
<string name="podcast_value_lnaddress_hint">Ime@primer.com</string>
|
||||
<string name="podcast_value_node_pubkey">Pubkej vozlišča</string>
|
||||
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
|
||||
<string name="podcast_value_weight">Teža</string>
|
||||
<string name="podcast_value_fee">Provizija</string>
|
||||
<plurals name="podcast_episode_count">
|
||||
<item quantity="one">%1$d epizoda</item>
|
||||
<item quantity="two">%1$d epizodi</item>
|
||||
@@ -1022,6 +1138,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="public_bookmarks">Javni zaznamki</string>
|
||||
<string name="repository_bookmarks">Repozitoriji</string>
|
||||
<string name="repository_bookmarks_explainer">Vaši zaznamovani repozitoriji Git</string>
|
||||
<string name="podcast_bookmarks">Podkasti</string>
|
||||
<string name="podcast_bookmarks_explainer">Vaši zaznamki podkastov in epizod</string>
|
||||
<string name="add_to_private_bookmarks">Dodaj v privatne zaznamke</string>
|
||||
<string name="add_to_public_bookmarks">Dodaj v javne zaznamke</string>
|
||||
<string name="remove_from_private_bookmarks">Odstrani iz privatnih zaznamkov</string>
|
||||
@@ -2300,6 +2418,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="profile_ui_setting_app_recommendations">Priporočene aplikacije</string>
|
||||
<string name="profile_ui_setting_zap_received_feed">Vir vsebin prejetih zapov</string>
|
||||
<string name="profile_ui_setting_followers_feed">Vir vsebin sledilcev</string>
|
||||
<string name="profile_ui_setting_onchain_wallet">Bitcoin (on-chain) denarnica</string>
|
||||
<string name="reactions_settings">Vrstica odzivnih ikon</string>
|
||||
<string name="reactions_settings_description">Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev.</string>
|
||||
<string name="reactions_settings_enabled">Omogočeno</string>
|
||||
@@ -3130,6 +3249,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="goal_progress">Zbranih %1$s od ciljnih %2$s satov</string>
|
||||
<string name="fundraiser_ends">Izteče se: %1$s</string>
|
||||
<string name="fundraiser_onchain_donation">On-chain donacija</string>
|
||||
<string name="bird_detection_title">Zaznana ptica</string>
|
||||
<plurals name="birdex_species_count">
|
||||
<item quantity="one">Birdex · %1$d vrsta</item>
|
||||
<item quantity="two">Birdex · %1$d vrsti</item>
|
||||
@@ -3143,6 +3263,9 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<item quantity="other">%1$s in še %2$d drugih</item>
|
||||
</plurals>
|
||||
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
|
||||
<string name="ps1_save_title">Shrani na pomnilniško kartico PS1</string>
|
||||
<string name="ps1_save_block">%1$d blok</string>
|
||||
<string name="ps1_save_empty_slot">Prazen prostor</string>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="road_event_police">Policija</string>
|
||||
<string name="road_event_speed_camera">Hitrostna kamera</string>
|
||||
@@ -3271,6 +3394,9 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="compose_settings">Nastavitve urejevalnika</string>
|
||||
<string name="auto_create_drafts_setting_title">Samodejno ustvari osnutke</string>
|
||||
<string name="auto_create_drafts_setting_description">Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje.</string>
|
||||
<string name="compose_signature_setting_title">Podpis</string>
|
||||
<string name="compose_signature_setting_description">Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite.</string>
|
||||
<string name="compose_signature_setting_hint">Vaš podpis</string>
|
||||
<string name="ai_writing_use_this">Uporabi to</string>
|
||||
<string name="ai_writing_dismiss">Prekliči</string>
|
||||
<string name="ai_tone_correct">Pravilno</string>
|
||||
|
||||
@@ -3124,6 +3124,9 @@
|
||||
<item quantity="other">%1$s + 另%2$d</item>
|
||||
</plurals>
|
||||
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
|
||||
<string name="ps1_save_title">PS1 内存卡保存</string>
|
||||
<string name="ps1_save_block">块 %1$d</string>
|
||||
<string name="ps1_save_empty_slot">空槽位</string>
|
||||
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
|
||||
<string name="road_event_police">警察</string>
|
||||
<string name="road_event_speed_camera">测速照相</string>
|
||||
|
||||
+1
@@ -86,6 +86,7 @@ class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onNewToken(token: String) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PushNotificationService", "PushNotificationReceiverService.onNewToken")
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
import com.android.build.gradle.tasks.GenerateResValues
|
||||
import com.diffplug.gradle.spotless.SpotlessExtensionPredeclare
|
||||
import java.util.Properties
|
||||
|
||||
// Local SonarQube analysis is opt-in: it activates only when `sonar.host.url`
|
||||
// is present in local.properties (gitignored) AND a sonar task was requested,
|
||||
// so neither developers who haven't opted in nor ordinary builds/IDE syncs of
|
||||
// opted-in developers resolve or apply the scanner plugin. The Kotlin DSL
|
||||
// compiles this buildscript {} section in an earlier stage that can't see the
|
||||
// file's imports (hence the qualified Properties) or share code with the body,
|
||||
// but it can publish values — the gate is computed once here and read below
|
||||
// via `by extra`.
|
||||
buildscript {
|
||||
val localProperties = File(rootDir, "local.properties")
|
||||
val sonarProperties by extra(
|
||||
java.util.Properties().apply {
|
||||
if (localProperties.exists()) localProperties.inputStream().use { load(it) }
|
||||
},
|
||||
)
|
||||
val sonarEnabled by extra(
|
||||
sonarProperties.getProperty("sonar.host.url") != null &&
|
||||
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") },
|
||||
)
|
||||
if (sonarEnabled) {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
// LGPL-3.0, build-time only — never linked into shipped artifacts.
|
||||
classpath(libs.sonarqube.gradle.plugin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication) apply false
|
||||
@@ -73,6 +105,32 @@ subprojects {
|
||||
}
|
||||
}
|
||||
|
||||
// Second half of the opt-in local SonarQube support gated above in buildscript {}.
|
||||
// All sonar.* entries in local.properties are forwarded as system properties, so
|
||||
// `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the
|
||||
// command line. sonar.projectKey/projectName default to the root project name
|
||||
// ("Amethyst") and only need overriding in local.properties if desired.
|
||||
val sonarEnabled: Boolean by extra
|
||||
if (sonarEnabled) {
|
||||
val sonarProperties: Properties by extra
|
||||
apply(plugin = "org.sonarqube")
|
||||
|
||||
sonarProperties
|
||||
.stringPropertyNames()
|
||||
.filter { it.startsWith("sonar.") }
|
||||
.forEach { System.setProperty(it, sonarProperties.getProperty(it)) }
|
||||
|
||||
// The scanner's sonarResolver task reads AGP's generated-res-values provider
|
||||
// but doesn't depend on the task that produces it — wire it up in every
|
||||
// module that has both (today only :amethyst enables resValues, but the
|
||||
// scanner defect is module-agnostic).
|
||||
subprojects {
|
||||
tasks.named { it == "sonarResolver" }.configureEach {
|
||||
dependsOn(tasks.withType<GenerateResValues>())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val installGitHook = tasks.register<Copy>("installGitHook") {
|
||||
val dotGit = File(rootProject.rootDir, ".git")
|
||||
val hooksDir: File = if (dotGit.isFile) {
|
||||
|
||||
@@ -38,6 +38,15 @@ What every caller — user, script, agent, CI — can rely on:
|
||||
copy to move. Tests isolate by overriding `$HOME` for the amy
|
||||
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
|
||||
convention `git`, `gpg`, and `npm` use.
|
||||
- **An account is only required to _sign_.** Read-only verbs (relay
|
||||
queries, the shared `store`, `offer`/`debit info`, and the stateless
|
||||
primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional`
|
||||
hands them an accountless dir (its `hasAccount = false`) pointing only at
|
||||
the shared event store, and `Context.openOrAnonymous` gives them an
|
||||
ephemeral key-less identity (they read fine, they just can't
|
||||
authenticate). Signing verbs go through `Context.open`, which re-asserts
|
||||
the account requirement — `init`/`create`/`login`/`logoff`/`whoami`
|
||||
resolve strictly, since they operate on the account dir itself.
|
||||
|
||||
Only the `--json` shape and the exit codes are public API. The default
|
||||
text format is allowed to change between releases. The five design
|
||||
|
||||
+54
-5
@@ -374,6 +374,8 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
|
||||
| `amy whoami` | Print the active account's name + npub. |
|
||||
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
|
||||
| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
|
||||
| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy/<account>/` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. |
|
||||
|
||||
### Social
|
||||
|
||||
@@ -383,6 +385,41 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
| `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). |
|
||||
| `amy graperank [OBSERVER] [--offline] [--publish] [--min-rank N] [--publish-relay URL]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. With `--publish`, reconciles NIP-85 kind:30382 cards signed by a per-observer **service key**: publishes changed/new ranks (cutoff `--min-rank`, default 2), skips unchanged, and **retracts** (kind:5) any card whose target left the graph or fell below the cutoff. |
|
||||
| `amy graperank operator [status \| relay <url>… \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where cards + retractions publish; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. |
|
||||
| `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). |
|
||||
| `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). |
|
||||
|
||||
#### Publishing GrapeRank scores (NIP-85)
|
||||
|
||||
Ranks are published as kind:30382 cards, but **not** under your account key. A
|
||||
machine holds one **operator master** seed (`~/.amy/operator/`, stored via the
|
||||
same `--secret-backend` as accounts, independent of any account). From it a
|
||||
distinct, deterministic **service key** is derived per observer:
|
||||
|
||||
```
|
||||
serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerHex)
|
||||
```
|
||||
|
||||
Because kind:30382 is addressable (`pubkey + d-tag`), the stable per-observer key
|
||||
means re-publishing **replaces** a target's card instead of orphaning it — and
|
||||
losing everything but the master seed still re-derives every key. Set up once and
|
||||
publish:
|
||||
|
||||
```bash
|
||||
amy graperank operator relay wss://relay.example.com # where all cards live
|
||||
amy graperank <observer> --publish # sign with the observer's service key
|
||||
```
|
||||
|
||||
Each publish **reconciles** against what the service key already published: new or
|
||||
changed ranks (≥ `--min-rank`, default 2) are signed and sent; unchanged ranks are
|
||||
skipped (no new event id); and any card whose target dropped out of the graph or
|
||||
fell below the cutoff is **retracted** with a kind:5. When the observer is your
|
||||
own account (we hold the key), Amy also writes their kind:10040 pointing
|
||||
`30382:rank → serviceKey @ operator relay` to their outbox, so clients can find
|
||||
the cards. For a third-party observer, `graperank operator providers` prints the
|
||||
`observer → service-pubkey` mapping to wire their kind:10040 out-of-band.
|
||||
|
||||
### Direct messages (NIP-17)
|
||||
|
||||
@@ -574,7 +611,18 @@ matches that:
|
||||
|
||||
1. If `~/.amy/current` is set, use it.
|
||||
2. Else if exactly one account exists, use it (silent auto-pick).
|
||||
3. Else error and list the candidates so you can disambiguate.
|
||||
3. Else — for a **read-only** verb, run **anonymously**; for a **signing**
|
||||
verb, error and list the candidates so you can disambiguate.
|
||||
|
||||
**No account? Reads still work.** Verbs that only query relays or the shared
|
||||
event store — `fetch`, `subscribe`, `count`, `publish` (broadcasts a
|
||||
pre-signed event), `outbox`, `search`, `sync`, `store …`, the read halves of
|
||||
`profile`/`notes`/`git`/`podcast`/`podcast20`, `nsite`/`napplet` fetch/serve/
|
||||
list, `blossom download`/`check`, `offer`/`debit info`, and every stateless
|
||||
primitive — run against an empty `~/.amy/` with a throwaway key. They read
|
||||
fine; they just can't authenticate. Only verbs that **sign or encrypt with
|
||||
your key** (post, edit, follow, dm, marmot, zap, relay-list edits, blossom
|
||||
upload/list/delete, cashu, …) require an account — and say so.
|
||||
|
||||
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
|
||||
For one-off override, prepend `--account NAME` to any command.
|
||||
@@ -640,11 +688,12 @@ Inside the amy process there's no test mode — it just sees a fresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no account at ~/.amy`** — you haven't created one yet. Run
|
||||
- **`no account configured` / `multiple accounts in ~/.amy (alice, bob)`** —
|
||||
only **signing** verbs raise these; reads run anonymously instead (see
|
||||
"No account? Reads still work" above). Create one with
|
||||
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
|
||||
(full Amethyst-style bootstrap).
|
||||
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
|
||||
`amy use NAME` or pass `--account NAME` per command.
|
||||
(full Amethyst-style bootstrap), or pin/select one with `amy use NAME` /
|
||||
`--account NAME`.
|
||||
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
|
||||
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
|
||||
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
|
||||
|
||||
@@ -43,6 +43,8 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
|---|---|---|
|
||||
| Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 |
|
||||
| Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` |
|
||||
| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. |
|
||||
| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. |
|
||||
| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay <noun> add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. |
|
||||
| MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` |
|
||||
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` |
|
||||
@@ -58,6 +60,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. |
|
||||
| NIP-65 outbox model queries | 🆕 | |
|
||||
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
|
||||
| NIP-72 communities | 🆕 | |
|
||||
| NIP-78 app-specific data (settings sync) | 🆕 | |
|
||||
| Long-form (NIP-23) publish / read | 🆕 | |
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# Reference Homebrew formula for `amy`, the Amethyst CLI.
|
||||
#
|
||||
# This file is NOT consumed by any build in this repo. It is the artifact you
|
||||
# submit to Homebrew/homebrew-core (`brew bump-formula-pr` / a new-formula PR).
|
||||
# Once accepted, homebrew-core's copy is the source of truth; keep this in sync
|
||||
# for reference and to make version bumps a copy-paste.
|
||||
# Reference Homebrew formula for `amy`, the Amethyst CLI. Submit this to
|
||||
# Homebrew/homebrew-core (new-formula PR) or drop it into a personal tap
|
||||
# (`Formula/amy.rb`) for an instant `brew install <tap>/amy`.
|
||||
#
|
||||
# The url + sha256 below are kept in sync automatically on every stable release
|
||||
# by .github/workflows/bump-homebrew-formula.yml (it downloads the published
|
||||
# `amy-<version>-jvm.tar.gz`, recomputes the sha256, and opens a PR). To refresh
|
||||
# by hand instead:
|
||||
# curl -fsSL -o amy-jvm.tar.gz \
|
||||
# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz
|
||||
# shasum -a 256 amy-jvm.tar.gz
|
||||
#
|
||||
# Why a pre-built jar bundle instead of building from source:
|
||||
# homebrew-core builds inside a network sandbox, so a Gradle build cannot
|
||||
@@ -11,17 +18,11 @@
|
||||
# to download a pre-built, no-JRE jar bundle and depend on the system openjdk.
|
||||
# We publish exactly that as `amy-<version>-jvm.tar.gz` (bin/amy + lib/*.jar,
|
||||
# no bundled runtime) from .github/workflows/create-release.yml.
|
||||
#
|
||||
# Before submitting: replace the version in the url and the sha256 with the
|
||||
# values for the actual published release asset:
|
||||
# curl -fsSL -o amy-jvm.tar.gz \
|
||||
# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz
|
||||
# shasum -a 256 amy-jvm.tar.gz
|
||||
class Amy < Formula
|
||||
desc "Command-line Nostr client from the Amethyst project"
|
||||
desc "Nostr client from the Amethyst project"
|
||||
homepage "https://github.com/vitorpamplona/amethyst"
|
||||
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz"
|
||||
sha256 "REPLACE_WITH_RELEASE_ASSET_SHA256"
|
||||
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amy-1.12.6-jvm.tar.gz"
|
||||
sha256 "209316d704a4622ddef1fd86b958b7619e9d049c20f3543dff60348ec73affd6"
|
||||
license "MIT"
|
||||
|
||||
# Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# GrapeRank score parity with NosFabrica Brainstorm
|
||||
|
||||
Goal: `amy graperank` should output scores **numerically very close** to
|
||||
NosFabrica's Brainstorm service, the reference GrapeRank implementation.
|
||||
|
||||
Sources analysed:
|
||||
- `NosFabrica/brainstorm_graperank_algorithm` — the Java scoring worker.
|
||||
- `NosFabrica/brainstorm_server` — the Python orchestration server.
|
||||
|
||||
## How Brainstorm builds its service
|
||||
|
||||
A four-stage pipeline:
|
||||
|
||||
1. **Ingest.** `app/nostr_event_transferer/nostr_event_transferer.py` copies raw
|
||||
social-graph events — **kinds 0, 3, 10000, 1984** (profiles, follows, mutes,
|
||||
reports) — from a strfry relay into the server. Same four kinds we crawl.
|
||||
2. **Graph.** Events land in **Neo4j** as a directed graph of follow / mute /
|
||||
report edges between pubkeys. Redis + Postgres back the job queue and config.
|
||||
3. **Score.** The Java worker (`grape/GrapeRankAlgorithm.java`) runs GrapeRank
|
||||
from an observer, producing a **`ScoreCard`** per user
|
||||
(`rank/ScoreCard.java`): `observer, observee, hops, averageScore, input,
|
||||
confidence, influence, verified, trustedFollowers, trustedReporters`.
|
||||
**There is no `rank` field — the trust value is `influence` ∈ [0,1].**
|
||||
4. **Serve / publish.** Presets are tunable per deployment
|
||||
(`DEFAULT` / `PERMISSIVE` / `RESTRICTIVE`, `graperank_preset` table, validated
|
||||
by `GrapeRankPresetParams`). Java `GrapeRankParams` mirrors the Python model
|
||||
field-for-field; the README states Python is the source of truth and both
|
||||
repos must stay in sync.
|
||||
|
||||
## The algorithm (their `grape/GrapeRankAlgorithm.java`)
|
||||
|
||||
```
|
||||
rigority = -log(rigor)
|
||||
confidence(sumWeights) = 1 - exp(-sumWeights * rigority) # weight -> confidence
|
||||
per edge: weight = edgeConfidence * influenceOfRater * attenuationFactor
|
||||
wxr = weight * edgeRating
|
||||
averageScore = sumWxR / sumWeights (0 if sumWeights == 0)
|
||||
influence = max(averageScore * confidence(sumWeights), 0)
|
||||
```
|
||||
|
||||
- Observer seeded at `influence = 1.0` (fixed authority).
|
||||
- Non-observers seeded by hop distance, then **iterated until every user's
|
||||
influence delta < 0.0001** (`loopBreakDelta`). Seeding only affects the
|
||||
starting guess; attenuation < 1 makes the update a contraction, so the fixed
|
||||
point is unique.
|
||||
- The rater weight uses the rater's **`influence`**, and
|
||||
`influence = max(weightToConfidence(sumW) * sumWR/sumW, 0)`.
|
||||
|
||||
## Side-by-side: Brainstorm DEFAULT vs `commons/wot`
|
||||
|
||||
`Constants.java` `DEFAULT_PARAMS` (== the Pydantic `GrapeRankPresetParams`
|
||||
DEFAULT) against our `GrapeRankParams` defaults:
|
||||
|
||||
| Brainstorm field | value | our field | value | match |
|
||||
|---|---|---|---|---|
|
||||
| `attenuationFactor` | 0.85 | `attenuation` | 0.85 | ✅ |
|
||||
| `rigor` | 0.5 | `rigor` | 0.5 | ✅ |
|
||||
| `followRating` | 1.0 | `FOLLOW.rating` | 1.0 | ✅ |
|
||||
| `muteRating` | -0.1 | `MUTE.rating` | -0.1 | ✅ |
|
||||
| `reportRating` | -0.1 | `REPORT.rating` | -0.1 | ✅ |
|
||||
| `followConfidenceOfObserver` | 0.5 | `directFollowConfidence` | 0.5 | ✅ |
|
||||
| `followConfidence` | 0.03 | `indirectFollowConfidence` | 0.03 | ✅ |
|
||||
| `muteConfidence` | 0.5 | `muteConfidence` | 0.5 | ✅ |
|
||||
| `reportConfidence` | 0.5 | `reportConfidence` | 0.5 | ✅ |
|
||||
| `loopBreakDelta` | 0.0001 | `convergence` | 0.0001 | ✅ |
|
||||
|
||||
The three `verified*InfluenceCutoff`s (followers 0.02, reporters 0.1,
|
||||
muters 0.01) only flag a derived `verified` boolean; they do **not** affect the
|
||||
score.
|
||||
|
||||
**Conclusion: our formula is identical and every scoring parameter matches
|
||||
DEFAULT.** Our `score` *is* their `influence`
|
||||
(`max(weightToConfidence(sumW) * sumWR/sumW, 0)`), propagated as the rater
|
||||
weight — the exact same quantity. On the same input graph the two produce the
|
||||
same influence to floating-point precision. Our published `rank = round(score *
|
||||
100)` is a presentation choice on top of that influence (their `ScoreCard`
|
||||
exposes `influence` as a raw float via the API).
|
||||
|
||||
## Where divergence can still come from — and why it's small
|
||||
|
||||
It is **data**, not math:
|
||||
|
||||
1. **Graph completeness.** Brainstorm ingests the whole strfry graph into Neo4j;
|
||||
we crawl outward from the observer via the outbox model. **This matters less
|
||||
than it seems:** a mute/report contributes `confidence * influenceOfRater *
|
||||
attenuation`, so a signal from a user with **zero influence** (someone outside
|
||||
the observer's trust graph) contributes **zero**. Only follows/mutes/reports
|
||||
authored by users *inside* the follow graph move a score — and those are
|
||||
exactly the users our crawl discovers and whose kind 3/10000/1984 we fetch.
|
||||
So the effective scoring input is the same, as long as the crawl actually
|
||||
checks every discovered user's outbox — which it now does exhaustively (no
|
||||
user cap, retrying an unreachable outbox a few times).
|
||||
2. **Fringe users / crawl gaps.** A relay timeout that drops a contact list
|
||||
removes edges and shifts nearby scores. The injector mitigates this with a
|
||||
two-stage model mirroring the app's `pickRelaysToLoadUsers`, plus a
|
||||
completeness loop that retries until every user's outbox has been checked:
|
||||
- **Relay-list discovery** (kind:10002) queries the account's relays +
|
||||
bootstrap + event-finder + **indexer relays** (purplepag.es, coracle, …).
|
||||
Indexers aggregate kind:10002 (and kind:0) for the whole network, so this is
|
||||
where a stranger's outbox is found — the biggest completeness lever.
|
||||
- **Content** (kind:3/10000/1984/0) is fetched from each user's **own outbox**
|
||||
write relays, with harvested **relay hints** (from the `p`-tag hints in
|
||||
contact lists we crawl) and general-purpose relays as a best-effort fallback
|
||||
when the outbox is unknown/down. **Indexers are not used for content** — they
|
||||
don't serve those kinds; kind:3/mutes/reports live only on the user's outbox.
|
||||
The crawl loops round by round, retrying any member whose contact list still
|
||||
didn't arrive (a few times), until every discovered user's outbox
|
||||
has been checked. Remaining mitigation lever: a generous `--timeout`.
|
||||
3. **Convergence precision.** Both stop at delta 0.0001; residual error is
|
||||
< ~0.0001 in influence ⇒ < ~0.01 rank points ⇒ identical integer `rank`.
|
||||
4. **Seeding.** Their hop-distance seed vs our zero seed — same fixed point, no
|
||||
effect on the result.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Keep the current DEFAULT params** — they are byte-for-byte the Brainstorm
|
||||
DEFAULT preset. No change needed for parity.
|
||||
- **The crawl is exhaustive by default** (no user cap; every reachable user's
|
||||
outbox is checked, unreachable outboxes retried a few times). An
|
||||
incomplete crawl is the single biggest source of drift, so avoid capping it.
|
||||
- **Optional, for fuller parity (not required for close scores):**
|
||||
- Add `--preset default|permissive|restrictive`. DEFAULT is confirmed; the
|
||||
PERMISSIVE / RESTRICTIVE numbers are DB-seeded in `brainstorm_server` (an
|
||||
alembic seed migration) and were not extractable from the public tree —
|
||||
pull them from a running instance before hard-coding.
|
||||
- Optionally expose `influence` as a raw float alongside `rank` in `--json`,
|
||||
and compute the `verified` flag from the cutoffs, to mirror their
|
||||
`ScoreCard` shape for interop diffing.
|
||||
|
||||
## Verification idea
|
||||
|
||||
Point `amy graperank <observer> --offline` at a store seeded from the same
|
||||
strfry snapshot Brainstorm ingested, and diff our `score` against their
|
||||
`ScoreCard.influence` for the same observer. Expect agreement to ~1e-4.
|
||||
@@ -143,6 +143,16 @@ data class Identity(
|
||||
npub = pubHex.hexToByteArray().toNpub(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Ephemeral, key-less identity for anonymous read-only runs (no
|
||||
* account on disk). It mints a throwaway public key so the
|
||||
* relay-list fallbacks (`outboxRelays()` etc.) resolve to the
|
||||
* built-in defaults, and it carries no private key, so any attempt
|
||||
* to sign/encrypt fails loudly — "you can read, you just can't
|
||||
* auth". Used by [com.vitorpamplona.amethyst.cli.Context.openOrAnonymous].
|
||||
*/
|
||||
fun anonymous(): Identity = fromPublicKeyHex(KeyPair().pubKey.toHexKey())
|
||||
|
||||
/**
|
||||
* Rebuild an in-memory identity after a load. Accepts the public
|
||||
* parts that live on disk and a private key resolved from the
|
||||
@@ -204,6 +214,17 @@ class DataDir(
|
||||
val eventsDir: File,
|
||||
val accountName: String,
|
||||
val secrets: SecretStore,
|
||||
/**
|
||||
* Whether this points at a concrete account. `false` for the
|
||||
* accountless directory [resolveOptional] hands back when `~/.amy/`
|
||||
* has no unambiguous account — [root] then points at the shared
|
||||
* sibling and only [eventsDir] (the cross-account event store) is
|
||||
* meaningful. Read-only verbs run anonymously against it; signing
|
||||
* verbs get [noAccountDetail] via `Context.open`.
|
||||
*/
|
||||
val hasAccount: Boolean = true,
|
||||
/** Human-readable reason there is no account, for the signing-verb error. */
|
||||
val noAccountDetail: String? = null,
|
||||
) {
|
||||
val identityFile = File(root, "identity.json")
|
||||
val stateFile = File(root, "state.json")
|
||||
@@ -213,14 +234,34 @@ class DataDir(
|
||||
val groupsDir = File(marmotDir, "groups")
|
||||
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
|
||||
|
||||
/**
|
||||
* SQLite event-store DB file, a sibling of [eventsDir] under
|
||||
* `<root>/shared/`. Used when the store backend is SQLite (the
|
||||
* default — see [StoreFactory]); the FS backend uses [eventsDir]
|
||||
* instead. Kept alongside the FS store so switching backends never
|
||||
* clobbers the other's data.
|
||||
*/
|
||||
val eventsDbFile: File = File(eventsDir.parentFile ?: root, "events.db")
|
||||
|
||||
/**
|
||||
* Machine-level operator keys for GrapeRank trusted-assertion publishing,
|
||||
* rooted at `~/.amy/operator/` (the account root's parent) so a single
|
||||
* operator master is shared across accounts. See [OperatorKeys].
|
||||
*/
|
||||
fun operatorKeys(): OperatorKeys = OperatorKeys(root.parentFile ?: root, secrets)
|
||||
|
||||
init {
|
||||
SecureFileIO.secureMkdirs(root)
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
// Tighten perms on any data already on disk from an older, unhardened CLI.
|
||||
SecureFileIO.tighten(identityFile)
|
||||
SecureFileIO.tighten(stateFile)
|
||||
SecureFileIO.tighten(marmotDir)
|
||||
SecureFileIO.tighten(keyPackageBundleFile)
|
||||
// The accountless dir only ever exposes the shared event store; don't
|
||||
// seed per-account marmot dirs / tighten identity files under it.
|
||||
if (hasAccount) {
|
||||
SecureFileIO.secureMkdirs(groupsDir)
|
||||
// Tighten perms on any data already on disk from an older, unhardened CLI.
|
||||
SecureFileIO.tighten(identityFile)
|
||||
SecureFileIO.tighten(stateFile)
|
||||
SecureFileIO.tighten(marmotDir)
|
||||
SecureFileIO.tighten(keyPackageBundleFile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +416,69 @@ class DataDir(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [resolve], but never throws when there is no account: read-only
|
||||
* verbs can run without one. When `--account` is given it is honoured;
|
||||
* otherwise the pin / sole-account are used if unambiguous. Failing
|
||||
* that, returns an *accountless* [DataDir] (`hasAccount = false`) whose
|
||||
* [root] is the shared sibling and whose [eventsDir] is still the
|
||||
* cross-account event store — enough for anonymous relay queries and
|
||||
* `store` maintenance. The reason no account was chosen is carried in
|
||||
* [DataDir.noAccountDetail] so a signing verb can surface it.
|
||||
*/
|
||||
fun resolveOptional(
|
||||
accountFlag: String?,
|
||||
secrets: SecretStore,
|
||||
): DataDir {
|
||||
val rootBase = DEFAULT_ROOT
|
||||
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
|
||||
if (accountFlag != null) {
|
||||
val name = validateName(accountFlag)
|
||||
return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets)
|
||||
}
|
||||
val picked = pickAccountOptional(rootBase)
|
||||
return if (picked.name != null) {
|
||||
DataDir(File(rootBase, picked.name).absoluteFile, sharedEvents, picked.name, secrets)
|
||||
} else {
|
||||
DataDir(
|
||||
root = File(rootBase, SHARED_DIR_NAME).absoluteFile,
|
||||
eventsDir = sharedEvents,
|
||||
accountName = SHARED_DIR_NAME,
|
||||
secrets = secrets,
|
||||
hasAccount = false,
|
||||
noAccountDetail = picked.detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of [pickAccountOptional]: an account [name], or null plus a [detail] reason. */
|
||||
private data class OptionalPick(
|
||||
val name: String?,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
/** Non-throwing sibling of [pickAccount]: null [name] with a [detail] when 0 / ambiguous. */
|
||||
private fun pickAccountOptional(rootBase: File): OptionalPick {
|
||||
val current = File(rootBase, CURRENT_MARKER_NAME)
|
||||
if (current.isFile) {
|
||||
val pinned = current.readText().trim()
|
||||
if (pinned.isNotEmpty() && File(rootBase, pinned).isDirectory) {
|
||||
return OptionalPick(pinned, null)
|
||||
}
|
||||
}
|
||||
val accounts = listAccounts(rootBase)
|
||||
return when (accounts.size) {
|
||||
0 -> OptionalPick(null, "no account configured (create one with `amy --account <name> init`)")
|
||||
1 -> OptionalPick(accounts.single(), null)
|
||||
else ->
|
||||
OptionalPick(
|
||||
null,
|
||||
"multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " +
|
||||
"pick one with --account <name> or `amy use <name>`",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select an account when `--name` was not given. Honours
|
||||
* `<root>/current` first (explicit pin from `amy use`), then
|
||||
|
||||
@@ -38,12 +38,14 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DrainFailure
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.classifyDrainFailure
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
|
||||
@@ -55,7 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketF
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
@@ -69,6 +71,7 @@ import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore
|
||||
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
|
||||
import com.vitorpamplona.quartz.utils.SeenIds
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -78,7 +81,9 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Per-invocation wiring. Each CLI run constructs a Context, does its work,
|
||||
@@ -98,9 +103,10 @@ import okhttp3.OkHttpClient
|
||||
* Every Nostr event Amy observes — whether received from a relay
|
||||
* subscription, unwrapped from a NIP-59 gift wrap, or generated locally
|
||||
* before publish — is verified (NIP-01 signature + id check via
|
||||
* [Event.verify]) and persisted to the file-backed [IEventStore] at
|
||||
* `<data-dir>/events-store/`. Malformed events are dropped before
|
||||
* reaching command code.
|
||||
* [Event.verify]) and persisted to the shared [IEventStore] under
|
||||
* `<data-dir>/shared/` (a SQLite DB by default, or the FS tree when
|
||||
* `AMY_STORE=fs` — see [StoreFactory]). Malformed events are dropped
|
||||
* before reaching command code.
|
||||
*
|
||||
* This makes [store] the authoritative cache of everything Amy has ever
|
||||
* seen: profile metadata, relay lists, contact lists, gift wraps,
|
||||
@@ -115,8 +121,40 @@ class Context(
|
||||
val dataDir: DataDir,
|
||||
val identity: Identity,
|
||||
val state: RunState,
|
||||
/**
|
||||
* Anonymous read-only run: no account on disk, [identity] is an ephemeral
|
||||
* key-less identity (see [Identity.anonymous]). Marmot state is not
|
||||
* restored and run-state is not persisted — the run only reads relays and
|
||||
* the shared event store. Signing verbs never take this path; they go
|
||||
* through [Companion.open], which requires a real account.
|
||||
*/
|
||||
val anonymous: Boolean = false,
|
||||
) : AutoCloseable {
|
||||
private val okhttp = OkHttpClient.Builder().socketFactory(TcpNoDelaySocketFactory).build()
|
||||
private val okhttp =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.socketFactory(TcpNoDelaySocketFactory)
|
||||
// The crawl opens WebSockets to thousands of relays. Each WS-upgrade
|
||||
// handshake is an async call through OkHttp's shared Dispatcher, whose
|
||||
// default cap (maxRequests=64) throttles the connection ramp — worse,
|
||||
// a dead relay holds a slot for the whole connectTimeout, starving live
|
||||
// relays queued behind it. Widen the dispatcher so handshakes fan out,
|
||||
// and keep connectTimeout tight-ish so an unreachable relay frees its
|
||||
// slot fast. This is orthogonal to REQ concurrency (that runs on
|
||||
// already-open sockets, bounded by AdaptiveRelayLimiter), so it can't
|
||||
// trip a relay's REQ rate-limit — it only speeds connection setup. The
|
||||
// executor thread pool is unbounded on demand, so raising maxRequests
|
||||
// just lets more of those short-lived handshakes proceed at once. 7s
|
||||
// (not 5s): a 5s cap struck too many merely-busy relays as connect
|
||||
// failures — the crawl treats a connect *timeout* as retryable anyway,
|
||||
// but the extra headroom lets slow-but-alive relays finish the handshake.
|
||||
.connectTimeout(7, TimeUnit.SECONDS)
|
||||
.dispatcher(
|
||||
Dispatcher().apply {
|
||||
maxRequests = 256
|
||||
maxRequestsPerHost = 16
|
||||
},
|
||||
).build()
|
||||
|
||||
val client: NostrClient =
|
||||
NostrClient(
|
||||
@@ -146,6 +184,52 @@ class Context(
|
||||
)
|
||||
} ?: NostrSignerInternal(identity.keyPair())
|
||||
|
||||
/**
|
||||
* Client-wide tally of relay feedback — NOTICE frames, CLOSED reasons
|
||||
* (auth-required / rate-limited / restricted / …), and NIP-42 AUTH
|
||||
* challenges — so a failed REQ can be explained instead of guessed at.
|
||||
* Registered on [client] for the life of this run.
|
||||
*/
|
||||
val relayDiagnostics: RelayDiagnostics = RelayDiagnostics().also { client.addConnectionListener(it) }
|
||||
|
||||
/**
|
||||
* Adaptive per-relay concurrent-subscription cap. Starts every relay
|
||||
* generous (100) and demotes only the ones that complain about concurrency
|
||||
* (100 → 20 → 10), driven straight off the NOTICE/CLOSED frames it observes
|
||||
* as a connection listener. [drain]'s `gatePerRelay` path holds a relay's
|
||||
* permit for the life of that relay's subscription, so we never exceed the
|
||||
* cap the relay itself asked for. Idle for commands that don't opt in.
|
||||
*/
|
||||
val relayLimiter: AdaptiveRelayLimiter =
|
||||
AdaptiveRelayLimiter(
|
||||
// The starting per-relay concurrent-sub cap dominates whether the crawl
|
||||
// floods a popular relay into timing out. Benchmarked: 16 is ~30% faster
|
||||
// on a from-scratch GrapeRank crawl than the old 100 (which drowned
|
||||
// damus/nos.lol in 100 concurrent giant REQs) at equal completeness, and
|
||||
// is still generous for the single-user fetches other amy commands do.
|
||||
startCap = 16,
|
||||
).also { client.addConnectionListener(it) }
|
||||
|
||||
/**
|
||||
* NIP-42 responder: answers a relay's AUTH challenge by signing with the
|
||||
* account key, so auth-gated relays serve our reads instead of CLOSing the
|
||||
* subscription. Constructing it registers its own listener on [client].
|
||||
* Only a local key auto-signs — a remote bunker signer is skipped, since a
|
||||
* per-relay remote round-trip during a crawl would stall it (and signing an
|
||||
* auth event with any key still unlocks relays that just want *some* auth).
|
||||
*/
|
||||
private val relayAuth: RelayAuthenticator =
|
||||
RelayAuthenticator(
|
||||
client = client,
|
||||
signWithAllLoggedInUsers = { _, template ->
|
||||
if (signer is NostrSignerInternal) {
|
||||
runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys.
|
||||
* Uses the same OkHttp instance as the WebSocket client so we share connection
|
||||
@@ -158,32 +242,40 @@ class Context(
|
||||
.OkHttpNip05Fetcher { _ -> okhttp },
|
||||
)
|
||||
|
||||
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
|
||||
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
||||
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
||||
// Lazy so an anonymous read (no account dir) never materialises the
|
||||
// per-account marmot stores — constructing them would `mkdir` group dirs
|
||||
// under the shared root. Real accounts build them on first marmot use.
|
||||
private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) }
|
||||
private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) }
|
||||
private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) }
|
||||
|
||||
/**
|
||||
* Filesystem-backed Nostr event store, rooted at [DataDir.eventsDir].
|
||||
* Lazy so commands that don't touch persistent event state pay zero
|
||||
* open cost (no `.lock` file, no seed allocation). Closed by
|
||||
* [close] when this Context shuts down.
|
||||
*
|
||||
* Files are written pretty-printed (not the compact NIP-01 canonical
|
||||
* form) so `cat`, `jq`, `git diff` are useful out of the box —
|
||||
* humans inspect these files. Verification always re-canonicalises,
|
||||
* so the stored bytes never feed back into a signature check.
|
||||
* Shared Nostr event store for this run, opened via [StoreFactory]
|
||||
* (SQLite by default, or the FS tree when `AMY_STORE=fs`). Lazy so
|
||||
* commands that don't touch persistent event state pay zero open cost
|
||||
* (no DB file / `.lock`, no seed allocation). Closed by [close] when
|
||||
* this Context shuts down.
|
||||
*/
|
||||
private val storeDelegate: Lazy<IEventStore> =
|
||||
lazy {
|
||||
FsEventStore(
|
||||
root = dataDir.eventsDir.toPath(),
|
||||
eventToJson = JacksonMapper::toJsonPretty,
|
||||
)
|
||||
}
|
||||
private val storeDelegate: Lazy<IEventStore> = lazy { StoreFactory.open(dataDir) }
|
||||
val store: IEventStore by storeDelegate
|
||||
|
||||
/**
|
||||
* Shared relay-reachability cache (NIP-66 kind:30166 records in [store]), signed by
|
||||
* the machine's dedicated monitor key — derived from the operator master, NOT the
|
||||
* account (see [OperatorKeys.monitorKey]). The crawler and the WoT updater read its
|
||||
* dead set to skip proven-dead relays and write their findings back, so liveness
|
||||
* knowledge is shared across procedures and runs instead of rediscovered each time.
|
||||
* Lazy so a run that never touches relays doesn't materialize the operator master.
|
||||
*/
|
||||
val reachability: RelayReachabilityStore by lazy {
|
||||
RelayReachabilityStore(
|
||||
store = store,
|
||||
signer = NostrSignerInternal(dataDir.operatorKeys().monitorKey()),
|
||||
)
|
||||
}
|
||||
|
||||
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
|
||||
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
|
||||
val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cashu (NIP-60 / NIP-61) — shared wallet code from commons
|
||||
@@ -302,7 +394,9 @@ class Context(
|
||||
*/
|
||||
suspend fun prepare() {
|
||||
if (prepared) return
|
||||
marmot.restoreAll()
|
||||
// Anonymous runs have no account and therefore no marmot state to
|
||||
// restore (and touching `marmot` would allocate the per-account stores).
|
||||
if (!anonymous) marmot.restoreAll()
|
||||
client.connect()
|
||||
// A bunker account must open its NIP-46 response subscription and run
|
||||
// the connect handshake before any signing/encryption call.
|
||||
@@ -370,6 +464,23 @@ class Context(
|
||||
/** Union of all three buckets. */
|
||||
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
|
||||
|
||||
/**
|
||||
* Index relays — the shared, app-global set used to fetch profile
|
||||
* metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop
|
||||
* app's `LocalRelayCategories.indexRelays` by reading from the same
|
||||
* `java.util.prefs` node
|
||||
* (`com/vitorpamplona/amethyst/relays/index`). Falls back to the
|
||||
* shipping defaults when the user hasn't configured anything.
|
||||
*
|
||||
* This is what `amy wot sync` uses; `outboxRelays()` /
|
||||
* `inboxRelays()` remain for callers that want relay lists derived
|
||||
* from NIP-65 identity semantics.
|
||||
*/
|
||||
fun indexRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.amethyst.commons.relays.index
|
||||
.PreferencesIndexRelays()
|
||||
.effective()
|
||||
|
||||
/**
|
||||
* Seed relays for "look up someone we know nothing about" queries —
|
||||
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
|
||||
@@ -411,15 +522,26 @@ class Context(
|
||||
* Subscribe to the given filters across the given relays, drain all events
|
||||
* until either every relay has sent EOSE or the timeout elapses, and
|
||||
* return them. Used for one-shot catch-up queries — not live subscriptions.
|
||||
*
|
||||
* When [deadOut] is provided, every relay that reported it could not be
|
||||
* connected to (`onCannotConnect`) is added to it, so callers can prune
|
||||
* proven-dead relays from future routing instead of paying the full
|
||||
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported —
|
||||
* only hard connect failures, so a temporarily-busy relay isn't discarded.
|
||||
*/
|
||||
suspend fun drain(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 8_000,
|
||||
diagnoseSlow: Boolean = false,
|
||||
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
|
||||
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
|
||||
// Carries the terminal reason per relay so a timeout can distinguish a slow
|
||||
// relay (never terminal) from a connect failure / CLOSED.
|
||||
val doneChannel = Channel<Pair<NormalizedRelayUrl, String>>(UNLIMITED)
|
||||
val remaining = filters.keys.toMutableSet()
|
||||
val doneReasons = HashMap<NormalizedRelayUrl, String>()
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -436,7 +558,7 @@ class Context(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
doneChannel.trySend(relay to "eose")
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
@@ -444,7 +566,7 @@ class Context(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
doneChannel.trySend(relay to "closed:$message")
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
@@ -452,37 +574,75 @@ class Context(
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
doneChannel.trySend(relay to "cannot:$message")
|
||||
}
|
||||
}
|
||||
val collected = mutableListOf<Pair<NormalizedRelayUrl, Event>>()
|
||||
try {
|
||||
client.subscribe(subId, filters, listener)
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
while (remaining.isNotEmpty()) {
|
||||
select {
|
||||
eventChannel.onReceive { pair ->
|
||||
if (verifyAndStore(pair.second)) collected.add(pair)
|
||||
val completed =
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
while (remaining.isNotEmpty()) {
|
||||
select {
|
||||
eventChannel.onReceive { pair ->
|
||||
if (verifyAndStore(pair.second)) collected.add(pair)
|
||||
}
|
||||
doneChannel.onReceive { (relay, reason) ->
|
||||
remaining.remove(relay)
|
||||
doneReasons[relay] = reason
|
||||
}
|
||||
}
|
||||
doneChannel.onReceive { r -> remaining.remove(r) }
|
||||
}
|
||||
// Drain any events that landed after EOSE but before cancel
|
||||
while (true) {
|
||||
val r = eventChannel.tryReceive()
|
||||
if (!r.isSuccess) break
|
||||
val pair = r.getOrThrow()
|
||||
if (verifyAndStore(pair.second)) collected.add(pair)
|
||||
}
|
||||
true
|
||||
}
|
||||
// Drain any events that landed after EOSE but before cancel
|
||||
while (true) {
|
||||
val r = eventChannel.tryReceive()
|
||||
if (!r.isSuccess) break
|
||||
val pair = r.getOrThrow()
|
||||
if (verifyAndStore(pair.second)) collected.add(pair)
|
||||
}
|
||||
if (diagnoseSlow && completed == null && remaining.isNotEmpty()) {
|
||||
logSlowDrain(timeoutMs, remaining, doneReasons, collected)
|
||||
}
|
||||
} finally {
|
||||
client.unsubscribe(subId)
|
||||
eventChannel.close()
|
||||
doneChannel.close()
|
||||
}
|
||||
deadOut?.let { out ->
|
||||
for ((relay, reason) in doneReasons) {
|
||||
classifyDrainFailure(reason)?.let { out[relay] = it }
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* On a [drain] timeout, report which relays stalled and why — a relay that
|
||||
* never sent EOSE (slow, possibly still streaming) vs one that couldn't be
|
||||
* reached (CANNOT-CONNECT, which points at our side / the network) vs one
|
||||
* that CLOSED the sub. Includes how many events each slow relay did send, so
|
||||
* "relay is slow" and "we never connected" are easy to tell apart.
|
||||
*/
|
||||
private fun logSlowDrain(
|
||||
timeoutMs: Long,
|
||||
stalled: Set<NormalizedRelayUrl>,
|
||||
doneReasons: Map<NormalizedRelayUrl, String>,
|
||||
collected: List<Pair<NormalizedRelayUrl, Event>>,
|
||||
) {
|
||||
val eventsPer = collected.groupingBy { it.first }.eachCount()
|
||||
val cannot = doneReasons.filterValues { it.startsWith("cannot") }
|
||||
val closed = doneReasons.filterValues { it.startsWith("closed") }
|
||||
val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" }
|
||||
val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" }
|
||||
System.err.println(
|
||||
"[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
|
||||
(if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") +
|
||||
(if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [drain], but paginates every relay to completion via
|
||||
* [fetchAllPagesFromPool] instead of stopping at the first EOSE — so a query
|
||||
@@ -582,26 +742,17 @@ class Context(
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify [event]'s NIP-01 id+signature and, if valid, persist it
|
||||
* to [store]. Returns `true` when the event was accepted (and
|
||||
* therefore should be surfaced to callers). Persistence failures
|
||||
* (I/O errors, full disk) are logged but do not propagate.
|
||||
* Verify [event]'s NIP-01 id+signature and, if valid, persist it to [store].
|
||||
* Returns `true` when the event was accepted (and therefore should be surfaced
|
||||
* to callers). Persistence failures (I/O errors, full disk) are logged but do
|
||||
* not propagate; a UNIQUE-constraint rejection is normal and swallowed quietly.
|
||||
*
|
||||
* Every event-arrival path in the CLI funnels through this method
|
||||
* so that [store] is the authoritative cache of what Amy has seen.
|
||||
* Every event-arrival path in the CLI funnels through this so that [store] is
|
||||
* the authoritative cache of what Amy has seen. Delegates to the shared quartz
|
||||
* [verifyAndInsert] sink so the CLI and the GrapeRank crawler apply the exact
|
||||
* same verify-then-store policy.
|
||||
*/
|
||||
suspend fun verifyAndStore(event: Event): Boolean {
|
||||
if (!event.verify()) {
|
||||
System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature")
|
||||
return false
|
||||
}
|
||||
try {
|
||||
store.insert(event)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("[cli] store insert failed for ${event.id.take(8)}: ${t.message}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
suspend fun verifyAndStore(event: Event): Boolean = store.verifyAndInsert(event)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cache-first reads from [store]
|
||||
@@ -852,7 +1003,8 @@ class Context(
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
dataDir.saveRunState(state)
|
||||
// Nothing to persist for an anonymous run (no account dir to write into).
|
||||
if (!anonymous) dataDir.saveRunState(state)
|
||||
(signer as? NostrSignerRemote)?.let {
|
||||
try {
|
||||
it.closeSubscription()
|
||||
@@ -881,12 +1033,21 @@ class Context(
|
||||
*/
|
||||
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
|
||||
|
||||
/** Build a Context but require an identity to already exist — most commands can't run without one. */
|
||||
/**
|
||||
* Build a Context but require an account with a usable identity —
|
||||
* signing verbs can't run without one. Throws [IllegalArgumentException]
|
||||
* (→ exit 2) when no account was resolvable, carrying the "which
|
||||
* account?" hint from [DataDir.resolveOptional]; throws
|
||||
* [IllegalStateException] when the account exists but has no identity.
|
||||
*/
|
||||
fun open(dataDir: DataDir): Context {
|
||||
require(dataDir.hasAccount) {
|
||||
dataDir.noAccountDetail ?: "no account selected; pass --account <name> or run `amy use <name>`"
|
||||
}
|
||||
val identity =
|
||||
dataDir.loadIdentityOrNull()
|
||||
?: run {
|
||||
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
|
||||
System.err.println("No identity found at ${dataDir.identityFile}. Run `amy --account ${dataDir.accountName} init` first.")
|
||||
throw IllegalStateException("no identity")
|
||||
}
|
||||
return Context(
|
||||
@@ -895,5 +1056,24 @@ class Context(
|
||||
state = dataDir.loadRunState(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for read-only verbs: use the resolved account when one is
|
||||
* present, otherwise run anonymously (ephemeral key-less identity, no
|
||||
* persisted state). Lets `fetch`/`subscribe`/`count`/`publish`/`outbox`/
|
||||
* … query relays and the shared store with no account on disk — they
|
||||
* read fine, they just can't sign.
|
||||
*/
|
||||
fun openOrAnonymous(dataDir: DataDir): Context =
|
||||
if (dataDir.hasAccount && dataDir.identityExists()) {
|
||||
open(dataDir)
|
||||
} else {
|
||||
Context(
|
||||
dataDir = dataDir,
|
||||
identity = Identity.anonymous(),
|
||||
state = RunState(),
|
||||
anonymous = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,14 @@ import com.vitorpamplona.amethyst.cli.commands.FilterCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.FollowCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.GiftCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.GitCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.GrapeRankCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.GroupCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.InitCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.KeyCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.KindCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.LogoffCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand
|
||||
@@ -61,16 +63,20 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.UseCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.VerifyCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.WotCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ZapCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.route
|
||||
import com.vitorpamplona.amethyst.cli.secrets.SecretStore
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -104,6 +110,12 @@ fun main(argv: Array<String>) {
|
||||
// braces guard for invocations that bypass the launcher scripts.
|
||||
System.setProperty("java.awt.headless", "true")
|
||||
|
||||
// Quiet quartz's internal DEBUG chatter (relay auth, MLS restore, URL
|
||||
// rejection, throttle notices) by default so it doesn't drown a command's
|
||||
// own output; --verbose / -v restores full DEBUG. Set before dispatch so
|
||||
// even startup logging is gated.
|
||||
Log.minLevel = if (argv.any { it == "--verbose" || it == "-v" }) LogLevel.DEBUG else LogLevel.WARN
|
||||
|
||||
// Set output mode before dispatch so even argument-parsing errors
|
||||
// honour --json.
|
||||
if (argv.any { it == "--json" || it == "--json=true" }) {
|
||||
@@ -129,6 +141,16 @@ class AwaitTimeout(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
/**
|
||||
* Verbs that create, select, or delete the account/identity on disk. They
|
||||
* write to (or read) the per-account directory directly rather than through
|
||||
* `Context.open`, so they need a concrete account and must resolve strictly —
|
||||
* an accountless run has nowhere to put a new identity. Every other verb
|
||||
* resolves via [DataDir.resolveOptional] and either runs anonymously (reads)
|
||||
* or re-asserts the requirement inside `Context.open` (signing).
|
||||
*/
|
||||
private val STRICT_ACCOUNT_VERBS = setOf("init", "create", "login", "logoff", "whoami")
|
||||
|
||||
private suspend fun dispatch(argv: Array<String>): Int {
|
||||
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
|
||||
printUsage()
|
||||
@@ -150,6 +172,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value
|
||||
GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value
|
||||
GlobalFlag.JSON -> Output.mode = Output.Mode.JSON
|
||||
GlobalFlag.VERBOSE -> Unit // level already applied in main(); just strip it here
|
||||
null -> filteredArgs.add(a)
|
||||
}
|
||||
i += consumed.tokensConsumed
|
||||
@@ -170,6 +193,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
return UseCommand.run(tail)
|
||||
}
|
||||
|
||||
// `status` is a cross-account, read-only overview of everything on
|
||||
// disk under ~/.amy/. Like `use`, it must work regardless of how many
|
||||
// accounts exist (zero, one, or many), so it dispatches before account
|
||||
// resolution rather than through the single-account DataDir path.
|
||||
if (head == "status") {
|
||||
return StatusCommand.run(tail)
|
||||
}
|
||||
|
||||
// Stateless local primitives (nak-style army-knife verbs). They operate
|
||||
// purely on their arguments — no identity, no relays, no `~/.amy/` — so
|
||||
// they dispatch before account resolution and work with zero state.
|
||||
@@ -197,13 +228,33 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
return CashuMintCommands.dispatch(tail.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
// `offer info NOFFER` / `debit info NDEBIT` decode a CLINK pointer locally —
|
||||
// no network, no account. The rest of `offer`/`debit` operates on the account.
|
||||
if (head == "offer" && tail.firstOrNull() == "info") {
|
||||
return OfferCommands.info(tail.drop(1).toTypedArray())
|
||||
}
|
||||
if (head == "debit" && tail.firstOrNull() == "info") {
|
||||
return DebitCommands.info(tail.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
|
||||
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
|
||||
// Identity-lifecycle verbs create / select / delete the account itself, so
|
||||
// they need a concrete account and resolve strictly (helpful ambiguity
|
||||
// errors). Everything else resolves optionally: read-only verbs then run
|
||||
// anonymously when there is no account, while signing verbs re-assert the
|
||||
// requirement through `Context.open`.
|
||||
val dataDir =
|
||||
if (head in STRICT_ACCOUNT_VERBS) {
|
||||
DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
|
||||
} else {
|
||||
DataDir.resolveOptional(accountFlag = accountFlag, secrets = secrets)
|
||||
}
|
||||
|
||||
return when (head) {
|
||||
"init" -> InitCommands.init(dataDir, Args(tail))
|
||||
"create" -> CreateCommand.run(dataDir, tail)
|
||||
"login" -> LoginCommand.run(dataDir, tail)
|
||||
"logoff" -> LogoffCommand.run(dataDir, tail)
|
||||
"whoami" -> InitCommands.whoami(dataDir)
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
@@ -216,6 +267,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"store" -> StoreCommands.dispatch(dataDir, tail)
|
||||
"follow" -> FollowCommand.follow(dataDir, tail)
|
||||
"unfollow" -> FollowCommand.unfollow(dataDir, tail)
|
||||
"graperank" -> GrapeRankCommand.dispatch(dataDir, tail)
|
||||
"search" -> SearchCommand.dispatch(dataDir, tail)
|
||||
"zap" -> ZapCommand.dispatch(dataDir, tail)
|
||||
"offer" -> OfferCommands.dispatch(dataDir, tail)
|
||||
@@ -238,6 +290,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
|
||||
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
|
||||
"bunker" -> BunkerCommand.run(dataDir, tail)
|
||||
"wot" -> WotCommand.dispatch(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
@@ -267,11 +320,13 @@ private suspend fun marmotDispatch(
|
||||
private enum class GlobalFlag(
|
||||
val long: String,
|
||||
val takesValue: Boolean = true,
|
||||
val short: String? = null,
|
||||
) {
|
||||
ACCOUNT("--account"),
|
||||
SECRET_BACKEND("--secret-backend"),
|
||||
PASSPHRASE_FILE("--passphrase-file"),
|
||||
JSON("--json", takesValue = false),
|
||||
VERBOSE("--verbose", takesValue = false, short = "-v"),
|
||||
}
|
||||
|
||||
private data class ConsumedFlag(
|
||||
@@ -290,7 +345,7 @@ private fun extractGlobalFlag(
|
||||
idx: Int,
|
||||
): Pair<GlobalFlag?, ConsumedFlag> {
|
||||
for (flag in GlobalFlag.values()) {
|
||||
if (token == flag.long) {
|
||||
if (token == flag.long || token == flag.short) {
|
||||
return if (flag.takesValue) {
|
||||
flag to ConsumedFlag(argv.getOrNull(idx + 1), 2)
|
||||
} else {
|
||||
@@ -315,20 +370,27 @@ private fun printUsage() {
|
||||
| [--secret-backend auto|keychain|ncryptsec|plaintext]
|
||||
| [--passphrase-file PATH]
|
||||
| [--json]
|
||||
| [--verbose|-v]
|
||||
| <cmd> [args...]
|
||||
|
|
||||
|Account selection:
|
||||
| All state lives under ~/.amy/. Per-account directories
|
||||
| ~/.amy/<account>/ hold identity, cursors, MLS state, and
|
||||
| aliases; every observed Nostr event lands in the shared
|
||||
| ~/.amy/shared/events-store/. ACCOUNT must match
|
||||
| store under ~/.amy/shared/ (a SQLite `events.db` by default, or
|
||||
| the `events-store/` tree when AMY_STORE=fs). ACCOUNT must match
|
||||
| [a-zA-Z0-9_-]{1,64} (no spaces, no slashes).
|
||||
|
|
||||
| Resolution order:
|
||||
| 1. --account X if given.
|
||||
| 2. ~/.amy/current marker (set by `amy use X`).
|
||||
| 3. Sole subdirectory of ~/.amy/ other than shared/.
|
||||
| 4. Error — disambiguate with --account or `amy use`.
|
||||
| 4. Read-only verbs (fetch, subscribe, count, publish, outbox,
|
||||
| search, sync, store, profile/git/podcast reads, nsite/napplet
|
||||
| fetch, decode/encode/… primitives, offer/debit info) run
|
||||
| ANONYMOUSLY — they query relays and the shared store with no
|
||||
| account, they just can't sign. Signing verbs error here:
|
||||
| disambiguate with --account or `amy use`.
|
||||
|
|
||||
| Test harnesses isolate by overriding ${'$'}HOME for the amy
|
||||
| subprocess (`HOME=/tmp/run.123 amy --account alice ...`).
|
||||
@@ -336,6 +398,9 @@ private fun printUsage() {
|
||||
| use NAME pin NAME as the active account
|
||||
| use --clear remove the pin
|
||||
| use print current pin + available accounts
|
||||
| status read-only overview of every account, signer
|
||||
| type, local Marmot/Cashu state, and the shared
|
||||
| event store (no keychain prompt, no network)
|
||||
|
|
||||
|Output:
|
||||
| Default: human-readable text on stdout.
|
||||
@@ -382,6 +447,9 @@ private fun printUsage() {
|
||||
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
|
||||
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://)
|
||||
| whoami print current identity
|
||||
| logoff [--yes] [--keep-events] log off: delete this account's key, per-account state,
|
||||
| and its events in the shared store (--keep-events skips the
|
||||
| cache purge). Requires --yes; without it, prints a dry run.
|
||||
|
|
||||
|Remote signing (NIP-46):
|
||||
| bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a
|
||||
@@ -526,6 +594,42 @@ private fun printUsage() {
|
||||
| unfollow USER [--timeout SECS] remove USER from your contact list
|
||||
| (USER: npub|nprofile|hex|name@domain)
|
||||
|
|
||||
|Web of Trust (GrapeRank):
|
||||
| graperank [OBSERVER] compute subjective trust scores (0..1) for every
|
||||
| [--limit N] [--min-score X] user reachable in the follow/mute/report graph.
|
||||
| [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox
|
||||
| [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every
|
||||
| [--offline] [--timeout SECS] discovered user has been checked (no user cap;
|
||||
| [--diagnose] --max-hops bounds follow distance, e.g. 8;
|
||||
| --diagnose dumps per-relay telemetry: outcome
|
||||
| mix, yield, latency, and a LIVE/DEAD + limits
|
||||
| classification table of every relay contacted).
|
||||
| [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default:
|
||||
| [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local
|
||||
| store only. --publish reconciles NIP-85 kind:30382
|
||||
| cards signed by a per-observer service key: sends
|
||||
| new/changed ranks >= --min-rank (default 2), skips
|
||||
| unchanged, and retracts (kind:5) any card whose
|
||||
| target left the graph or fell below the cutoff.
|
||||
| graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
|
||||
| [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all
|
||||
| [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write
|
||||
| [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per
|
||||
| relay scoped to its authors. Bidirectional by default;
|
||||
| the deletion settle downloads the relay's kind:5 when
|
||||
| an uploaded record was rejected (author retracted it).
|
||||
| Falls back to a full paged download when a relay
|
||||
| can't reconcile via negentropy.
|
||||
| graperank operator [status|relay <url>… manage the machine's operator keys (~/.amy/operator/,
|
||||
| |providers] independent of accounts): relay sets where cards +
|
||||
| retractions publish; status shows master + relays;
|
||||
| providers lists observer -> service-pubkey.
|
||||
| graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so
|
||||
| [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the
|
||||
| [--private] 30382:rank provider at your first outbox relay).
|
||||
| graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers
|
||||
| [--timeout SECS] (default: active account).
|
||||
|
|
||||
|Zaps (NIP-57):
|
||||
| zap user USER SATS build a profile zap-request, fetch a BOLT11
|
||||
| [--comment X] [--anon|--private] invoice from the recipient's LN service
|
||||
@@ -617,11 +721,15 @@ private fun printUsage() {
|
||||
|
|
||||
| marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive)
|
||||
|
|
||||
|Local event store (`<data-dir>/events-store/`):
|
||||
| store stat event count, kind histogram, disk usage
|
||||
|Local event store (shared, under `<data-dir>/shared/`):
|
||||
| Backend selected by AMY_STORE: sqlite (default; `shared/events.db`)
|
||||
| or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is
|
||||
| far more compact at scale — the FS tree spends one file per index
|
||||
| posting, so large crawls balloon on disk.
|
||||
| store stat event count + disk usage (kind histogram/mtime on fs)
|
||||
| store sweep-expired delete events past their NIP-40 expiration
|
||||
| store scrub rebuild idx/ from canonical events (after edits / crashes)
|
||||
| store compact drop dangling idx entries (canonical gone)
|
||||
| store scrub fs: rebuild idx/ from canonical events; sqlite: no-op
|
||||
| store compact fs: drop dangling idx entries; sqlite: VACUUM
|
||||
| store reindex-fts rebuild the NIP-50 search index (after a searchable-kinds change)
|
||||
""".trimMargin(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import com.vitorpamplona.amethyst.cli.secrets.SecretStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Operator-level signing keys for GrapeRank trusted-assertion publishing.
|
||||
*
|
||||
* A machine holds ONE operator master seed, **independent of any amy account**,
|
||||
* stored under `~/.amy/operator/` through the same [SecretStore] backend the
|
||||
* accounts use (OS keychain / NIP-49 ncryptsec / plaintext). From it we
|
||||
* deterministically derive ONE service key per observer:
|
||||
*
|
||||
* ```
|
||||
* serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerHex ‖ counter)
|
||||
* ```
|
||||
*
|
||||
* That service key signs the observer's kind:30382 rank cards (and their kind:5
|
||||
* retractions). Deterministic derivation buys two things:
|
||||
* - **Stable identity** — the same observer always maps to the same key, so
|
||||
* re-signing a card *replaces* the prior one (kind:30382 is addressable)
|
||||
* instead of orphaning it and spamming clients with duplicates.
|
||||
* - **One-secret backup** — back up only the master seed; every service key is
|
||||
* re-derivable even if the [providers] manifest is lost.
|
||||
*
|
||||
* The manifest (`~/.amy/operator/operator.json`) records the master pubkey, the
|
||||
* configured operator relay(s), and the observer → provider-pubkey mapping. Only
|
||||
* the master itself is a secret; it rides the [SecretStore] descriptor, so the
|
||||
* manifest holds public data.
|
||||
*/
|
||||
class OperatorKeys(
|
||||
amyHome: File,
|
||||
private val secrets: SecretStore,
|
||||
) {
|
||||
private val dir = File(amyHome, DIR_NAME)
|
||||
private val configFile = File(dir, CONFIG_NAME)
|
||||
|
||||
data class ProviderRecord(
|
||||
val providerPubKey: HexKey = "",
|
||||
)
|
||||
|
||||
data class Config(
|
||||
val masterPubKey: HexKey = "",
|
||||
val master: IdentitySecret? = null,
|
||||
val relays: List<String> = emptyList(),
|
||||
val providers: MutableMap<HexKey, ProviderRecord> = mutableMapOf(),
|
||||
)
|
||||
|
||||
private fun load(): Config? = if (configFile.exists()) Output.mapper.readValue<Config>(configFile.readText()) else null
|
||||
|
||||
private fun save(cfg: Config) {
|
||||
SecureFileIO.secureMkdirs(dir)
|
||||
configFile.writeText(Output.mapper.writeValueAsString(cfg))
|
||||
SecureFileIO.tighten(configFile)
|
||||
}
|
||||
|
||||
/** True once an operator master exists on this machine. */
|
||||
fun exists(): Boolean = load()?.master != null
|
||||
|
||||
/** Load (or, on first use, create + persist) the operator master private key. */
|
||||
private fun masterPriv(): ByteArray {
|
||||
load()?.master?.let { return secrets.resolve(it).hexToByteArray() }
|
||||
val kp = KeyPair()
|
||||
val pub = kp.pubKey.toHexKey()
|
||||
val secret = secrets.store(pub, kp.privKey!!.toHexKey())
|
||||
save(Config(masterPubKey = pub, master = secret))
|
||||
System.err.println("[operator] created operator master ${pub.take(8)}… at ${configFile.path}")
|
||||
return kp.privKey!!
|
||||
}
|
||||
|
||||
/** The operator master pubkey, creating the master on first use. */
|
||||
fun masterPubKey(): HexKey {
|
||||
masterPriv()
|
||||
return load()!!.masterPubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic service key for [observerHex], recording the observer →
|
||||
* provider-pubkey mapping in the manifest. The counter loop only ever runs
|
||||
* once in practice — it's a guard for the ~2^-128 chance a sha256 output isn't
|
||||
* a valid secp256k1 scalar.
|
||||
*/
|
||||
fun serviceKey(observerHex: HexKey): KeyPair {
|
||||
val master = masterPriv()
|
||||
var counter = 0
|
||||
while (true) {
|
||||
val material = master + "$DERIVATION_LABEL$observerHex:$counter".encodeToByteArray()
|
||||
val kp = runCatching { KeyPair(privKey = sha256(material)) }.getOrNull()
|
||||
if (kp?.privKey != null) {
|
||||
recordProvider(observerHex, kp.pubKey.toHexKey())
|
||||
return kp
|
||||
}
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The machine's dedicated NIP-66 relay-monitor identity, derived once from the
|
||||
* operator master (independent of any amy account). Unlike [serviceKey] this is
|
||||
* NOT per-observer — the machine publishes relay-reachability (kind:30166) under a
|
||||
* single, stable monitor pubkey, so a re-probe *replaces* the prior 30166 for a
|
||||
* relay instead of orphaning it. Re-derivable from the one master seed alone.
|
||||
*/
|
||||
fun monitorKey(): KeyPair {
|
||||
val master = masterPriv()
|
||||
var counter = 0
|
||||
while (true) {
|
||||
val material = master + "$MONITOR_LABEL$counter".encodeToByteArray()
|
||||
val kp = runCatching { KeyPair(privKey = sha256(material)) }.getOrNull()
|
||||
if (kp?.privKey != null) return kp
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordProvider(
|
||||
observerHex: HexKey,
|
||||
providerPubKey: HexKey,
|
||||
) {
|
||||
val cfg = load() ?: return
|
||||
if (cfg.providers[observerHex]?.providerPubKey == providerPubKey) return
|
||||
cfg.providers[observerHex] = ProviderRecord(providerPubKey)
|
||||
save(cfg)
|
||||
}
|
||||
|
||||
/** Relays the operator publishes all its 30382 cards + retractions to. */
|
||||
fun operatorRelays(): Set<NormalizedRelayUrl> =
|
||||
load()
|
||||
?.relays
|
||||
.orEmpty()
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet()
|
||||
|
||||
fun setRelays(urls: List<String>) {
|
||||
masterPriv() // make sure the config (and master) exists first
|
||||
save(load()!!.copy(relays = urls))
|
||||
}
|
||||
|
||||
fun providers(): Map<HexKey, ProviderRecord> = load()?.providers.orEmpty()
|
||||
|
||||
companion object {
|
||||
private const val DIR_NAME = "operator"
|
||||
private const val CONFIG_NAME = "operator.json"
|
||||
private const val DERIVATION_LABEL = "graperank-provider:"
|
||||
private const val MONITOR_LABEL = "relay-monitor:"
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,12 @@ object Output {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `bad_args` failure for any command that takes a relay-URL
|
||||
* argument, so every command names the offending input the same way.
|
||||
*/
|
||||
fun invalidRelayUrl(raw: String): Int = error("bad_args", "invalid relay url: $raw")
|
||||
|
||||
private fun renderText(value: Any?): String {
|
||||
val color = Ansi.forStream(isStderr = false)
|
||||
val out = StringBuilder()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Client-wide tally of the relay feedback the crawl would otherwise never see:
|
||||
* `NOTICE` frames, `CLOSED` reasons (`auth-required` / `rate-limited` /
|
||||
* `restricted` / …), and NIP-42 `AUTH` challenges. Registered as a
|
||||
* [RelayConnectionListener] on the shared client, so every incoming message
|
||||
* during a run is counted and a REQ failure can be explained instead of
|
||||
* guessed at.
|
||||
*
|
||||
* Callbacks fire on the per-relay socket threads, so all state is concurrent.
|
||||
*/
|
||||
class RelayDiagnostics : RelayConnectionListener {
|
||||
private val closedByReason = ConcurrentHashMap<String, AtomicLong>()
|
||||
private val noticeSamples = ConcurrentHashMap<String, AtomicLong>()
|
||||
private val authChallenges = AtomicLong()
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
// CLOSED reasons follow the NIP-01 machine-readable "word: text"
|
||||
// convention, so the prefix categorises the failure.
|
||||
is ClosedMessage -> bump(closedByReason, prefix(msg.message))
|
||||
// NOTICE is free-form; keep the (truncated) text so recurring
|
||||
// relay complaints ("too many concurrent REQs", …) are visible.
|
||||
is NoticeMessage -> if (noticeSamples.size < MAX_DISTINCT_NOTICES) bump(noticeSamples, msg.message.trim().take(80))
|
||||
is AuthMessage -> authChallenges.incrementAndGet()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun bump(
|
||||
map: ConcurrentHashMap<String, AtomicLong>,
|
||||
key: String,
|
||||
) {
|
||||
map.getOrPut(key) { AtomicLong() }.incrementAndGet()
|
||||
}
|
||||
|
||||
/** The NIP-01 machine-readable prefix (`word` before `:`), or `other`. */
|
||||
private fun prefix(message: String): String {
|
||||
val head = message.substringBefore(':').trim().lowercase()
|
||||
return head.ifEmpty { "other" }.take(24)
|
||||
}
|
||||
|
||||
fun hadFeedback(): Boolean = authChallenges.get() > 0 || closedByReason.isNotEmpty() || noticeSamples.isNotEmpty()
|
||||
|
||||
/** JSON-friendly summary for the command output. */
|
||||
fun snapshot(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"auth_challenges" to authChallenges.get(),
|
||||
"closed_by_reason" to closedByReason.entries.associate { it.key to it.value.get() }.toSortedMap(),
|
||||
"notices" to noticeSamples.values.sumOf { it.get() },
|
||||
"notice_top" to
|
||||
noticeSamples.entries
|
||||
.sortedByDescending { it.value.get() }
|
||||
.take(TOP_NOTICES)
|
||||
.map { "${it.key} (${it.value.get()})" },
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAX_DISTINCT_NOTICES = 500
|
||||
private const val TOP_NOTICES = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import kotlin.io.path.Path
|
||||
|
||||
/** On-disk backend for the shared event store. */
|
||||
enum class StoreBackend {
|
||||
/**
|
||||
* Single SQLite database file at [DataDir.eventsDbFile]. Postings live
|
||||
* in shared B-tree pages, so an event's kind/author/tag indexes cost a
|
||||
* handful of rows — not one 4 KB-block file each, the way the FS store
|
||||
* lays them out. For crawl-scale corpora (hundreds of thousands of
|
||||
* follow lists) this is several times smaller on disk and the default.
|
||||
*/
|
||||
SQLITE,
|
||||
|
||||
/**
|
||||
* Filesystem tree at [DataDir.eventsDir] — one pretty-printed JSON file
|
||||
* per event plus one file per index posting. Human-inspectable with
|
||||
* `cat`/`jq`/`git diff`, but every posting rounds up to a filesystem
|
||||
* block, so a large corpus balloons. Opt in with `AMY_STORE=fs`.
|
||||
*/
|
||||
FS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses and opens the event-store backend for `amy`. The backend is
|
||||
* selected by the `AMY_STORE` environment variable and defaults to
|
||||
* [StoreBackend.SQLITE]; set `AMY_STORE=fs` for the legacy filesystem
|
||||
* store. Both backends implement [IEventStore], so every command works
|
||||
* unchanged regardless of the choice — the only user-visible difference
|
||||
* is where bytes land ([DataDir.eventsDbFile] vs [DataDir.eventsDir]) and
|
||||
* how much disk they take.
|
||||
*/
|
||||
object StoreFactory {
|
||||
const val ENV = "AMY_STORE"
|
||||
|
||||
/** Resolve the configured backend. Unrecognised values fall back to the default. */
|
||||
fun backend(): StoreBackend =
|
||||
when (System.getenv(ENV)?.trim()?.lowercase()) {
|
||||
"fs", "file", "files", "filesystem" -> StoreBackend.FS
|
||||
else -> StoreBackend.SQLITE
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the store for [dataDir] using the configured [backend]. Events
|
||||
* are written pretty-printed on the FS backend so the on-disk JSON stays
|
||||
* inspection-friendly; the SQLite backend stores the compact NIP-01
|
||||
* form internally. Neither is re-used for signature checks (verification
|
||||
* always re-canonicalises), so the stored representation is purely an
|
||||
* implementation detail. Callers own [IEventStore.close].
|
||||
*/
|
||||
fun open(dataDir: DataDir): IEventStore =
|
||||
when (backend()) {
|
||||
StoreBackend.SQLITE -> {
|
||||
// BundledSQLiteDriver won't create parent directories.
|
||||
dataDir.eventsDbFile.parentFile?.mkdirs()
|
||||
EventStore(dbName = dataDir.eventsDbFile.absolutePath, relay = null)
|
||||
}
|
||||
StoreBackend.FS ->
|
||||
FsEventStore(
|
||||
root = Path(dataDir.eventsDir.absolutePath),
|
||||
eventToJson = JacksonMapper::toJsonPretty,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* Read-only introspection of a file-backed Nostr event store on disk.
|
||||
*
|
||||
* Pure filesystem walk — no relay traffic, no writer lock, no [Context].
|
||||
* Shared by `amy store stat` (full detail) and `amy status` (a compact
|
||||
* roll-up alongside the account overview).
|
||||
*/
|
||||
data class StoreStats(
|
||||
val events: Long,
|
||||
/** Per-kind event counts derived from `idx/kind/<k>/`, sorted by kind string. */
|
||||
val byKind: Map<String, Long>,
|
||||
val diskBytes: Long,
|
||||
/** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */
|
||||
val oldestAt: Long?,
|
||||
val newestAt: Long?,
|
||||
val root: Path,
|
||||
) {
|
||||
val distinctKinds: Int get() = byKind.size
|
||||
|
||||
companion object {
|
||||
/** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */
|
||||
fun of(storeRoot: Path): StoreStats {
|
||||
if (!storeRoot.exists()) {
|
||||
return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath())
|
||||
}
|
||||
|
||||
val eventsRoot = storeRoot.resolve("events")
|
||||
var count = 0L
|
||||
var oldest: Long? = null
|
||||
var newest: Long? = null
|
||||
if (Files.isDirectory(eventsRoot)) {
|
||||
Files.walk(eventsRoot).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
if (!p.fileName.toString().endsWith(".json")) continue
|
||||
count++
|
||||
val mt =
|
||||
try {
|
||||
Files.getLastModifiedTime(p).to(TimeUnit.SECONDS)
|
||||
} catch (_: IOException) {
|
||||
continue
|
||||
}
|
||||
val o = oldest
|
||||
if (o == null || mt < o) oldest = mt
|
||||
val n = newest
|
||||
if (n == null || mt > n) newest = mt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Histogram from idx/kind/<k>/ — for a healthy store this is
|
||||
// exactly one entry per (kind, event), so summing matches `count`.
|
||||
// Mismatch points at index drift; run `amy store scrub` to fix.
|
||||
val kindRoot = storeRoot.resolve("idx/kind")
|
||||
val byKind = sortedMapOf<String, Long>()
|
||||
if (Files.isDirectory(kindRoot)) {
|
||||
Files.list(kindRoot).use { stream ->
|
||||
for (kindDir in stream) {
|
||||
if (!Files.isDirectory(kindDir)) continue
|
||||
val n = Files.list(kindDir).use { it.count() }
|
||||
byKind[kindDir.fileName.toString()] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StoreStats(
|
||||
events = count,
|
||||
byKind = byKind,
|
||||
diskBytes = walkSize(storeRoot),
|
||||
oldestAt = oldest,
|
||||
newestAt = newest,
|
||||
root = storeRoot.toAbsolutePath(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun walkSize(root: Path): Long {
|
||||
if (!Files.exists(root)) return 0L
|
||||
var total = 0L
|
||||
Files.walk(root).use { stream ->
|
||||
for (p in stream) {
|
||||
if (!Files.isRegularFile(p)) continue
|
||||
total +=
|
||||
try {
|
||||
Files.size(p)
|
||||
} catch (_: IOException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ object AdminCommand {
|
||||
val args = Args(rest)
|
||||
val relayArg = args.positionalOrNull(0) ?: return Output.error("bad_args", "usage: admin RELAY METHOD [args]")
|
||||
val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods")
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.error("bad_args", "invalid relay url: $relayArg")
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg)
|
||||
val p2 = args.positionalOrNull(2)
|
||||
val reason = args.flag("reason")
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ object BlossomCommands {
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
Context.open(dataDir).use { _ ->
|
||||
// Read-only HEAD probe — no auth, so it runs anonymously without an account.
|
||||
Context.openOrAnonymous(dataDir).use { _ ->
|
||||
val http = OkHttpClient()
|
||||
val results =
|
||||
hashes.map { hash ->
|
||||
@@ -188,7 +189,8 @@ object BlossomCommands {
|
||||
val server = args.flag("server")
|
||||
val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Public download — no auth, so it runs anonymously without an account.
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
val bytes =
|
||||
BlossomClient().download(url)
|
||||
?: return Output.error("not_found", "server returned no blob for $url")
|
||||
|
||||
@@ -47,7 +47,7 @@ object CountCommand {
|
||||
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
|
||||
@@ -60,7 +60,7 @@ object DebitCommands {
|
||||
)
|
||||
|
||||
/** Local decode of an `ndebit` pointer — no network, no account needed. */
|
||||
private fun info(rest: Array<String>): Int {
|
||||
internal fun info(rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val debit =
|
||||
ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit
|
||||
|
||||
@@ -61,7 +61,10 @@ object FeedCommand {
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account. `--author` /
|
||||
// `--following` still work; the bare "self" feed just has no self to
|
||||
// resolve without an account.
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val (authors, mode) =
|
||||
|
||||
@@ -94,7 +94,7 @@ object FetchCommand {
|
||||
val filter = RawEventSupport.buildFilter(args).copy(limit = effectiveLimit)
|
||||
val paginate = args.bool("paginate") || args.bool("all")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
@@ -148,7 +148,7 @@ object FetchCommand {
|
||||
timeoutMs: Long,
|
||||
): Int {
|
||||
val code = codeArg.removePrefix("nostr:")
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
var filter: Filter
|
||||
|
||||
@@ -113,7 +113,9 @@ object GitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (defaults to
|
||||
// the anonymous key, so pass a USER to list someone's repos).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
@@ -143,7 +145,7 @@ object GitCommands {
|
||||
return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
|
||||
Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content))
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
/*
|
||||
* 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.Output
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.quartz.experimental.graperank.GrapeRank
|
||||
import com.vitorpamplona.quartz.experimental.graperank.GrapeRankCrawler
|
||||
import com.vitorpamplona.quartz.experimental.graperank.GrapeRankParams
|
||||
import com.vitorpamplona.quartz.experimental.graperank.GrapeRankPublisher
|
||||
import com.vitorpamplona.quartz.experimental.graperank.GrapeRankUpdater
|
||||
import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* `amy graperank [OBSERVER] [flags]` — compute GrapeRank web-of-trust scores.
|
||||
*
|
||||
* GrapeRank assigns every user reachable in the follow/mute/report graph a
|
||||
* subjective trust score in `[0, 1]` from the observer's point of view (the
|
||||
* observer has full self-trust). It crawls the follow graph outward using the
|
||||
* outbox model — each user's kind:10002 write relays are located first, then
|
||||
* their kind:3 / kind:10000 / kind:1984 events are fetched from *their own*
|
||||
* relays. The crawl is exhaustive: it keeps going, with no user cap, until every
|
||||
* discovered user's outbox has been checked and their contact list pulled (an
|
||||
* unreachable outbox is retried a few times), then runs the scoring engine in
|
||||
* `commons/wot`.
|
||||
*
|
||||
* Prints a ranked list (text, or one JSON object under `--json`). With
|
||||
* `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent`
|
||||
* trusted assertions (one per scored user, `rank = round(score*100)`).
|
||||
*
|
||||
* The crawl and the computation are separable, because the crawl persists every
|
||||
* event it fetches to the store and the score is a pure function over it:
|
||||
* - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's
|
||||
* kind 3/10000/1984/10002 into the local store (aliased as the former `sync`).
|
||||
* Idempotent and cumulative, so run it a few times to make sure everything is
|
||||
* loaded. Scores nothing.
|
||||
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store
|
||||
* and score (same as bare `--offline`). Instant and param-tunable; repeat with
|
||||
* different `--rigor`/`--attenuation`/cutoffs without re-crawling.
|
||||
* - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score.
|
||||
*
|
||||
* Sub-verbs complete the NIP-85 provider experience — the discovery layer that
|
||||
* lets clients find and consume those assertions:
|
||||
* - `amy graperank register` — advertise a `30382:rank` provider in the
|
||||
* account's kind:10040 [TrustProviderListEvent] (defaults to self, so a
|
||||
* provider publishing ranks announces where to find them).
|
||||
* - `amy graperank providers [USER]` — list a user's trusted providers.
|
||||
*/
|
||||
object GrapeRankCommand {
|
||||
// Broad, big general relays that carry kind:10002 for many users, added to the
|
||||
// crawler's discovery set to raise the odds of resolving a stranger's outbox.
|
||||
private val EXTRA_DISCOVERY_RELAYS: Set<NormalizedRelayUrl> =
|
||||
listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.snort.social",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr.land",
|
||||
"wss://eden.nostr.land",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
// Network-wide aggregators that scrape and hold kind:3 for users whose own
|
||||
// outbox lacks it. The crawler queries these for a straggler's CONTENT (kind:3),
|
||||
// not just their kind:10002 relay list. Measured on observer 460c25e6, the distinct
|
||||
// missing authors whose kind:3 each holds: kindpag.es 369, yabu 126, oxtr.dev 76,
|
||||
// nos.lol 72, ditto 56, nostr1 29, momostr 11, mostr 3. So beyond the profile
|
||||
// indexers (kindpag/purplepag/coracle/yabu/nostr1) and the ActivityPub bridges
|
||||
// (ditto/momostr/mostr, which host bridged users' lists), two big general relays --
|
||||
// nostr.oxtr.dev and nos.lol -- carry ~150 more that no indexer has.
|
||||
private val CONTENT_AGGREGATOR_RELAYS: Set<NormalizedRelayUrl> =
|
||||
DefaultIndexerRelayList +
|
||||
listOf(
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.momostr.pink",
|
||||
"wss://relay.mostr.pub",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nos.lol",
|
||||
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
private const val PROBE_TIMEOUT_MS = 2000
|
||||
|
||||
// The probe does BLOCKING DNS + TCP connect, and dead-domain DNS lookups can hang
|
||||
// far past the connect timeout. On the shared Dispatchers.IO those hanging lookups
|
||||
// starve the crawl's own IO — measured +462s on the finishing drain at hop-3. Run
|
||||
// them on a dedicated, isolated daemon pool instead so the crawl's IO is untouched.
|
||||
private val probeDispatcher =
|
||||
Executors
|
||||
.newFixedThreadPool(128) { r -> Thread(r, "relay-probe").apply { isDaemon = true } }
|
||||
.asCoroutineDispatcher()
|
||||
|
||||
/**
|
||||
* Cheap reachability pre-probe: a raw TCP connect (one round trip) with a tight
|
||||
* timeout. Returns false only when the port won't even accept a socket — a dead
|
||||
* dropper, refusal, or unroutable/onion/LAN host — which the crawler drops into
|
||||
* deadHosts before the WS path pays its 7s connectTimeout. A busy-but-alive relay
|
||||
* accepts the SYN instantly at the kernel level (its slowness is at the app layer),
|
||||
* so it passes here and is left for the real WS attempt. Unparseable host → true,
|
||||
* so an odd URL is never culled on a parse quirk — let the WS decide.
|
||||
*/
|
||||
private suspend fun tcpReachable(relay: NormalizedRelayUrl): Boolean =
|
||||
withContext(probeDispatcher) {
|
||||
val hostPort = relayHostPort(relay) ?: return@withContext true
|
||||
try {
|
||||
Socket().use { it.connect(InetSocketAddress(hostPort.first, hostPort.second), PROBE_TIMEOUT_MS) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun relayHostPort(relay: NormalizedRelayUrl): Pair<String, Int>? =
|
||||
try {
|
||||
val uri = URI(relay.url)
|
||||
val host = uri.host ?: return null
|
||||
val port =
|
||||
if (uri.port > 0) {
|
||||
uri.port
|
||||
} else if (relay.url.startsWith("wss://", ignoreCase = true)) {
|
||||
443
|
||||
} else {
|
||||
80
|
||||
}
|
||||
host to port
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
// Sub-verbs are explicit words; anything else (npub / hex / nprofile /
|
||||
// NIP-05, or nothing) is the OBSERVER positional for a score computation.
|
||||
when (tail.firstOrNull()) {
|
||||
"register" -> register(dataDir, tail.drop(1).toTypedArray())
|
||||
"providers" -> providers(dataDir, tail.drop(1).toTypedArray())
|
||||
"operator" -> operator(dataDir, tail.drop(1).toTypedArray())
|
||||
// `sync` is the pre-rename name kept as a back-compat alias; `crawl` is
|
||||
// canonical (disambiguates from negentropy `amy sync` / `graperank update`).
|
||||
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
|
||||
"update" -> update(dataDir, tail.drop(1).toTypedArray())
|
||||
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
|
||||
else -> run(dataDir, tail)
|
||||
}
|
||||
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
forceOffline: Boolean = false,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val observerArg = args.positionalOrNull(0)
|
||||
// Crawl to full convergence by default (every reachable user's outbox
|
||||
// checked). --max-rounds is only a safety backstop; --max-hops bounds the
|
||||
// follow-graph distance from the observer that we crawl (Brainstorm uses 8).
|
||||
val limit = args.intFlag("limit", 100)
|
||||
val minScore = args.flag("min-score")?.toDoubleOrNull() ?: 0.0
|
||||
// `graperank score` forces the local (no-network) path; `--offline` does the
|
||||
// same on the bare command. Either way we build + score from the store only.
|
||||
val offline = forceOffline || args.bool("offline")
|
||||
// Crawl tuning (--max-rounds/--max-hops/--timeout/--diagnose/--drain-concurrency)
|
||||
// is read straight from args by [newCrawler]; only these two are surfaced in
|
||||
// the result JSON, so keep local copies for that.
|
||||
val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000
|
||||
val insertBatch = args.intFlag("insert-batch", 500)
|
||||
val doPublish = args.bool("publish")
|
||||
// Publish cutoff: only cards with rank >= this are published; existing
|
||||
// cards for targets below it (or gone from the graph) are retracted. Rank
|
||||
// is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail.
|
||||
val minRank = args.intFlag("min-rank", 2)
|
||||
val publishLimit = args.intFlag("publish-limit", 500)
|
||||
val publishRelaysArg = args.flag("publish-relay")
|
||||
// Benchmark: build + sign one kind:30382 card per scored user (rank >=
|
||||
// --min-rank) with a throwaway key and time it, WITHOUT publishing.
|
||||
// Measures the id-hash + Schnorr-sign cost of emitting the full card set.
|
||||
val benchSign = args.bool("bench-sign")
|
||||
|
||||
val params =
|
||||
GrapeRankParams(
|
||||
attenuation = args.flag("attenuation")?.toDoubleOrNull() ?: GrapeRankParams().attenuation,
|
||||
rigor = args.flag("rigor")?.toDoubleOrNull() ?: GrapeRankParams().rigor,
|
||||
)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
|
||||
// Contact lists stream straight into a compact int-CSR structure as the
|
||||
// crawl finds them and the Event is discarded, so the whole network fits
|
||||
// in memory without holding millions of kind:3 objects.
|
||||
val builder = TrustGraphBuilder()
|
||||
var contactListsFed = 0
|
||||
// Wall time to read + deserialize the contact lists out of the store
|
||||
// (offline path only; online streams them in during the crawl).
|
||||
var storeLoadMs: Long? = null
|
||||
// Crawl telemetry (online path only): rounds, relays contacted, the
|
||||
// per-hop histogram, and the network-bound download time that dominates a
|
||||
// from-scratch run. Null on the offline path.
|
||||
var crawlStats: GrapeRankCrawler.Stats? = null
|
||||
|
||||
if (!offline) {
|
||||
val stats = newCrawler(ctx, args).crawl(observer, builder)
|
||||
crawlStats = stats
|
||||
contactListsFed = stats.contactListsFed
|
||||
flushReachability(ctx, args, stats)
|
||||
reportRelayFeedback(ctx)
|
||||
} else {
|
||||
// Offline: stream contact lists from the local store into the graph.
|
||||
val loadStart = System.nanoTime()
|
||||
for (event in ctx.store.query<Event>(Filter(kinds = listOf(ContactListEvent.KIND)))) {
|
||||
if (event is ContactListEvent) {
|
||||
builder.addFollows(event.pubKey, event.verifiedFollowKeySet())
|
||||
contactListsFed++
|
||||
}
|
||||
}
|
||||
storeLoadMs = (System.nanoTime() - loadStart) / 1_000_000
|
||||
System.err.println("[graperank] offline: $contactListsFed contact lists from local store in $storeLoadMs ms")
|
||||
}
|
||||
|
||||
// Mutes + reports come from the store (both paths). Far fewer than contact
|
||||
// lists, so materialising them is cheap.
|
||||
for (event in ctx.store.query<Event>(Filter(kinds = listOf(MuteListEvent.KIND)))) {
|
||||
if (event is MuteListEvent) builder.addMutes(event.pubKey, event.linkedPubKeys())
|
||||
}
|
||||
val reportsDeleted = materializeReports(ctx, builder)
|
||||
|
||||
val buildStart = System.nanoTime()
|
||||
val graph = builder.build()
|
||||
val buildMs = (System.nanoTime() - buildStart) / 1_000_000
|
||||
System.err.println("[graperank] graph built: ${graph.nodeCount} users, ${graph.edgeCount()} edges in $buildMs ms; scoring…")
|
||||
|
||||
// Live scoring progress: fires once per Gauss-Seidel sweep with the
|
||||
// running node-update count and how many nodes still moved more than the
|
||||
// convergence delta this sweep — that second number trends to 0, so a
|
||||
// large graph shows convergence instead of hanging silently.
|
||||
val scoreStart = System.nanoTime()
|
||||
var sweeps = 0
|
||||
val scores =
|
||||
GrapeRank(params).compute(graph, observer) { visited, stillMoving ->
|
||||
sweeps++
|
||||
System.err.println("[graperank] scoring sweep $sweeps: $visited node-updates, $stillMoving still moving")
|
||||
}
|
||||
|
||||
fun rankOf(score: Double) = (score * 100).roundToInt()
|
||||
|
||||
val observerId = graph.idOf(observer)
|
||||
// Reachable users with positive trust at or above --min-score, high→low.
|
||||
val rankedIds = ArrayList<Int>()
|
||||
for (id in 0 until graph.nodeCount) {
|
||||
if (id != observerId && scores[id] > 0.0 && scores[id] >= minScore) rankedIds.add(id)
|
||||
}
|
||||
rankedIds.sortByDescending { scores[it] }
|
||||
val scoringMs = (System.nanoTime() - scoreStart) / 1_000_000
|
||||
System.err.println("[graperank] scored ${rankedIds.size} users in $scoringMs ms")
|
||||
|
||||
val hopHistogram = crawlStats?.hopHistogram.orEmpty()
|
||||
val result =
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"crawl_rounds" to (crawlStats?.rounds ?: 0),
|
||||
"relays_contacted" to (crawlStats?.relaysContacted ?: 0),
|
||||
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
|
||||
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
|
||||
"max_hop_reached" to (hopHistogram.keys.maxOrNull() ?: 0),
|
||||
"users_by_hop" to hopHistogram.mapKeys { it.key.toString() },
|
||||
"contact_lists_by_hop" to crawlStats?.contactsFedByHop.orEmpty().mapKeys { it.key.toString() },
|
||||
"graph_users" to graph.nodeCount,
|
||||
"graph_edges" to graph.edgeCount(),
|
||||
"reports_deleted" to reportsDeleted,
|
||||
"users_scored" to rankedIds.size,
|
||||
"download_ms" to crawlStats?.downloadMs,
|
||||
"verify_ms" to crawlStats?.verifyMs,
|
||||
"insert_ms" to crawlStats?.insertMs,
|
||||
"events_stored" to crawlStats?.eventsStored,
|
||||
"insert_batch" to insertBatch,
|
||||
"park_timeout_ms" to parkTimeoutMs,
|
||||
"store_load_ms" to storeLoadMs,
|
||||
"graph_build_ms" to buildMs,
|
||||
"scoring_ms" to scoringMs,
|
||||
"scoring_sweeps" to sweeps,
|
||||
"scores" to
|
||||
rankedIds.take(limit).map {
|
||||
mapOf("pubkey" to graph.pubkeyOf(it), "score" to scores[it], "rank" to rankOf(scores[it]))
|
||||
},
|
||||
)
|
||||
|
||||
if (doPublish) {
|
||||
// The cards for THIS observer are signed by a dedicated, stable
|
||||
// per-observer service key derived from the machine's operator
|
||||
// master (see OperatorKeys) — not the account key. Same key across
|
||||
// runs means re-signing a card replaces the addressable prior one.
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val serviceKey = opKeys.serviceKey(observer)
|
||||
val serviceSigner = NostrSignerInternal(serviceKey)
|
||||
val providerPubkey = serviceKey.pubKey.toHexKey()
|
||||
result["provider_pubkey"] = providerPubkey
|
||||
|
||||
// Cards go to the operator's own relay(s), where the whole
|
||||
// trusted-assertion set lives; --publish-relay overrides.
|
||||
val relays =
|
||||
publishRelaysArg
|
||||
?.split(",")
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: opKeys.operatorRelays()
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
result["published"] = 0
|
||||
result["publish_error"] = "no operator relay configured — run `amy graperank operator relay <url>` or pass --publish-relay"
|
||||
} else {
|
||||
// The scorer's desired card set: every user at or above the rank
|
||||
// cutoff, as (target, rank). GrapeRankPublisher reconciles this
|
||||
// against what this provider key already published and upserts /
|
||||
// retracts the difference.
|
||||
val publishable =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
|
||||
val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) }
|
||||
val pub =
|
||||
publisher.reconcileAndPublish(
|
||||
providerSigner = serviceSigner,
|
||||
providerPubkey = providerPubkey,
|
||||
scored = publishable,
|
||||
relays = relays,
|
||||
publishLimit = publishLimit,
|
||||
)
|
||||
|
||||
result["skipped_unchanged"] = pub.skippedUnchanged
|
||||
if (pub.truncated > 0) result["publish_truncated"] = pub.truncated
|
||||
result["published"] = pub.published
|
||||
result["publish_rejected"] = pub.publishRejected
|
||||
result["deleted"] = pub.deleted
|
||||
result["delete_rejected"] = pub.deleteRejected
|
||||
result["published_kind"] = ContactCardEvent.KIND
|
||||
result["published_to"] = relays.map { it.url }
|
||||
|
||||
// Help the observer point clients at this provider: publish their
|
||||
// kind:10040 (30382:rank -> providerPubkey @ operator relay) to
|
||||
// their outbox — but only when we actually hold their key.
|
||||
maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let {
|
||||
result["observer_10040"] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (benchSign) {
|
||||
// Throwaway key — these cards are for timing only and never leave
|
||||
// the process, so no real identity signs them.
|
||||
val tempSigner = NostrSignerInternal(KeyPair())
|
||||
val cards =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
val signStart = System.nanoTime()
|
||||
val signed = signCards(cards, tempSigner)
|
||||
val signMs = (System.nanoTime() - signStart) / 1_000_000
|
||||
val perSec = if (signMs > 0) signed * 1000L / signMs else 0
|
||||
System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)")
|
||||
result["bench_signed"] = signed
|
||||
result["bench_sign_ms"] = signMs
|
||||
}
|
||||
|
||||
Output.emit(result)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the outbox-model crawler from the crawl flags on [args] plus the
|
||||
* account's relay policy. Shared by the bare command and `graperank crawl`.
|
||||
* Relay policy — where a stranger's kind:10002 is found (index/discovery
|
||||
* aggregators + general defaults) and best-effort general relays that might
|
||||
* hold content when an outbox is unknown — lives in app code, so the quartz
|
||||
* crawler takes it injected.
|
||||
*/
|
||||
private suspend fun newCrawler(
|
||||
ctx: Context,
|
||||
args: Args,
|
||||
): GrapeRankCrawler {
|
||||
val discoveryRelays =
|
||||
ctx.bootstrapRelays() + Constants.eventFinderRelays + DefaultIndexerRelayList + EXTRA_DISCOVERY_RELAYS
|
||||
val contentFallback = ctx.bootstrapRelays() + Constants.eventFinderRelays
|
||||
// Aggregator kind:3 recovery for stragglers is on by default; --no-aggregators
|
||||
// disables it for A/B comparison.
|
||||
val aggregators = if (args.bool("no-aggregators")) emptySet() else CONTENT_AGGREGATOR_RELAYS
|
||||
// Seed the crawl with relays a prior run/monitor proved dead within the cache's
|
||||
// TTL, so the WS path never re-pays their connect timeouts (--no-reachability-cache
|
||||
// to skip). The crawl's own final live/dead set is flushed back by the caller.
|
||||
val knownDead =
|
||||
if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead
|
||||
return GrapeRankCrawler(
|
||||
client = ctx.client,
|
||||
store = ctx.store,
|
||||
limiter = ctx.relayLimiter,
|
||||
config =
|
||||
GrapeRankCrawler.Config(
|
||||
relayListDiscoveryRelays = discoveryRelays,
|
||||
knownDeadRelays = knownDead,
|
||||
contentFallbackRelays = contentFallback,
|
||||
contentAggregatorRelays = aggregators,
|
||||
maxRounds = args.intFlag("max-rounds", Int.MAX_VALUE),
|
||||
maxHops = args.intFlag("max-hops", Int.MAX_VALUE),
|
||||
timeoutMs = args.longFlag("timeout", 10L) * 1000,
|
||||
parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000,
|
||||
diagnose = args.bool("diagnose"),
|
||||
insertBatchSize = args.intFlag("insert-batch", 500),
|
||||
drainConcurrency = args.intFlag("drain-concurrency", 24),
|
||||
timeoutEvictStrikes = args.intFlag("timeout-evict", 3),
|
||||
// Cheap TCP reachability pre-probe (--no-probe to disable). No Tor
|
||||
// transport here, so .onion relays are skipped on sight.
|
||||
reachabilityProbe = if (args.bool("no-probe")) null else ::tcpReachable,
|
||||
torEnabled = false,
|
||||
// shedDeadDiscovery / shardRotations keep their benchmarked-best
|
||||
// Config defaults.
|
||||
),
|
||||
log = { System.err.println(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */
|
||||
private fun reportRelayFeedback(ctx: Context) {
|
||||
if (ctx.relayDiagnostics.hadFeedback()) {
|
||||
System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.snapshot()}")
|
||||
}
|
||||
if (ctx.relayLimiter.hadThrottling()) {
|
||||
System.err.println("[graperank] relay throttling: ${ctx.relayLimiter.snapshot()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the crawl's final live/dead relay verdicts into the shared reachability
|
||||
* cache (NIP-66 kind:30166) so the next crawl and the WoT updater start warm and
|
||||
* skip proven-dead relays. Best-effort and behind `--no-reachability-cache`: a
|
||||
* cache write must never fail the crawl it is summarizing.
|
||||
*/
|
||||
private suspend fun flushReachability(
|
||||
ctx: Context,
|
||||
args: Args,
|
||||
stats: GrapeRankCrawler.Stats,
|
||||
) {
|
||||
if (args.bool("no-reachability-cache")) return
|
||||
runCatching {
|
||||
ctx.reachability.record(reachable = stats.liveRelays, dead = stats.deadRelays)
|
||||
System.err.println(
|
||||
"[graperank] reachability cache: recorded ${stats.liveRelays.size} live, ${stats.deadRelays.size} dead",
|
||||
)
|
||||
}.onFailure { System.err.println("[graperank] reachability cache flush failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank crawl [OBSERVER]` — network-only WoT data crawl (aliased as the
|
||||
* former `sync`). Crawls the reachable follow/mute/report graph into the local
|
||||
* store (kind 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring.
|
||||
* Idempotent + cumulative: run it a few times to make sure everything is loaded,
|
||||
* then `graperank score`.
|
||||
*/
|
||||
private suspend fun crawl(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val observerArg = args.positionalOrNull(0)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
// Persist-only crawl: no in-memory graph (null builder); every event
|
||||
// still lands in the store for a later `score`.
|
||||
val stats = newCrawler(ctx, args).crawl(observer, null)
|
||||
flushReachability(ctx, args, stats)
|
||||
reportRelayFeedback(ctx)
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"crawl_rounds" to stats.rounds,
|
||||
"relays_contacted" to stats.relaysContacted,
|
||||
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
|
||||
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
|
||||
"max_hop_reached" to (stats.hopHistogram.keys.maxOrNull() ?: 0),
|
||||
"users_by_hop" to stats.hopHistogram.mapKeys { it.key.toString() },
|
||||
"contact_lists_by_hop" to stats.contactsFedByHop.mapKeys { it.key.toString() },
|
||||
"users_discovered" to stats.hopHistogram.values.sum(),
|
||||
"contact_lists_fed" to stats.contactListsFed,
|
||||
"download_ms" to stats.downloadMs,
|
||||
"verify_ms" to stats.verifyMs,
|
||||
"insert_ms" to stats.insertMs,
|
||||
"events_stored" to stats.eventsStored,
|
||||
),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank update [flags]` — refresh every locally-known author's WoT
|
||||
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the
|
||||
* next `graperank score` runs on current data without a full follow-graph crawl.
|
||||
*
|
||||
* Thin wrapper over quartz's [GrapeRankUpdater]: it reads every kind:10002 in the
|
||||
* store, inverts them into a `write-relay -> authors` map (the outbox model), and
|
||||
* runs one NIP-77 negentropy reconcile per write relay scoped to its authors —
|
||||
* bidirectional, settling deletions over the residual (its applyDown direction
|
||||
* downloads the relay's kind:5 when an uploaded record was rejected), and falling
|
||||
* back to a full paged download when a relay can't reconcile. This command only
|
||||
* parses flags and renders the [GrapeRankUpdater.Result] as text/JSON.
|
||||
*
|
||||
* Flags: `--timeout SECS` (per-group idle watchdog, default 30),
|
||||
* `--relay-concurrency N` (relays reconciled at once, default 4),
|
||||
* `--author-chunk N` (authors per reconcile filter, default 500),
|
||||
* `--min-authors N` (skip relays hosting fewer than N of our authors, default 1),
|
||||
* `--report-limit N` (per-relay rows in the JSON, default 50),
|
||||
* `--down` / `--up` / `--no-sync-deletions`.
|
||||
*/
|
||||
private suspend fun update(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val reportLimit = args.intFlag("report-limit", 50).coerceAtLeast(0)
|
||||
// Default is bidirectional; a single --down/--up narrows to that direction.
|
||||
val downFlag = args.bool("down")
|
||||
val upFlag = args.bool("up")
|
||||
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
// Skip relays a crawl/monitor proved dead within the cache's TTL — a dead
|
||||
// relay cannot serve its authors, so reconciling it only burns a timeout.
|
||||
// Live author-advertised relays are always synced (--no-reachability-cache
|
||||
// to reconcile every relay regardless).
|
||||
val knownDead =
|
||||
if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead
|
||||
|
||||
val updater =
|
||||
GrapeRankUpdater(
|
||||
client = ctx.client,
|
||||
store = ctx.store,
|
||||
config =
|
||||
GrapeRankUpdater.Config(
|
||||
down = downFlag || !upFlag,
|
||||
up = upFlag || !downFlag,
|
||||
syncDeletions = !args.bool("no-sync-deletions"),
|
||||
relayConcurrency = args.intFlag("relay-concurrency", 4),
|
||||
authorChunk = args.intFlag("author-chunk", 500),
|
||||
minAuthors = args.intFlag("min-authors", 1),
|
||||
idleTimeoutMs = args.longFlag("timeout", 30L) * 1000,
|
||||
knownDead = knownDead,
|
||||
),
|
||||
log = { System.err.println(it) },
|
||||
)
|
||||
|
||||
val result = updater.update()
|
||||
|
||||
if (result.relays == 0) {
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"relay_lists_in_store" to result.relayListsInStore,
|
||||
"authors_with_outbox" to result.authorsWithOutbox,
|
||||
"relays" to 0,
|
||||
"note" to "no kind:10002 write relays in the local store — run `graperank crawl` first",
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Busiest relays first, capped so a many-thousand-relay run still emits a
|
||||
// bounded JSON object; totals below always cover every relay.
|
||||
val report =
|
||||
result.perRelay
|
||||
.sortedByDescending { it.downloaded + it.uploaded }
|
||||
.take(reportLimit)
|
||||
.map {
|
||||
linkedMapOf<String, Any?>(
|
||||
"relay" to it.relay.url,
|
||||
"authors" to it.authors,
|
||||
"need" to it.need,
|
||||
"have" to it.have,
|
||||
"downloaded" to it.downloaded,
|
||||
"uploaded" to it.uploaded,
|
||||
"deletions_sent_up" to it.deletionsSentUp,
|
||||
"deletions_applied_down" to it.deletionsAppliedDown,
|
||||
"paged_fallback" to it.pagedFallback,
|
||||
"error" to it.error,
|
||||
)
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"kinds" to GrapeRankUpdater.DEFAULT_KINDS,
|
||||
"relay_lists_in_store" to result.relayListsInStore,
|
||||
"authors_with_outbox" to result.authorsWithOutbox,
|
||||
"relays" to result.relays,
|
||||
"relays_ok" to result.relaysOk,
|
||||
"relays_failed" to result.relaysFailed,
|
||||
"relays_paged_fallback" to result.relaysPagedFallback,
|
||||
"downloaded" to result.downloaded,
|
||||
"uploaded" to result.uploaded,
|
||||
"deletions_sent_up" to result.deletionsSentUp,
|
||||
"deletions_applied_down" to result.deletionsAppliedDown,
|
||||
"report_limit" to reportLimit,
|
||||
"per_relay" to report,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned
|
||||
* out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed
|
||||
* events are discarded — this only exists to time card generation. Returns
|
||||
* the number signed.
|
||||
*/
|
||||
private suspend fun signCards(
|
||||
cards: List<Pair<HexKey, Int>>,
|
||||
signer: NostrSigner,
|
||||
): Int {
|
||||
if (cards.isEmpty()) return 0
|
||||
val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
||||
val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1)
|
||||
return coroutineScope {
|
||||
cards
|
||||
.chunked(chunkSize)
|
||||
.map { chunk ->
|
||||
async(Dispatchers.Default) {
|
||||
for ((target, rank) in chunk) {
|
||||
ContactCardEvent.create(
|
||||
targetUser = target,
|
||||
signer = signer,
|
||||
publicInitializer = { add(RankTag.assemble(rank)) },
|
||||
)
|
||||
}
|
||||
chunk.size
|
||||
}
|
||||
}.awaitAll()
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank operator [status | relay <url>… | providers]`
|
||||
*
|
||||
* Manage the machine's operator keys used to sign trusted-assertion cards.
|
||||
* - `status` (default): master pubkey, configured relay(s), provider count.
|
||||
* - `relay <url>…`: set the operator relay(s) the cards + retractions publish
|
||||
* to; creates the operator master on first use.
|
||||
* - `providers`: the observer -> provider-pubkey mapping learned so far.
|
||||
*/
|
||||
private fun operator(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val opKeys = dataDir.operatorKeys()
|
||||
return when (rest.firstOrNull()) {
|
||||
"relay" -> {
|
||||
val urls = rest.drop(1).filter { it.isNotBlank() }
|
||||
val normalized = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (normalized.isEmpty()) return Output.error("bad_args", "usage: amy graperank operator relay <wss://…> [<wss://…> …]")
|
||||
opKeys.setRelays(urls)
|
||||
Output.emit(mapOf("master_pubkey" to opKeys.masterPubKey(), "relays" to normalized.map { it.url }))
|
||||
0
|
||||
}
|
||||
|
||||
"providers" -> {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null,
|
||||
"providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) },
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
|
||||
null, "status" -> {
|
||||
if (!opKeys.exists()) {
|
||||
Output.emit(mapOf("initialized" to false))
|
||||
} else {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"initialized" to true,
|
||||
"master_pubkey" to opKeys.masterPubKey(),
|
||||
"relays" to opKeys.operatorRelays().map { it.url },
|
||||
"providers" to opKeys.providers().size,
|
||||
),
|
||||
)
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] [--private]`
|
||||
*
|
||||
* Add a NIP-85 provider entry to the account's kind:10040
|
||||
* [TrustProviderListEvent] — the declaration a client reads to discover which
|
||||
* key publishes which assertion, and where. Defaults to declaring *self* as
|
||||
* the `30382:rank` provider at the account's first outbox relay, which is the
|
||||
* self-advertisement a GrapeRank provider makes so its followers can find the
|
||||
* cards it publishes. Fetches the freshest list first so existing providers
|
||||
* are preserved.
|
||||
*/
|
||||
private suspend fun register(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val providerArg = args.positionalOrNull(0) ?: args.flag("provider")
|
||||
val serviceArg = args.flag("service")
|
||||
val relayArg = args.flag("relay")
|
||||
val isPrivate = args.bool("private")
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
val service =
|
||||
serviceArg?.let {
|
||||
ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank")
|
||||
} ?: ProviderTypes.rank
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val provider = providerArg?.let { ctx.requireUserHex(it) } ?: self
|
||||
|
||||
val outbox = ctx.outboxRelays()
|
||||
val relay =
|
||||
relayArg?.let { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
?: outbox.firstOrNull()
|
||||
?: return Output.error("no_relays", "no relay hint; pass --relay URL or configure outbox relays")
|
||||
|
||||
val latest = fetchLatestProviderList(ctx, self, outbox, timeoutMs)
|
||||
val alreadyListed =
|
||||
latest?.serviceProviders()?.any {
|
||||
it.service == service && it.pubkey == provider && it.relayUrl == relay
|
||||
} ?: false
|
||||
|
||||
if (alreadyListed) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"service" to service.toValue(),
|
||||
"provider" to provider,
|
||||
"relay" to relay.url,
|
||||
"changed" to false,
|
||||
"based_on" to latest.id,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
val tag = ServiceProviderTag(service, provider, relay)
|
||||
val event =
|
||||
if (latest == null) {
|
||||
TrustProviderListEvent.create(tag, isPrivate = isPrivate, signer = ctx.signer)
|
||||
} else {
|
||||
TrustProviderListEvent.add(latest, tag, isPrivate = isPrivate, signer = ctx.signer)
|
||||
}
|
||||
|
||||
val ack = ctx.publish(event, outbox)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"service" to service.toValue(),
|
||||
"provider" to provider,
|
||||
"relay" to relay.url,
|
||||
"private" to isPrivate,
|
||||
"changed" to true,
|
||||
"event_id" to event.id,
|
||||
"based_on" to latest?.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank providers [USER] [--refresh] [--timeout SECS]`
|
||||
*
|
||||
* List the NIP-85 trusted providers a user declares in their kind:10040
|
||||
* (default: the active account). Cache-first; falls back to a relay drain on
|
||||
* a miss or with `--refresh`. For the active account, private (NIP-44)
|
||||
* provider entries are decrypted and included too.
|
||||
*/
|
||||
private suspend fun providers(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val userArg = args.positionalOrNull(0)
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val user = userArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val isSelf = user == ctx.identity.pubKeyHex
|
||||
|
||||
var event = if (refresh) null else providerListOf(ctx, user)
|
||||
if (event == null) {
|
||||
ctx.drain(
|
||||
(ctx.bootstrapRelays() + Constants.eventFinderRelays).associateWith {
|
||||
listOf(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(user), limit = 1))
|
||||
},
|
||||
timeoutMs,
|
||||
)
|
||||
event = providerListOf(ctx, user)
|
||||
}
|
||||
|
||||
if (event == null) {
|
||||
Output.emit(mapOf("user" to user, "found" to false, "providers" to emptyList<Any>()))
|
||||
return 0
|
||||
}
|
||||
|
||||
val public = event.serviceProviders()
|
||||
val private = if (isSelf) event.privateTags(ctx.signer)?.serviceProviders().orEmpty() else emptyList()
|
||||
|
||||
fun render(
|
||||
tag: ServiceProviderTag,
|
||||
scope: String,
|
||||
) = mapOf(
|
||||
"service" to tag.service.toValue(),
|
||||
"provider" to tag.pubkey,
|
||||
"relay" to tag.relayUrl.url,
|
||||
"scope" to scope,
|
||||
)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"user" to user,
|
||||
"found" to true,
|
||||
"event_id" to event.id,
|
||||
"created_at" to event.createdAt,
|
||||
"providers" to public.map { render(it, "public") } + private.map { render(it, "private") },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest known kind:10040 provider list for [pubKey] from the local store. */
|
||||
private suspend fun providerListOf(
|
||||
ctx: Context,
|
||||
pubKey: HexKey,
|
||||
): TrustProviderListEvent? =
|
||||
ctx.store
|
||||
.query<Event>(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1))
|
||||
.firstOrNull() as? TrustProviderListEvent
|
||||
|
||||
/**
|
||||
* Fetch the freshest kind:10040 for [pubKey] from [relays] so a register
|
||||
* builds on top of the current provider set instead of clobbering it.
|
||||
*/
|
||||
private suspend fun fetchLatestProviderList(
|
||||
ctx: Context,
|
||||
pubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long,
|
||||
): TrustProviderListEvent? {
|
||||
if (relays.isEmpty()) return providerListOf(ctx, pubKey)
|
||||
val filter = Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1)
|
||||
ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
|
||||
return providerListOf(ctx, pubKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed reports into [builder], dropping any that a valid NIP-09 deletion has
|
||||
* retracted. Uses quartz's [DeletionIndex] — the same indexer the Android
|
||||
* app's LocalCache runs — which keys each deletion under the DELETER's pubkey,
|
||||
* so `hasBeenDeleted(report)` is true only when the report's own author
|
||||
* deleted it (NIP-09: a deletion is authoritative only from the event's
|
||||
* author). It also honours created_at ordering. Returns how many were dropped.
|
||||
*/
|
||||
private suspend fun materializeReports(
|
||||
ctx: Context,
|
||||
builder: TrustGraphBuilder,
|
||||
): Int {
|
||||
val reports = ctx.store.query<Event>(Filter(kinds = listOf(ReportEvent.KIND))).filterIsInstance<ReportEvent>()
|
||||
if (reports.isEmpty()) return 0
|
||||
|
||||
// Everything in the store already passed verifyAndStore, so mark the
|
||||
// deletions as verified and skip the redundant signature check.
|
||||
val deletions = DeletionIndex()
|
||||
for (ev in ctx.store.query<Event>(Filter(kinds = listOf(DeletionEvent.KIND)))) {
|
||||
if (ev is DeletionEvent) deletions.add(ev, wasVerified = true)
|
||||
}
|
||||
|
||||
var dropped = 0
|
||||
for (r in reports) {
|
||||
if (deletions.hasBeenDeleted(r)) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubkey })
|
||||
}
|
||||
if (dropped > 0) System.err.println("[graperank] dropped $dropped retracted reports (NIP-09 deletions)")
|
||||
return dropped
|
||||
}
|
||||
|
||||
/**
|
||||
* If the active account IS the observer (so we hold their key), publish/refresh
|
||||
* their kind:10040 declaring `30382:rank` -> [providerPubkey] at [relay], to
|
||||
* their own outbox relays — the NIP-85 pointer a client follows to find these
|
||||
* cards. Returns the 10040 event id, or null when we don't hold the key (a
|
||||
* third-party observer must add the provider to their 10040 out-of-band).
|
||||
*/
|
||||
private suspend fun maybePublishObserverProviderList(
|
||||
ctx: Context,
|
||||
observer: HexKey,
|
||||
providerPubkey: HexKey,
|
||||
relay: NormalizedRelayUrl,
|
||||
): String? {
|
||||
if (observer != ctx.identity.pubKeyHex) return null
|
||||
val service = ProviderTypes.rank
|
||||
val outbox = ctx.outboxRelays()
|
||||
val latest = fetchLatestProviderList(ctx, observer, outbox, 8_000)
|
||||
val alreadyListed =
|
||||
latest?.serviceProviders()?.any {
|
||||
it.service == service && it.pubkey == providerPubkey && it.relayUrl == relay
|
||||
} ?: false
|
||||
if (alreadyListed) return latest.id
|
||||
|
||||
val tag = ServiceProviderTag(service, providerPubkey, relay)
|
||||
val event =
|
||||
if (latest == null) {
|
||||
TrustProviderListEvent.create(tag, isPrivate = false, signer = ctx.signer)
|
||||
} else {
|
||||
TrustProviderListEvent.add(latest, tag, isPrivate = false, signer = ctx.signer)
|
||||
}
|
||||
ctx.publish(event, outbox)
|
||||
return event.id
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ object KeyCommands {
|
||||
Output.emit(mapOf("valid" to false))
|
||||
return 0
|
||||
}
|
||||
val npub = hex!!.hexToByteArray().toNpub()
|
||||
val npub = hex.hexToByteArray().toNpub()
|
||||
Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub))
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy logoff [--yes] [--keep-events]` — log off an account and clear its
|
||||
* local data.
|
||||
*
|
||||
* "Logging off" a CLI with no server session means removing everything the
|
||||
* account left on this machine:
|
||||
* - the identity file and any backend-held secret (keychain / ncryptsec /
|
||||
* plaintext) — via [DataDir.deleteIdentity],
|
||||
* - the rest of the per-account directory `~/.amy/<account>/` (run-state
|
||||
* cursors, aliases, cashu counters, all MLS/Marmot state),
|
||||
* - the active-account pin at `~/.amy/current`, if it points here,
|
||||
* - and the account's events in the SHARED store at
|
||||
* `~/.amy/shared/events-store/`.
|
||||
*
|
||||
* The event store is shared across every account on the machine, so this
|
||||
* does NOT wipe it wholesale — it deletes only the events that involve this
|
||||
* account: those it authored (`authors`) plus those addressed to it via a
|
||||
* `#p` tag (inbound gift wraps, nutzaps, reactions, mentions…). Other
|
||||
* accounts' cached events are untouched. Pass `--keep-events` to leave the
|
||||
* shared cache alone and only remove the identity + per-account state.
|
||||
*
|
||||
* The account is selected the normal way (the `--account` flag, the
|
||||
* `current` pin, or the sole account) — when more than one account exists
|
||||
* and none is pinned, [DataDir.resolve] already errors out asking the caller
|
||||
* to disambiguate, so logoff never guesses which account to destroy.
|
||||
*
|
||||
* Reads the public key straight from `identity.json` (never unlocking the
|
||||
* private key), so it needs no passphrase and pops no keychain prompt.
|
||||
*
|
||||
* Requires `--yes` to execute, because it is destructive and cannot be
|
||||
* undone — the private key is gone with the identity file. Without `--yes`
|
||||
* the command reports what it would delete and exits with code 2.
|
||||
*/
|
||||
object LogoffCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
val confirmed = tail.any { it == "--yes" || it == "-y" }
|
||||
val keepEvents = tail.any { it == "--keep-events" }
|
||||
|
||||
// Read the on-disk identity metadata only — no SecretStore round-trip,
|
||||
// so we never prompt for a passphrase or trip a keychain dialog just
|
||||
// to log off.
|
||||
val idFile =
|
||||
dataDir.loadIdentityFileOrNull()
|
||||
?: return Output.error(
|
||||
"no_account",
|
||||
"no identity at ${dataDir.identityFile.absolutePath}; nothing to log off",
|
||||
)
|
||||
val pubkey = idFile.pubKeyHex
|
||||
|
||||
val marker = File(DataDir.DEFAULT_ROOT, DataDir.CURRENT_MARKER_NAME)
|
||||
val isPinned = marker.isFile && marker.readText().trim() == dataDir.accountName
|
||||
|
||||
// Everything the account touched in the shared store: authored by it,
|
||||
// or addressed to it via a #p tag (gift wraps, nutzaps, reactions…).
|
||||
val involvedFilters =
|
||||
listOf(
|
||||
Filter(authors = listOf(pubkey)),
|
||||
Filter(tags = mapOf("p" to listOf(pubkey))),
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
val eventCount = if (keepEvents) 0 else withStore(dataDir) { it.count(involvedFilters) }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"dry_run" to true,
|
||||
"account" to dataDir.accountName,
|
||||
"npub" to idFile.npub,
|
||||
"pubkey" to pubkey,
|
||||
"account_dir" to dataDir.root.absolutePath,
|
||||
"pinned" to isPinned,
|
||||
"events_to_purge" to eventCount,
|
||||
"keep_events" to keepEvents,
|
||||
"detail" to "pass --yes to permanently delete this account's key, local state" +
|
||||
(if (keepEvents) "" else ", and cached events"),
|
||||
),
|
||||
)
|
||||
return 2
|
||||
}
|
||||
|
||||
// 1. Purge the account's events from the shared store.
|
||||
var purged = 0
|
||||
if (!keepEvents) {
|
||||
withStore(dataDir) { store ->
|
||||
val before = store.count(involvedFilters)
|
||||
store.delete(involvedFilters)
|
||||
purged = (before - store.count(involvedFilters)).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove the identity file and any backend-held secret.
|
||||
dataDir.deleteIdentity()
|
||||
|
||||
// 3. Wipe the rest of the per-account directory (run-state, aliases,
|
||||
// cashu counters, Marmot/MLS state). The shared events-store lives
|
||||
// outside this directory, so it is not affected.
|
||||
val dirFullyRemoved = dataDir.root.deleteRecursively()
|
||||
|
||||
// 4. Drop the active-account pin if it pointed at this account.
|
||||
val clearedPin = isPinned && marker.delete()
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"logoff" to true,
|
||||
"account" to dataDir.accountName,
|
||||
"npub" to idFile.npub,
|
||||
"events_purged" to purged,
|
||||
"removed_dir" to dataDir.root.absolutePath,
|
||||
"dir_fully_removed" to dirFullyRemoved,
|
||||
"cleared_pin" to clearedPin,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared [FsEventStore] directly — logoff needs the store but no
|
||||
* identity, signer, or relays, so it skips [com.vitorpamplona.amethyst.cli.Context.open]
|
||||
* (which requires a bootstrapped identity). Mirrors `StoreCommands.withStore`.
|
||||
*/
|
||||
private inline fun <T> withStore(
|
||||
dataDir: DataDir,
|
||||
body: (FsEventStore) -> T,
|
||||
): T {
|
||||
val store =
|
||||
FsEventStore(
|
||||
root = dataDir.eventsDir.toPath(),
|
||||
eventToJson = JacksonMapper::toJsonPretty,
|
||||
)
|
||||
try {
|
||||
return body(store)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ object NappletCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -134,7 +134,7 @@ object NappletCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -193,7 +193,7 @@ object NappletCommands {
|
||||
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays =
|
||||
extraRelays
|
||||
|
||||
@@ -81,7 +81,7 @@ object NostrConnect {
|
||||
}
|
||||
}
|
||||
if (secret == null) return null
|
||||
return Offer(clientPubkey, relays, secret!!, name)
|
||||
return Offer(clientPubkey, relays, secret, name)
|
||||
}
|
||||
|
||||
private fun buildOffer(
|
||||
|
||||
@@ -79,7 +79,7 @@ object NsiteCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -145,7 +145,7 @@ object NsiteCommands {
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
val relays =
|
||||
@@ -203,7 +203,7 @@ object NsiteCommands {
|
||||
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val authorHex = ctx.requireUserHex(author)
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ object OfferCommands {
|
||||
}
|
||||
|
||||
/** Local decode of a `noffer` pointer — no network, no account needed. */
|
||||
private fun info(rest: Array<String>): Int {
|
||||
internal fun info(rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val offer =
|
||||
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
|
||||
|
||||
@@ -44,7 +44,7 @@ object OutboxCommand {
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
|
||||
|
||||
@@ -219,7 +219,9 @@ object Podcast20Commands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (pass a USER to
|
||||
// list someone else's episodes).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
|
||||
@@ -136,7 +136,9 @@ object PodcastCommands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (pass a USER to
|
||||
// list someone else's podcasts).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
|
||||
@@ -63,7 +63,9 @@ object ProfileCommands {
|
||||
val args = Args(rest)
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
// Read-only: runs anonymously when there is no account (an explicit
|
||||
// USER is then required, since there is no "own" profile to default to).
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubKey =
|
||||
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
|
||||
|
||||
@@ -55,7 +55,7 @@ object PublishCommand {
|
||||
return Output.error("invalid_event", "event id/signature does not verify — refusing to publish")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val targets = RawEventSupport.publishTargets(ctx, args)
|
||||
if (targets.isEmpty()) {
|
||||
|
||||
@@ -237,7 +237,7 @@ object RelayCommands {
|
||||
val raw = args.positional(0, "relay-url")
|
||||
val normalized =
|
||||
raw.normalizeRelayUrlOrNull()
|
||||
?: return Output.error("bad_args", "invalid relay url: $raw")
|
||||
?: return Output.invalidRelayUrl(raw)
|
||||
val httpUrl = normalized.toHttp()
|
||||
|
||||
val request =
|
||||
@@ -286,21 +286,21 @@ object RelayCommands {
|
||||
val self = ctx.identity.pubKeyHex
|
||||
when (verb) {
|
||||
"add" -> {
|
||||
val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
|
||||
val existing = flat.read(ctx, self)
|
||||
val added = existing.none { it.url == url.url }
|
||||
if (added) ctx.verifyAndStore(flat.build(ctx, existing + url))
|
||||
Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "added" to added))
|
||||
}
|
||||
"remove", "rm" -> {
|
||||
val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
|
||||
val existing = flat.read(ctx, self)
|
||||
val removed = existing.any { it.url == url.url }
|
||||
if (removed) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url }))
|
||||
Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "removed" to removed))
|
||||
}
|
||||
"set" -> {
|
||||
val relays = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val relays = parseUrls(args.positional) ?: return badUrlIn(args.positional)
|
||||
if (relays.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${flat.noun} clear` to empty it")
|
||||
val signed = flat.build(ctx, relays)
|
||||
ctx.verifyAndStore(signed)
|
||||
@@ -335,7 +335,7 @@ object RelayCommands {
|
||||
when (verb) {
|
||||
"add", "remove", "rm" -> {
|
||||
val present = verb == "add"
|
||||
val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
|
||||
val changed = mutateNip65(ctx, self) { applyFacet(it, url, facet, present) }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
@@ -352,7 +352,7 @@ object RelayCommands {
|
||||
if (verb == "clear") {
|
||||
emptyList()
|
||||
} else {
|
||||
val parsed = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val parsed = parseUrls(args.positional) ?: return badUrlIn(args.positional)
|
||||
if (parsed.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${facet.noun} clear` to empty it")
|
||||
parsed
|
||||
}
|
||||
@@ -388,7 +388,7 @@ object RelayCommands {
|
||||
)
|
||||
}
|
||||
"remove", "rm" -> {
|
||||
val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
|
||||
val removed = mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } }
|
||||
Output.emit(mapOf("noun" to "nip65", "kind" to AdvertisedRelayListEvent.KIND, "url" to url.url, "removed" to removed))
|
||||
}
|
||||
@@ -416,7 +416,7 @@ object RelayCommands {
|
||||
args: Args,
|
||||
add: Boolean,
|
||||
): Int {
|
||||
val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url")
|
||||
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
|
||||
Context.open(dataDir).use { ctx ->
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val changed = linkedMapOf<String, Boolean>()
|
||||
@@ -518,6 +518,9 @@ object RelayCommands {
|
||||
|
||||
private fun parseUrl(raw: String): NormalizedRelayUrl? = raw.normalizeRelayUrlOrNull()
|
||||
|
||||
/** The single relay-URL argument every add/remove verb takes, or null if it doesn't parse. */
|
||||
private fun urlArg(args: Args): NormalizedRelayUrl? = parseUrl(args.positional(0, "url"))
|
||||
|
||||
/** Normalize + dedupe (order-preserving) a list of raw URLs, or null on any bad one. */
|
||||
private fun parseUrls(raws: List<String>): List<NormalizedRelayUrl>? {
|
||||
val out = mutableListOf<NormalizedRelayUrl>()
|
||||
@@ -525,6 +528,9 @@ object RelayCommands {
|
||||
return out.distinctBy { it.url }
|
||||
}
|
||||
|
||||
/** Error exit naming the first URL in [raws] that made [parseUrls] fail. */
|
||||
private fun badUrlIn(raws: List<String>): Int = Output.invalidRelayUrl(raws.first { parseUrl(it) == null })
|
||||
|
||||
private suspend fun readNip65(
|
||||
ctx: Context,
|
||||
self: HexKey,
|
||||
|
||||
@@ -145,7 +145,7 @@ object SearchCommand {
|
||||
timeoutMs: Long,
|
||||
render: (List<Event>) -> List<Map<String, Any?>>,
|
||||
): Int {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays =
|
||||
SearchActions.resolveSearchRelays(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.RunState
|
||||
import com.vitorpamplona.amethyst.cli.StoreStats
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
|
||||
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy status` — a single at-a-glance overview of everything amy is
|
||||
* holding on disk under `~/.amy/`. Built for the returning user: "I
|
||||
* haven't run this in months — what accounts do I have, which one is
|
||||
* active, can they still sign, and how big is the local database?"
|
||||
*
|
||||
* Cross-account by design, so it dispatches *before* account resolution
|
||||
* (like `use`) and never fails on "zero accounts" or "ambiguous account".
|
||||
* It is strictly read-only and metadata-only: it parses the on-disk
|
||||
* `identity.json` / `state.json` / `aliases.json` and walks the shared
|
||||
* event store, but it never unlocks a private key (no keychain prompt,
|
||||
* no NIP-49 passphrase) and never touches the network.
|
||||
*
|
||||
* Per account it reports the npub, how the key is stored (local keychain
|
||||
* / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can
|
||||
* sign, and the local footprint that account has accumulated: aliases,
|
||||
* Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the
|
||||
* sync cursors that tell catch-up commands where they left off.
|
||||
*/
|
||||
object StatusCommand {
|
||||
fun run(tail: Array<String>): Int {
|
||||
// `status` takes no positional args; tolerate an accidental one
|
||||
// rather than erroring — it's a read-only inspection command.
|
||||
val rootBase = DataDir.DEFAULT_ROOT
|
||||
|
||||
val currentPin =
|
||||
File(rootBase, DataDir.CURRENT_MARKER_NAME)
|
||||
.takeIf { it.isFile }
|
||||
?.readText()
|
||||
?.trim()
|
||||
?.ifEmpty { null }
|
||||
|
||||
val accountNames = DataDir.listAccounts(rootBase)
|
||||
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
|
||||
|
||||
// The event store is shared across every account.
|
||||
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"root" to rootBase.absolutePath,
|
||||
"current" to currentPin,
|
||||
"account_count" to accounts.size,
|
||||
"accounts" to accounts,
|
||||
"store" to
|
||||
mapOf(
|
||||
"events" to store.events,
|
||||
"distinct_kinds" to store.distinctKinds,
|
||||
"disk_bytes" to store.diskBytes,
|
||||
"oldest_at" to store.oldestAt,
|
||||
"newest_at" to store.newestAt,
|
||||
"root" to store.root.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun accountRow(
|
||||
accountRoot: File,
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
): Map<String, Any?> {
|
||||
val identity = readIdentity(File(accountRoot, "identity.json"))
|
||||
val signer = classifySigner(identity)
|
||||
|
||||
val marmotGroups =
|
||||
File(accountRoot, "marmot/groups")
|
||||
.listFiles { f -> f.name.endsWith(".state") }
|
||||
?.size ?: 0
|
||||
val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile
|
||||
val hasCashuWallet = File(accountRoot, "cashu.json").isFile
|
||||
val aliasCount = readAliases(File(accountRoot, "aliases.json")).size
|
||||
val runState = readRunState(File(accountRoot, "state.json"))
|
||||
|
||||
// LinkedHashMap so the text renderer prints fields in this order.
|
||||
val row = LinkedHashMap<String, Any?>()
|
||||
row["name"] = name
|
||||
row["current"] = isCurrent
|
||||
row["npub"] = identity?.npub
|
||||
row["hex"] = identity?.pubKeyHex
|
||||
row["signer"] = signer.kind
|
||||
row["key_storage"] = signer.storage
|
||||
row["can_sign"] = signer.canSign
|
||||
if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays
|
||||
row["aliases"] = aliasCount
|
||||
row["marmot_groups"] = marmotGroups
|
||||
row["key_package_published"] = hasKeyPackage
|
||||
row["cashu_wallet"] = hasCashuWallet
|
||||
row["dm_cursor_at"] = runState.giftWrapSince
|
||||
row["marmot_group_cursors"] = runState.groupSince.size
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* How this account can sign, derived purely from the on-disk
|
||||
* [IdentityFile] — never resolves the secret itself.
|
||||
* - `local` — an on-device private key ([storage] says where).
|
||||
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
|
||||
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
|
||||
*/
|
||||
private data class SignerInfo(
|
||||
val kind: String,
|
||||
val storage: String?,
|
||||
val canSign: Boolean,
|
||||
val bunkerRelays: List<String>?,
|
||||
)
|
||||
|
||||
private fun classifySigner(identity: IdentityFile?): SignerInfo {
|
||||
if (identity == null) return SignerInfo("unknown", null, false, null)
|
||||
identity.bunker?.let { bunker ->
|
||||
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
|
||||
}
|
||||
val storage = secretStorageLabel(identity.secret)
|
||||
return when {
|
||||
identity.secret != null -> SignerInfo("local", storage, true, null)
|
||||
// Pre-secret-store data-dirs kept the key inline; still signable.
|
||||
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
|
||||
else -> SignerInfo("read-only", null, false, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun secretStorageLabel(secret: IdentitySecret?): String? =
|
||||
when (secret) {
|
||||
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
|
||||
is IdentitySecret.Ncryptsec -> "ncryptsec"
|
||||
is IdentitySecret.Plaintext -> "plaintext"
|
||||
null -> null
|
||||
}
|
||||
|
||||
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<IdentityFile>(file.readText()) }.getOrNull() else null
|
||||
|
||||
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
|
||||
|
||||
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
|
||||
}
|
||||
@@ -22,9 +22,13 @@ package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.amethyst.cli.StoreBackend
|
||||
import com.vitorpamplona.amethyst.cli.StoreFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
@@ -33,19 +37,24 @@ import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* `amy store <stat|sweep-expired|scrub|compact>` — direct introspection
|
||||
* and maintenance of the file-backed event store at
|
||||
* `<data-dir>/events-store/`.
|
||||
* and maintenance of the shared event store under `<data-dir>/shared/`.
|
||||
*
|
||||
* - `stat` total event count, kind histogram, disk bytes,
|
||||
* mtime range — pure read, no relay traffic.
|
||||
* The store backend is selected by `AMY_STORE` (SQLite by default, or the
|
||||
* FS tree with `AMY_STORE=fs` — see [StoreFactory]); each verb adapts to
|
||||
* whichever is active:
|
||||
*
|
||||
* - `stat` total event count, disk bytes, backend, plus (FS only)
|
||||
* the per-kind histogram and mtime range — pure read,
|
||||
* no relay traffic.
|
||||
* - `sweep-expired` delete events whose NIP-40 `expiration` tag has
|
||||
* passed (per the store's own sweep logic). Run
|
||||
* from cron / scheduler / `amy` periodically.
|
||||
* - `scrub` rebuild every `idx/` entry from the canonical
|
||||
* events. Recovers from partial-write crashes or
|
||||
* external edits.
|
||||
* - `compact` drop dangling `idx/` entries whose canonical is
|
||||
* gone. Cheaper than scrub.
|
||||
* - `scrub` FS: rebuild every `idx/` entry from the canonical
|
||||
* events, recovering from partial-write crashes or
|
||||
* external edits. SQLite: a no-op (indexes are updated
|
||||
* transactionally and can't drift).
|
||||
* - `compact` FS: drop dangling `idx/` entries whose canonical is
|
||||
* gone. SQLite: `VACUUM` the database to reclaim space.
|
||||
* - `reindex-fts` wipe and rebuild only the NIP-50 full-text search
|
||||
* index from the stored events. Run after a quartz
|
||||
* upgrade that changes which kinds are searchable.
|
||||
@@ -68,7 +77,52 @@ object StoreCommands {
|
||||
),
|
||||
)
|
||||
|
||||
private fun stat(dataDir: DataDir): Int {
|
||||
private suspend fun stat(dataDir: DataDir): Int =
|
||||
when (StoreFactory.backend()) {
|
||||
StoreBackend.SQLITE -> sqliteStat(dataDir)
|
||||
StoreBackend.FS -> fsStat(dataDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite `stat`: total count via `COUNT(*)` and on-disk bytes from the
|
||||
* DB file plus its `-wal`/`-shm` sidecars. The per-kind histogram and
|
||||
* mtime range are FS-store concepts (they read the `idx/kind` tree and
|
||||
* file mtimes), so they're omitted here.
|
||||
*/
|
||||
private suspend fun sqliteStat(dataDir: DataDir): Int {
|
||||
val dbFile = dataDir.eventsDbFile
|
||||
if (!dbFile.exists()) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"backend" to "sqlite",
|
||||
"events" to 0,
|
||||
"disk_bytes" to 0L,
|
||||
"root" to dbFile.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
val count =
|
||||
EventStore(dbName = dbFile.absolutePath, relay = null).use { store ->
|
||||
store.count(Filter())
|
||||
}
|
||||
val diskBytes =
|
||||
listOf("", "-wal", "-shm").sumOf { suffix ->
|
||||
val f = File(dbFile.absolutePath + suffix)
|
||||
if (f.isFile) f.length() else 0L
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"backend" to "sqlite",
|
||||
"events" to count,
|
||||
"disk_bytes" to diskBytes,
|
||||
"root" to dbFile.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun fsStat(dataDir: DataDir): Int {
|
||||
val storeRoot = dataDir.eventsDir.toPath()
|
||||
if (!storeRoot.exists()) {
|
||||
Output.emit(
|
||||
@@ -140,37 +194,65 @@ object StoreCommands {
|
||||
|
||||
private suspend fun sweepExpired(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at")
|
||||
val before = countEntries(expiresAtDir)
|
||||
store.deleteExpiredEvents()
|
||||
val after = countEntries(expiresAtDir)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"swept" to (before - after).coerceAtLeast(0L),
|
||||
"remaining" to after,
|
||||
),
|
||||
)
|
||||
if (store is FsEventStore) {
|
||||
// The FS store exposes its expiration index as a directory,
|
||||
// so we can report exactly how many entries the sweep cleared.
|
||||
val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at")
|
||||
val before = countEntries(expiresAtDir)
|
||||
store.deleteExpiredEvents()
|
||||
val after = countEntries(expiresAtDir)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"swept" to (before - after).coerceAtLeast(0L),
|
||||
"remaining" to after,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
store.deleteExpiredEvents()
|
||||
Output.emit(mapOf("ok" to true))
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
private fun scrub(dataDir: DataDir): Int =
|
||||
private suspend fun scrub(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
store.scrub()
|
||||
Output.emit(mapOf("ok" to true))
|
||||
when (store) {
|
||||
is FsEventStore -> {
|
||||
store.scrub()
|
||||
Output.emit(mapOf("ok" to true))
|
||||
}
|
||||
// SQLite indexes are written in the same transaction as the
|
||||
// event, so they can't drift the way the FS `idx/` tree can —
|
||||
// there is nothing to rebuild.
|
||||
else ->
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"note" to "scrub is a no-op for the sqlite backend (indexes update transactionally)",
|
||||
),
|
||||
)
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
private fun compact(dataDir: DataDir): Int =
|
||||
private suspend fun compact(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
store.compact()
|
||||
when (store) {
|
||||
// FS: drop dangling idx/ postings. SQLite: VACUUM to rebuild
|
||||
// the file and hand freed pages back to the OS.
|
||||
is FsEventStore -> store.compact()
|
||||
is EventStore -> store.store.vacuum()
|
||||
else -> Unit
|
||||
}
|
||||
Output.emit(mapOf("ok" to true))
|
||||
0
|
||||
}
|
||||
|
||||
private suspend fun reindexFts(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
val fsBacked = store is FsEventStore
|
||||
val ftsDir = dataDir.eventsDir.toPath().resolve("idx/fts")
|
||||
val before = countEntries(ftsDir)
|
||||
val before = if (fsBacked) countEntries(ftsDir) else 0L
|
||||
// Drive the resumable, batched path to completion so a huge
|
||||
// store is processed without holding the writer lock for the
|
||||
// whole pass. A real long-running caller would persist the
|
||||
@@ -184,35 +266,34 @@ object StoreCommands {
|
||||
processed += progress.processedThisBatch
|
||||
batches++
|
||||
} while (!progress.done)
|
||||
val after = countEntries(ftsDir)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
val out =
|
||||
linkedMapOf<String, Any?>(
|
||||
"ok" to true,
|
||||
"processed" to processed,
|
||||
"batches" to batches,
|
||||
"tokens_before" to before,
|
||||
"tokens_after" to after,
|
||||
),
|
||||
)
|
||||
)
|
||||
if (fsBacked) {
|
||||
// Token-file counts are an FS-store notion (idx/fts is a
|
||||
// directory); the SQLite FTS index doesn't expose one.
|
||||
out["tokens_before"] = before
|
||||
out["tokens_after"] = countEntries(ftsDir)
|
||||
}
|
||||
Output.emit(out)
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance verbs only need the store — not identity, not relays,
|
||||
* not the signer. Skip [Context.open] (which throws if no identity
|
||||
* has been bootstrapped) and construct the [FsEventStore] directly
|
||||
* from [DataDir.eventsDir]. Pretty formatter matches what the rest
|
||||
* of the CLI uses for inspection-friendly output.
|
||||
* not the signer. Skip [Context.open] (which throws if no identity has
|
||||
* been bootstrapped) and open the configured backend directly via
|
||||
* [StoreFactory], so `amy store` acts on whichever store the rest of
|
||||
* the CLI is using.
|
||||
*/
|
||||
private inline fun withStore(
|
||||
private suspend fun withStore(
|
||||
dataDir: DataDir,
|
||||
body: (FsEventStore) -> Int,
|
||||
body: suspend (IEventStore) -> Int,
|
||||
): Int {
|
||||
val store =
|
||||
FsEventStore(
|
||||
root = dataDir.eventsDir.toPath(),
|
||||
eventToJson = JacksonMapper::toJsonPretty,
|
||||
)
|
||||
val store = StoreFactory.open(dataDir)
|
||||
try {
|
||||
return body(store)
|
||||
} finally {
|
||||
|
||||
@@ -53,7 +53,7 @@ object SubscribeCommand {
|
||||
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 }
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
|
||||
|
||||
@@ -26,8 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DeletionSettleResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
|
||||
@@ -54,15 +56,29 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* Pass both for a full bidirectional sync. The filter flags are the same as
|
||||
* `fetch`/`subscribe`; an empty filter reconciles the whole store.
|
||||
*
|
||||
* Both directions are pipelined with the reconcile: need-id batches feed
|
||||
* [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single
|
||||
* uploader, so downloads and uploads overlap the remaining reconcile rounds
|
||||
* instead of waiting for the full diff. Every downloaded event funnels
|
||||
* through `Context.drain`'s verify-and-store path, unchanged.
|
||||
* Deletion propagation (on by default; disable with `--no-sync-deletions`) is a
|
||||
* **second pass over the residual**, not per-event work in the content pass — so it
|
||||
* costs the same whether the database is tiny or huge. After the content settle, a
|
||||
* re-reconcile's leftover diff is (barring races) exactly the events a deletion kept
|
||||
* from converging:
|
||||
*
|
||||
* Thin assembly only: the windowing, streaming, and back-pressure live in
|
||||
* quartz (`negentropyReconcile`); this file only routes ids to
|
||||
* `Context.drain` / `Context.publish`.
|
||||
* - a residual **need** (relay has it, we still lack it after `--down` tried to
|
||||
* download) = we deleted it → publish OUR covering deletion up so the relay drops it;
|
||||
* - a residual **have** (we have it, relay still lacks it after `--up` tried to upload)
|
||||
* = the relay deleted it → pull the relay's covering kind-5 down and apply it locally.
|
||||
*
|
||||
* Coverage is any way a deletion reaches an event ([deletionsCovering]): a NIP-09 kind-5
|
||||
* by id (`e`) or address (`a`, cutoff-checked), or a NIP-62 vanish targeting this relay
|
||||
* (up direction only — a pulled vanish is not auto-applied, its blast radius being the
|
||||
* whole account). The residual is small (only real deletion mismatches), so only it is
|
||||
* fetched — never the whole need set. The loop repeats until a round resolves nothing.
|
||||
* So `amy sync` (default `--down`) makes the relay honor your deletions; `--up` makes
|
||||
* your store honor the relay's; `--up --down` converges both ways.
|
||||
*
|
||||
* Content is pipelined with the reconcile: need-id batches feed [DOWNLOAD_WORKERS]
|
||||
* concurrent by-id REQ drains and have-ids feed a single uploader. Thin assembly only:
|
||||
* the windowing, streaming, and back-pressure live in quartz (`negentropyReconcile`);
|
||||
* this file only routes ids to `Context.drain` / `Context.publish`.
|
||||
*/
|
||||
object SyncCommand {
|
||||
private const val ID_CHUNK = 500
|
||||
@@ -78,6 +94,15 @@ object SyncCommand {
|
||||
/** Overlapped `created_at`-window reconciles after an over-cap split. */
|
||||
private const val RECONCILE_CONCURRENCY = 2
|
||||
|
||||
/**
|
||||
* Cap on deletion-settle rounds. Each round resolves the residual it can and
|
||||
* re-reconciles; a healthy sync converges in 1–2 (round N sends/applies, round
|
||||
* N+1 confirms empty). The cap only bounds pathological non-convergence (e.g. a
|
||||
* relay that refuses a deletion), which the "resolved nothing → stop" check
|
||||
* normally catches first.
|
||||
*/
|
||||
private const val MAX_DELETION_ROUNDS = 4
|
||||
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
@@ -88,14 +113,15 @@ object SyncCommand {
|
||||
?: return Output.error("bad_args", "sync requires --relay URL")
|
||||
val relay =
|
||||
RelayUrlNormalizer.normalizeOrNull(relayUrl)
|
||||
?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
?: return Output.invalidRelayUrl(relayUrl)
|
||||
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 30L) * 1000
|
||||
// Default direction is download; --up adds upload.
|
||||
val up = args.bool("up")
|
||||
val down = args.bool("down") || !up
|
||||
val syncDeletions = !args.bool("no-sync-deletions")
|
||||
val filter = RawEventSupport.buildFilter(args)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val localEvents = ctx.store.query<Event>(filter)
|
||||
val localById = localEvents.associateBy { it.id }
|
||||
@@ -104,23 +130,22 @@ object SyncCommand {
|
||||
val downloaded = AtomicInteger(0)
|
||||
val uploaded = AtomicInteger(0)
|
||||
|
||||
// ── Pass 1: content settle — download needs, upload haves. No deletion
|
||||
// logic, so a plain sync costs exactly what it always did.
|
||||
val result =
|
||||
try {
|
||||
coroutineScope {
|
||||
// needIds = relay has, we lack; haveIds = we have, relay lacks.
|
||||
// Bounded so a slow download back-pressures the reconcile
|
||||
// rounds instead of piling ids up in memory.
|
||||
val needBatches = Channel<List<HexKey>>(DOWNLOAD_WORKERS * 2)
|
||||
// Unbounded is fine here: have-ids reference events we already
|
||||
// hold locally, so memory is bounded by the local set.
|
||||
val haveBatches = Channel<List<HexKey>>(Channel.UNLIMITED)
|
||||
|
||||
val downloaders =
|
||||
List(DOWNLOAD_WORKERS) {
|
||||
launch {
|
||||
for (batch in needBatches) {
|
||||
val got = ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs)
|
||||
downloaded.addAndGet(got.size)
|
||||
// drain verifies + stores; anything we deleted is
|
||||
// rejected by our own tombstone and stays a "need".
|
||||
downloaded.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,8 +154,7 @@ object SyncCommand {
|
||||
for (batch in haveBatches) {
|
||||
for (id in batch) {
|
||||
val ev = localById[id] ?: continue
|
||||
val ack = ctx.publish(ev, setOf(relay))
|
||||
if (ack.values.any { it }) uploaded.incrementAndGet()
|
||||
if (ctx.publish(ev, setOf(relay)).values.any { it }) uploaded.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +184,28 @@ object SyncCommand {
|
||||
return Output.error("sync_error", e.message ?: "negentropy sync failed")
|
||||
}
|
||||
|
||||
// ── Pass 2+: deletion settle. The reusable quartz accessory re-reconciles
|
||||
// and resolves only the residual — send our deletions up for what we deleted
|
||||
// (bounded by --down), apply the relay's kind-5 down for what it deleted
|
||||
// (bounded by --up) — looping until stable. Cheap regardless of database size
|
||||
// (see negentropySettleDeletions), and best-effort so it can't fail the sync.
|
||||
val deletions =
|
||||
if (syncDeletions) {
|
||||
ctx.client.negentropySettleDeletions(
|
||||
relay = relay,
|
||||
filter = filter,
|
||||
store = ctx.store,
|
||||
sendUp = down,
|
||||
applyDown = up,
|
||||
batchSize = ID_CHUNK,
|
||||
idleTimeoutMs = timeoutMs,
|
||||
maxRounds = MAX_DELETION_ROUNDS,
|
||||
reconcileConcurrency = RECONCILE_CONCURRENCY,
|
||||
)
|
||||
} else {
|
||||
DeletionSettleResult(0, 0, 0)
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"relay" to relay.url,
|
||||
@@ -169,6 +215,9 @@ object SyncCommand {
|
||||
"have" to result.haveCount,
|
||||
"downloaded" to downloaded.get(),
|
||||
"uploaded" to uploaded.get(),
|
||||
"deletions_sent_up" to deletions.sentUp,
|
||||
"deletions_applied_down" to deletions.appliedDown,
|
||||
"deletion_rounds" to deletions.rounds,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.Output
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
|
||||
import com.vitorpamplona.amethyst.commons.wot.WoTService
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
|
||||
*
|
||||
* The score for a pubkey X is the count of accounts in the active user's
|
||||
* kind-3 follow set who also follow X. `get` and `list` are read-only —
|
||||
* they hydrate the score map from whatever kind-3 events already live in
|
||||
* the local event store. `sync` pulls fresh kind-3 events from the
|
||||
* configured relay pool so the next `get` / `list` is up to date.
|
||||
*/
|
||||
object WotCommand {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val head = rest.firstOrNull() ?: return usage()
|
||||
val tail = rest.drop(1).toTypedArray()
|
||||
return when (head) {
|
||||
"get" -> get(dataDir, tail)
|
||||
"list" -> list(dataDir, tail)
|
||||
"sync" -> sync(dataDir, tail)
|
||||
else -> usage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
|
||||
|
||||
private suspend fun get(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "wot get <pubkey|npub>")
|
||||
val userArg = rest[0]
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val target = ctx.requireUserHex(userArg)
|
||||
val (svc, scope) = buildHydratedService(ctx)
|
||||
try {
|
||||
val score = svc.scoresSnapshot()[target] ?: 0
|
||||
Output.emit(mapOf("pubkey" to target, "score" to score))
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val threshold = args.flag("threshold")?.toIntOrNull() ?: 1
|
||||
val limit = args.flag("limit")?.toIntOrNull() ?: 50
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val (svc, scope) = buildHydratedService(ctx)
|
||||
try {
|
||||
val entries =
|
||||
svc
|
||||
.scoresSnapshot()
|
||||
.entries
|
||||
.asSequence()
|
||||
.filter { it.value >= threshold }
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { mapOf("pubkey" to it.key, "score" to it.value) }
|
||||
.toList()
|
||||
Output.emit(mapOf("count" to entries.size, "entries" to entries))
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
// Overall timeout; per-relay budget is set by OutboxDispatcher's
|
||||
// default (4s). `--timeout N` overrides the overall cap.
|
||||
val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows =
|
||||
myKind3?.verifiedFollowKeySet()?.toSet()
|
||||
?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first")
|
||||
if (follows.isEmpty()) {
|
||||
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
|
||||
return 0
|
||||
}
|
||||
val relays = ctx.indexRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
// Buffer discovered events; persist synchronously after
|
||||
// the fetch. `store.insert` is suspending so we can't call
|
||||
// it from the non-suspending gateway callbacks. This also
|
||||
// keeps `insert` errors surfaceable in a single log line
|
||||
// rather than swallowed into a race.
|
||||
val buffered = Collections.synchronizedList(mutableListOf<Event>())
|
||||
val gateway =
|
||||
object : OutboxCacheGateway {
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? =
|
||||
// Amy's store lookup is suspending; can't do
|
||||
// it here. The dispatcher then falls through
|
||||
// to Phase 1 discovery for every author, which
|
||||
// matches the old `amy wot sync` behaviour of
|
||||
// always re-asking. A future optimisation
|
||||
// could pre-populate a `Map<HexKey,
|
||||
// AdvertisedRelayListEvent>` before dispatch.
|
||||
null
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = ctx.client,
|
||||
scope = scope,
|
||||
indexRelays = { relays },
|
||||
gateway = gateway,
|
||||
overallTimeoutMs = overallTimeoutMs,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(follows)
|
||||
|
||||
// Persist to store so future `get` / `list` see them.
|
||||
val eventsToPersist = synchronized(buffered) { buffered.toList() }
|
||||
eventsToPersist.forEach { runCatching { ctx.store.insert(it) } }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"followers" to follows.size,
|
||||
"authors_requested" to result.authorsRequested,
|
||||
"kind10002_received" to result.kind10002Received,
|
||||
"kind3_received" to result.kind3Received,
|
||||
"outbox_covered_authors" to result.outboxCoveredAuthors,
|
||||
"fallback_authors" to result.fallbackAuthors,
|
||||
"persisted" to eventsToPersist.size,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [WoTService], populate it from the local event store, then
|
||||
* return the (service, backing scope). Caller must cancel the scope
|
||||
* when done.
|
||||
*/
|
||||
private suspend fun buildHydratedService(ctx: Context): Pair<WoTService, CoroutineScope> {
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows: Set<HexKey> = myKind3?.verifiedFollowKeySet() ?: emptySet()
|
||||
svc.onFollowSetChange(follows, self)
|
||||
// Pull each follower's kind-3 from the store and feed into the service.
|
||||
follows.forEach { follower ->
|
||||
val followerKind3 = ctx.contactsOf(follower) ?: return@forEach
|
||||
svc.applyKind3(follower, followerKind3.verifiedFollowKeySet())
|
||||
}
|
||||
svc.markReadyOnce()
|
||||
return svc to scope
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ dm/state-dm-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
sync/state-sync-deletions/
|
||||
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync-deletions-headless.sh — drives the real `amy` binary against a real
|
||||
# `amy serve` relay to prove NIP-77 deletion propagation end-to-end.
|
||||
#
|
||||
# `amy sync` converges deletions in a second pass over the reconcile residual
|
||||
# (see quartz `negentropySettleDeletions`). This exercises both directions plus
|
||||
# the opt-out:
|
||||
#
|
||||
# T1 (up) — we deleted a note the relay still has → `amy sync` sends our
|
||||
# kind-5 up and the relay drops the note. Verified by an ISOLATED
|
||||
# third account whose store reads the relay only (no tombstone).
|
||||
# T2 (off) — same setup with `--no-sync-deletions` → the relay keeps the note
|
||||
# and nothing is sent.
|
||||
# T3 (down) — the relay deleted a note we still hold → `amy sync --up` pulls the
|
||||
# relay's kind-5 down and applies it locally (converges on re-sync).
|
||||
#
|
||||
# Each amy account gets its OWN $HOME so their file stores don't share (accounts
|
||||
# under one $HOME share ~/.amy/shared/events-store). The relay (amy serve) keeps
|
||||
# a separate store from any client store.
|
||||
#
|
||||
# Usage: ./sync-deletions-headless.sh [--port N] [--no-build]
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-sync-deletions"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
RUN_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
|
||||
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
RELAY_HOST="127.0.0.1"
|
||||
RELAY_PORT="${RELAY_PORT:-7790}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Fresh state every run — stale per-account $HOME dirs from a prior run must not
|
||||
# leak into this one.
|
||||
rm -rf "$STATE_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
|
||||
# Leniently-trimmed equality assertion (assert helpers live in the DM-specific
|
||||
# helpers.sh, which hardcodes its own amy wrappers — so define our own here).
|
||||
assert_eq() {
|
||||
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
|
||||
if [[ "${actual// /}" == "${expected// /}" ]]; then
|
||||
info "assert: $test_id \"$actual\" == \"$expected\""
|
||||
return 0
|
||||
fi
|
||||
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
|
||||
record_result "$test_id" fail "${note:-mismatch}"
|
||||
return 1
|
||||
}
|
||||
|
||||
SERVE_PID=""
|
||||
RELAY_HOME=""
|
||||
cleanup() {
|
||||
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
|
||||
trap - EXIT INT TERM HUP
|
||||
print_summary
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
banner "amy sync — NIP-77 deletion propagation headless ($RUN_TS)"
|
||||
|
||||
# ---- build ------------------------------------------------------------------
|
||||
if [[ "$NO_BUILD" -eq 0 ]]; then
|
||||
step "Building amy (installDist)…"
|
||||
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|
||||
|| { fail_msg "build failed (see $LOG_FILE)"; exit 1; }
|
||||
fi
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; }
|
||||
|
||||
# ---- amy wrappers (one isolated $HOME per account) --------------------------
|
||||
strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:|MarmotManager|MlsGroup"; }
|
||||
mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; }
|
||||
# amy_run <home> <account> args...
|
||||
amy_run() {
|
||||
local home="$1" acct="$2"; shift 2
|
||||
HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip
|
||||
}
|
||||
|
||||
RELAY_HOME="$(mk_home)"
|
||||
amy_run "$RELAY_HOME" a init >/dev/null
|
||||
|
||||
step "Starting amy serve on $RELAY_URL…"
|
||||
HOME="$RELAY_HOME" "$AMY_BIN" --account a --secret-backend plaintext \
|
||||
serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 &
|
||||
SERVE_PID=$!
|
||||
|
||||
# Wait for the relay to accept connections (poll the serve log).
|
||||
for _ in $(seq 1 60); do
|
||||
grep -q "relay up at" "$LOG_FILE" && break
|
||||
sleep 0.5
|
||||
done
|
||||
grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; }
|
||||
|
||||
# Isolated verifier: its own empty store, reads the relay only (no tombstone).
|
||||
VERIFY_HOME="$(mk_home)"
|
||||
amy_run "$VERIFY_HOME" v init >/dev/null
|
||||
relay_count() { amy_run "$VERIFY_HOME" v fetch --id "$1" --relay "$RELAY_URL" | jq -r '.count // 0'; }
|
||||
|
||||
# =============================================================================
|
||||
# T1 — up direction: we deleted it, the relay still has it → sync sends it up.
|
||||
# =============================================================================
|
||||
banner "T1 — amy sync sends our deletion up (relay drops the note)"
|
||||
NOTE="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t1" | jq -c '.event')"
|
||||
NID="$(echo "$NOTE" | jq -r '.id')"
|
||||
echo "$NOTE" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null
|
||||
|
||||
before="$(relay_count "$NID")"
|
||||
assert_eq "$before" "1" T1.setup "relay should hold the note before sync" \
|
||||
&& record_result T1.setup pass "relay has the note"
|
||||
|
||||
# Delete locally only (no --relay → stored, applied, not sent to the relay).
|
||||
amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID\"]]" --content "" --publish >/dev/null
|
||||
|
||||
SYNC="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL")"
|
||||
info "sync: $SYNC"
|
||||
sent="$(echo "$SYNC" | jq -r '.deletions_sent_up // 0')"
|
||||
assert_eq "$sent" "1" T1.sent_up "sync should report one deletion sent up" \
|
||||
&& record_result T1.sent_up pass "deletions_sent_up=1"
|
||||
|
||||
sleep 1
|
||||
after="$(relay_count "$NID")"
|
||||
assert_eq "$after" "0" T1.relay_dropped "relay must have removed the note after sync" \
|
||||
&& record_result T1.relay_dropped pass "relay note count 1 → 0"
|
||||
|
||||
# =============================================================================
|
||||
# T2 — opt-out: --no-sync-deletions leaves the relay untouched.
|
||||
# =============================================================================
|
||||
banner "T2 — --no-sync-deletions propagates nothing"
|
||||
NOTE2="$(amy_run "$RELAY_HOME" a event --kind 1 --content "keep-me-t2" | jq -c '.event')"
|
||||
NID2="$(echo "$NOTE2" | jq -r '.id')"
|
||||
echo "$NOTE2" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null
|
||||
amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID2\"]]" --content "" --publish >/dev/null
|
||||
|
||||
SYNC2="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL" --no-sync-deletions)"
|
||||
info "sync: $SYNC2"
|
||||
sent2="$(echo "$SYNC2" | jq -r '.deletions_sent_up // 0')"
|
||||
assert_eq "$sent2" "0" T2.no_send "--no-sync-deletions must send nothing" \
|
||||
&& record_result T2.no_send pass "deletions_sent_up=0"
|
||||
sleep 1
|
||||
kept="$(relay_count "$NID2")"
|
||||
assert_eq "$kept" "1" T2.relay_kept "relay must still hold the note" \
|
||||
&& record_result T2.relay_kept pass "relay note untouched"
|
||||
|
||||
# =============================================================================
|
||||
# T3 — down direction: the relay deleted it, we still hold it → sync --up pulls
|
||||
# the relay's deletion down and applies it locally.
|
||||
# =============================================================================
|
||||
banner "T3 — amy sync --up applies the relay's deletion locally"
|
||||
BOB_HOME="$(mk_home)"
|
||||
amy_run "$BOB_HOME" b init >/dev/null
|
||||
NOTE3="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t3" | jq -c '.event')"
|
||||
NID3="$(echo "$NOTE3" | jq -r '.id')"
|
||||
echo "$NOTE3" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null
|
||||
# bob's isolated store learns the note from the relay…
|
||||
amy_run "$BOB_HOME" b fetch --id "$NID3" --relay "$RELAY_URL" >/dev/null
|
||||
# …then the relay deletes it (author pushes a kind-5 straight to the relay).
|
||||
amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID3\"]]" --content "" | jq -c '.event' \
|
||||
| amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null
|
||||
|
||||
SYNC3="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")"
|
||||
info "sync: $SYNC3"
|
||||
applied="$(echo "$SYNC3" | jq -r '.deletions_applied_down // 0')"
|
||||
assert_eq "$applied" "1" T3.applied_down "sync --up should apply one relay deletion locally" \
|
||||
&& record_result T3.applied_down pass "deletions_applied_down=1"
|
||||
|
||||
# Converged: a second --up sync finds nothing left to apply.
|
||||
SYNC3B="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")"
|
||||
applied2="$(echo "$SYNC3B" | jq -r '.deletions_applied_down // 0')"
|
||||
assert_eq "$applied2" "0" T3.converged "re-sync applies nothing (converged)" \
|
||||
&& record_result T3.converged pass "second sync stable"
|
||||
|
||||
# print_summary runs from the cleanup trap; exit non-zero if any test failed.
|
||||
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,695 @@
|
||||
---
|
||||
title: WoT fetch via outbox model + PR #3483 review fixes
|
||||
type: fix
|
||||
status: completed
|
||||
date: 2026-07-06
|
||||
origin: PR https://github.com/vitorpamplona/amethyst/pull/3483 review comments (Vitor Pamplona, davotoula)
|
||||
---
|
||||
|
||||
# WoT fetch via outbox model + PR #3483 review fixes
|
||||
|
||||
## Overview
|
||||
|
||||
PR #3483 (branch `feat/wot-shared-index-relays`) adds Web-of-Trust badges + shared
|
||||
Index Relays + `amy wot` verbs. Two reviewers flagged issues:
|
||||
|
||||
- **Vitor** (owner): stop broadcasting kind-0/kind-3 REQs to a static index-relay
|
||||
list. Use the outbox model: index relays discover each author's kind-10002,
|
||||
then kind-0/kind-3 REQs go to each author's declared write relays.
|
||||
- **davotoula**: six correctness / perf / lifecycle bugs across
|
||||
`DesktopLocalCache`, `WoTService`, and `FeedMetadataCoordinator` — some
|
||||
Desktop-scoped, most in `commons/commonMain` so Android inherits them the
|
||||
moment WoT gets wired there.
|
||||
|
||||
This plan lands **both** in a single PR revision:
|
||||
|
||||
- The outbox-model refactor for kind-0 / kind-3 fetching (Vitor's ask).
|
||||
- All six correctness/perf/lifecycle fixes (davotoula's ask).
|
||||
- A sweep confirming no production default references the dying
|
||||
`relay.damus.io`.
|
||||
|
||||
The scope is intentionally larger than a normal review-fix cycle because the
|
||||
outbox refactor changes the same seams the bug-fixes touch — separating them
|
||||
would produce a churny diff.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### 1. Index-relay broadcast is architecturally wrong for kind-0 / kind-3
|
||||
|
||||
Current flow (this branch):
|
||||
|
||||
```
|
||||
Login (~350 follows) ─▶ Main.kt:1315
|
||||
└── FeedMetadataCoordinator.loadKind3Batched(follows)
|
||||
└── REQ kinds=[3] authors=[follows chunked/100]
|
||||
to *every* index relay in
|
||||
PreferencesIndexRelays.effective()
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- Every kind-3 event is fetched from index relays whether or not the author
|
||||
publishes there.
|
||||
- Users who *only* publish to their own outbox (increasingly common on modern
|
||||
Nostr) return no kind-3 — WoT signal is wrong (undercount).
|
||||
- Index-relay operators absorb the entire follow set's worth of REQ authors,
|
||||
even when other relays hold the data.
|
||||
- The same anti-pattern exists for kind-0 profile metadata (via
|
||||
`loadMetadataBatched`) and inside `amy wot sync` (which reimplements the same
|
||||
broadcast in `WotCommand.sync`).
|
||||
|
||||
Vitor's directive (quoting review):
|
||||
|
||||
> Kind 0 and 3 must be downloaded from the outbox relay (10002, write) of each
|
||||
> user. Basically, find all 10002 events via index relays (purple pages, etc),
|
||||
> then parse them all to find a list of relays per author, invert the map to
|
||||
> get a list of authors per relay, then use that list to download posts, kind
|
||||
> 0 and contact lists from each author.
|
||||
|
||||
### 2. Six correctness / perf / lifecycle bugs
|
||||
|
||||
| # | File:line | Symptom | Severity |
|
||||
|---|-----------|---------|----------|
|
||||
| 1 | `DesktopLocalCache.kt:509-530` | `accountPubkey` race → self kind-3 stamped `lastContactListByAuthor` before self-check; later relay retry rejected by `createdAt <= prev`; empty follow view; `FollowAction.follow` calls `createFromScratch(...)` and **wipes real follow list** | **P0 (data loss)** |
|
||||
| 2 | `commons/wot/WoTService.kt:162-190` | `handleFollowSet` sets `myFollows` before the `MAX_FOLLOWS` guard, guard doesn't return `myFollows` to empty, and `Main.kt:1561` still calls `loadKind3Batched` when over the cap — CPU/memory blow-up the PR description promised was skipped | P1 (perf regression on mega-follow accounts) |
|
||||
| 3 | `commons/wot/WoTService.kt` | No `close()/dispose()` → writer coroutine + `Channel` leak on account switch; leaks compound over long sessions | P1 (leak) |
|
||||
| 4 | `commons/wot/WoTService.kt:37-49, 149-160` | Doc claims "per-key subscriber isolation via `Snapshot.withMutableSnapshot`" — that's a mis-attribution. Per-key isolation is a `SnapshotStateMap` property, not a `withMutableSnapshot` property; the `withMutableSnapshot` on *every* op just batches writes. Any consumer that reads the map iteratively (size, keys) *will* invalidate on every mutation, which the comment claims won't happen. Future Android integrator will trust the comment. | P2 (misleading docs → landmine) |
|
||||
| 5 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:320-368` | `queuedKind3Pubkeys` marks pubkeys sent, never reset on failure. If every index relay times out (mobile flake / cold-start), WoT stays empty for the entire session; `loadKind3Batched` will short-circuit thereafter. | P1 (silent WoT-empty session) |
|
||||
| 6 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:274, 338` | `eoseReceived: MutableSet` written from per-relay `Dispatchers.IO` `onEose` callbacks with no sync → race can drop an EOSE, blocking on the full 5 s timeout instead of firing early. Low ceiling but pre-existing pattern that this PR duplicates. | P2 (perf / responsiveness) |
|
||||
|
||||
Additional owner-flagged item:
|
||||
- `relay.damus.io` shutting down end of month. Confirmed: no production
|
||||
default on this branch references it. Only commonTest fixtures do — leave
|
||||
those alone (they're wire-format fixtures, not runtime relay lists).
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Outbox refactor: two-phase discovery
|
||||
|
||||
Replace the single "broadcast a kind-3 REQ to all index relays" flow with a
|
||||
two-phase pipeline that reuses existing Quartz infrastructure. The pipeline
|
||||
lives in `commons/commonMain` so **Desktop, Android (future), and `amy`** all
|
||||
share it.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 1 — kind-10002 discovery (index-relay REQ) │
|
||||
│ inputs: pubkeys[], indexRelays[] │
|
||||
│ emits: Map<HexKey, Set<NormalizedRelayUrl>> │
|
||||
│ (author → declared write relays) │
|
||||
│ │
|
||||
│ • REQ kinds=[10002] authors=chunked-by-100 │
|
||||
│ to every index relay. │
|
||||
│ • Feed matching AdvertisedRelayListEvent into │
|
||||
│ LocalCache (so future lookups skip the REQ). │
|
||||
│ • Per-relay timeout (default 4s), NOT one global.│
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 2a — RelayListRecommendationProcessor │
|
||||
│ inputs: authorMap from Phase 1 │
|
||||
│ emits: Set<RelayRecommendation> │
|
||||
│ (relay → author set, minimal cover) │
|
||||
│ │
|
||||
│ Reuses Quartz's existing algorithm which: │
|
||||
│ • builds relay → author set (transpose) │
|
||||
│ • greedily picks most-popular relay, removes │
|
||||
│ covered authors, repeats │
|
||||
│ • second pass to ensure ≥2-relay coverage per │
|
||||
│ author │
|
||||
│ • filters onion/localhost per config │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 2b — per-relay kind 0 + kind 3 REQ │
|
||||
│ For each RelayRecommendation: │
|
||||
│ REQ kinds=[0,3] authors=[recommendation.users] │
|
||||
│ with per-relay timeout, single subscription. │
|
||||
│ Events flow into LocalCache via existing │
|
||||
│ consume path. │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Phase 3 — Fallback for authors without 10002 │
|
||||
│ Authors in the input set that never returned a │
|
||||
│ 10002 fall back to the current index-relay flow │
|
||||
│ (REQ kinds=[0,3] authors=[fallbackSet] on index │
|
||||
│ relays). Bounded; only fires when non-empty. │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
onEose() → WoTService.markReadyOnce()
|
||||
```
|
||||
|
||||
Global 2 s startup fallback in `Main.kt` stays as the outermost safety net.
|
||||
|
||||
### Bug fixes (correctness first, always)
|
||||
|
||||
**Fix 1 — `DesktopLocalCache` accountPubkey race.** Make `accountPubkey`
|
||||
either a constructor parameter or a required init that must resolve *before*
|
||||
hydration starts. Reorder `Main.kt` so `localCache.accountPubkey =
|
||||
account.pubKeyHex` runs before `localRelayStore.hydrate(localCache)`. Belt +
|
||||
braces: inside `consumeContactList`, do not stamp `lastContactListByAuthor`
|
||||
for events where `event.pubKey == accountPubkey` unless the self path
|
||||
actually accepted the event. This eliminates the "poisoned stamp" for the
|
||||
future relay retry even if a caller ever forgets to bind pubkey first.
|
||||
|
||||
**Fix 2 — MAX_FOLLOWS guard bypass.** Two-part fix:
|
||||
- In `WoTService.handleFollowSet`, when the follow set exceeds `MAX_FOLLOWS`,
|
||||
set `myFollows = emptySet()` *and* flip a `disabled: Boolean` flag. Both
|
||||
`handleKind3` and every future op must early-return on `disabled`.
|
||||
- In the outbox driver's entrypoint (formerly `Main.kt:1561`), consult
|
||||
`WoTService.isDisabled` (new StateFlow) or `follows.size <=
|
||||
WoTService.MAX_FOLLOWS` before dispatching Phase 1. When over the cap:
|
||||
skip Phase 1 + 2 entirely and call `markReadyOnce()` immediately.
|
||||
|
||||
**Fix 3 — `WoTService.close()`.** Add:
|
||||
|
||||
```kotlin
|
||||
private val supervisor = SupervisorJob(scope.coroutineContext[Job])
|
||||
private val serviceScope = CoroutineScope(scope.coroutineContext + supervisor + writerDispatcher)
|
||||
|
||||
fun close() {
|
||||
ops.close()
|
||||
supervisor.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
Call from account-switch (Main.kt clear path) and from `DesktopIAccount`
|
||||
disposal. Add an internal `AutoCloseable` implement so callers can lean on
|
||||
`use { }`.
|
||||
|
||||
**Fix 4 — Correct the misleading comments.** Rewrite `WoTService` KDoc to say:
|
||||
|
||||
> Scores are exposed via a Compose-observable `SnapshotStateMap`. Consumers
|
||||
> that read a *specific key* (`scores[pubkey]`) recompose only when that key
|
||||
> changes — this is `SnapshotStateMap`'s per-key observation. Consumers that
|
||||
> iterate the map or read its size will recompose on any mutation.
|
||||
>
|
||||
> Ops are serialized through a single-writer `Channel`. Coalescing writes
|
||||
> inside `Snapshot.withMutableSnapshot { }` batches state commits so a
|
||||
> multi-key op emits a single Compose invalidation instead of one per key.
|
||||
|
||||
No behaviour change; the comment is the fix.
|
||||
|
||||
**Fix 5 — `queuedKind3Pubkeys` retryable.** Convert the current mark-on-send
|
||||
set into mark-on-EOSE:
|
||||
- Track `inFlight: MutableSet<HexKey>` for de-duplication during a single call.
|
||||
- On successful EOSE (or per-relay EOSE), move pubkeys into `succeeded`
|
||||
(unchanged behaviour: skip future REQs).
|
||||
- On global timeout with zero events for a pubkey, **do not** promote to
|
||||
`succeeded`; keep them retryable on the next `loadKind3Batched` /
|
||||
`loadKind3ViaOutbox` call.
|
||||
- Cheap: same `Set` mechanics, just gated by outcome instead of intent.
|
||||
|
||||
**Fix 6 — Synchronise `eoseReceived`.** Two options; pick (b):
|
||||
- (a) Wrap in `Mutex` / `synchronized` — `synchronized` needs a JVM-only
|
||||
path or an `expect/actual`.
|
||||
- (b) **Use a single-writer coroutine**: replace the `MutableSet` +
|
||||
`CompletableDeferred<Unit>` handshake with a `Channel<NormalizedRelayUrl>(
|
||||
capacity = Channel.UNLIMITED)` + a launched consumer that increments a
|
||||
local counter and completes the deferred when it hits `indexRelays.size`.
|
||||
Same shape, zero shared mutable state across dispatchers. KMP-clean.
|
||||
|
||||
Apply the same fix to both `loadKind3Batched` and `loadMetadataBatched`
|
||||
because the pattern is duplicated.
|
||||
|
||||
### Damus sweep
|
||||
|
||||
Grep of the current branch found `relay.damus.io` only in test fixtures
|
||||
(`FeedDefinitionSerializerTest`, `TorRelayEvaluationTest`, `RichTextParserTest`,
|
||||
`ZapSplitResolverTest`). None are production defaults. `DEFAULT_INDEX_RELAYS`
|
||||
= `{nos.lol, nostr.wine, noswhere, primal.net}`;
|
||||
`AmethystDefaults.DefaultIndexerRelayList` = `{purplepages, coracle, userkinds,
|
||||
yabu, nostr1}`. Leave the test fixtures alone (they exercise URL parsing on
|
||||
canonical example URLs — replacing them adds churn without protecting users).
|
||||
|
||||
Include a one-line status note in the PR description so Vitor sees "checked".
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture — where each piece lives
|
||||
|
||||
Following the codebase-specific rule (`commons/ARCHITECTURE.md`): "protocol
|
||||
in Quartz, business logic in commons, layouts in platform apps."
|
||||
|
||||
```
|
||||
quartz/ (unchanged — reuse only)
|
||||
nip65RelayList/AdvertisedRelayListEvent.kt — parser (existing)
|
||||
nip65RelayList/RelayListRecommendationProcessor — transpose + cover (existing)
|
||||
|
||||
commons/commonMain/
|
||||
wot/WoTService.kt — bug fixes 2/3/4
|
||||
wot/OutboxDispatcher.kt — NEW (Phase 1-3 driver)
|
||||
wot/OutboxRelayLoader.kt — MOVED from amethyst/,
|
||||
Flow<Map<relay, authors>>
|
||||
relayClient/assemblers/FeedMetadataCoordinator.kt — bug fixes 5/6 + calls
|
||||
into OutboxDispatcher when
|
||||
configured
|
||||
|
||||
desktopApp/jvmMain/
|
||||
Main.kt — reorder localCache init,
|
||||
call OutboxDispatcher
|
||||
cache/DesktopLocalCache.kt — bug fix 1
|
||||
— new consumeAdvertisedRelayList
|
||||
path
|
||||
|
||||
cli/
|
||||
commands/WotCommand.kt — amy wot sync via
|
||||
OutboxDispatcher
|
||||
```
|
||||
|
||||
### OutboxDispatcher API (draft)
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>, // lazy — respects settings updates
|
||||
private val cache: OutboxCacheGateway, // interface, actual = DesktopLocalCache
|
||||
private val perRelayTimeoutMs: Long = 4_000,
|
||||
) {
|
||||
data class Result(
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 and kind-0 for [authors] via each author's declared write
|
||||
* relays (NIP-65). Falls back to [indexRelays] for authors with no 10002.
|
||||
*
|
||||
* Suspending — returns after every phase EOSEs or times out. Callers
|
||||
* that need "return immediately, mark ready later" should wrap in
|
||||
* [scope.launch].
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result // WoT-specific
|
||||
}
|
||||
|
||||
interface OutboxCacheGateway {
|
||||
/** Returns the cached kind-10002 for [pubkey] if the local store already has one. */
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
/** Called for every 10002 that comes back — cache should stash it. */
|
||||
fun onOutboxDiscovered(event: AdvertisedRelayListEvent, relay: NormalizedRelayUrl)
|
||||
/** Called for every kind-3 / kind-0 that comes back — cache should route through its consume path. */
|
||||
fun onDiscoveredEvent(event: Event, relay: NormalizedRelayUrl)
|
||||
}
|
||||
```
|
||||
|
||||
`DesktopLocalCache` implements `OutboxCacheGateway`; `amy` gets a minimal
|
||||
implementation that writes into its local store.
|
||||
|
||||
`OutboxRelayLoader` (moved from `amethyst/`) provides the *live* Flow-form for
|
||||
reactive lookups; `OutboxDispatcher` uses it internally for the "check cache
|
||||
first, only REQ what's missing" fast-path.
|
||||
|
||||
### Reactivity for "new follow arriving"
|
||||
|
||||
Current code (Main.kt:1559) collects `localCache.followedUsers` and calls
|
||||
`loadKind3Batched(follows)` on every change. The dedup set means the diff
|
||||
(only new pubkeys) actually flows through.
|
||||
|
||||
Under the outbox model, the analogous flow is:
|
||||
|
||||
```
|
||||
localCache.followedUsers.collect { follows ->
|
||||
wotService.onFollowSetChange(follows, account.pubKeyHex)
|
||||
if (wotService.isDisabled) { wotService.markReadyOnce(); return@collect }
|
||||
launch {
|
||||
val result = outboxDispatcher.fetchKind3Only(follows) // dedup inside
|
||||
wotService.markReadyOnce()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fetchKind3Only` internally consults `inFlight` + `succeeded` and only REQs
|
||||
the diff. Test scenario "user follows one new person mid-session" trivially
|
||||
covered because Phase 1 for a single-element authors set is a single index-
|
||||
relay REQ, and Phase 2 is one per-outbox REQ.
|
||||
|
||||
### `amy wot sync` under outbox
|
||||
|
||||
Replace the manual `Filter/chunked/ctx.drain(...)` block in
|
||||
`WotCommand.sync` with:
|
||||
|
||||
```kotlin
|
||||
val dispatcher = OutboxDispatcher(client, scope, ctx::indexRelays, AmyCacheGateway(store))
|
||||
val result = dispatcher.fetchKind3Only(follows.toSet())
|
||||
Output.emit("wot sync",
|
||||
"10002=${result.kind10002Received} kind3=${result.kind3Received} " +
|
||||
"fallback=${result.fallbackAuthors}")
|
||||
```
|
||||
|
||||
The JSON schema for `--json` gains three new keys (`kind10002_received`,
|
||||
`fallback_authors`, `kind3_received`) — additive, no rename.
|
||||
|
||||
### Concurrency & KMP concerns
|
||||
|
||||
- All new code targets `commonMain`. No `java.util.concurrent`, no
|
||||
`synchronized {}` (needs jvmAndroid actual). Rely on `Channel`, `Mutex`,
|
||||
`StateFlow`, and `Snapshot` — all KMP-safe.
|
||||
- `Dispatchers.IO` isn't KMP either; use `Dispatchers.Default` in commonMain
|
||||
and let platform code override if needed.
|
||||
- Per-relay timeouts implemented via `withTimeoutOrNull(perRelayTimeoutMs)`
|
||||
inside per-relay coroutines; overall EOSE gate uses a
|
||||
`CompletableDeferred<Unit>` that trips when either (a) all per-relay jobs
|
||||
complete or (b) the outer `withTimeoutOrNull(overallCap)` fires.
|
||||
|
||||
### Data flow: how discovered 10002s stop double-fetching
|
||||
|
||||
Every `AdvertisedRelayListEvent` received during Phase 1 goes through
|
||||
`OutboxCacheGateway.onOutboxDiscovered(event, relay)` → the platform cache's
|
||||
`consume` path. Next call for the same author checks `cachedOutbox(pubkey)`
|
||||
before dispatching Phase 1, so we never REQ the same 10002 twice within a
|
||||
session (or across sessions, if the local relay store persists the 10002 —
|
||||
which it does, since kind-10002 events are indexed like any other event).
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1 — Bug fixes (correctness first, self-contained)
|
||||
|
||||
Ship-blockers, no outbox dependency, land these commits first so a revert
|
||||
doesn't force rolling back the outbox refactor:
|
||||
|
||||
1. `fix(desktop-cache): eliminate accountPubkey race in
|
||||
consumeContactList` — reorder Main.kt so pubkey binds before hydrate;
|
||||
gate `lastContactListByAuthor` stamp inside self branch. Test:
|
||||
`DesktopLocalCacheHydrationTest` — reproduce the wipe by running
|
||||
hydration before pubkey bind, assert follow set survives relay retry.
|
||||
2. `fix(wot): clear myFollows + set disabled flag when MAX_FOLLOWS exceeded`
|
||||
— plus a Main.kt short-circuit before dispatching Phase 1. Test:
|
||||
`WoTServiceTest.overCapDisablesEverything`.
|
||||
3. `refactor(wot): close()/dispose() + AutoCloseable, call from
|
||||
account-switch` — Test: `WoTServiceLifecycleTest.closeCancelsWriter`.
|
||||
4. `docs(wot): correct SnapshotStateMap isolation comments` — comment-only.
|
||||
5. `fix(coordinator): mark queuedKind3Pubkeys only on EOSE, allow retry on
|
||||
timeout` — Test: `FeedMetadataCoordinatorTest.timeoutRetryIsAllowed`.
|
||||
6. `fix(coordinator): single-writer EOSE aggregator (KMP-safe)` — Test:
|
||||
`FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks`
|
||||
using a fake client that fires EOSE from multiple dispatchers.
|
||||
|
||||
**Success criteria phase 1:** all six tests pass; `./gradlew :commons:jvmTest
|
||||
:desktopApp:jvmTest :cli:test` green; `./gradlew spotlessApply` clean.
|
||||
|
||||
#### Phase 2 — Outbox scaffolding (commons)
|
||||
|
||||
7. `refactor(commons): move OutboxRelayLoader from amethyst/ to
|
||||
commons/commonMain` — pure code motion; leave a re-export in the amethyst
|
||||
package to avoid Android build breaks. Test: existing Android
|
||||
`OutboxRelayLoaderTest` (if any) still passes.
|
||||
8. `feat(commons): OutboxDispatcher two-phase kind-0/kind-3 fetcher` —
|
||||
commonMain, plus jvmMain test that drives a fake `INostrClient` through
|
||||
Phase 1/2/3 including the fallback path.
|
||||
9. `feat(commons): OutboxCacheGateway interface + DesktopLocalCache impl` —
|
||||
including a new `consumeAdvertisedRelayList(event, relay)` in
|
||||
`DesktopLocalCache` that mirrors the existing `consumeContactList` pattern.
|
||||
|
||||
**Success criteria phase 2:** `./gradlew :commons:jvmTest` green;
|
||||
`OutboxDispatcherTest` covers "author with 10002", "author without 10002 →
|
||||
fallback", "index relay times out on Phase 1", and "per-relay timeout on
|
||||
Phase 2 doesn't cancel other relays".
|
||||
|
||||
#### Phase 3 — Cutover (Main.kt + amy)
|
||||
|
||||
10. `feat(desktop): route WoT kind-3 fetch through OutboxDispatcher` —
|
||||
Main.kt uses OutboxDispatcher; delete the direct `loadKind3Batched`
|
||||
call. Preserve the 2 s startup fallback for `markReadyOnce`.
|
||||
11. `feat(desktop): also route stranger-avatar kind-0 through
|
||||
OutboxDispatcher` — MetadataPreloader gets a hook that prefers outbox
|
||||
when a 10002 exists for the author.
|
||||
12. `feat(cli): amy wot sync via OutboxDispatcher` — rewrite the manual
|
||||
filter/drain in `WotCommand.sync`. Update its `--json` schema (additive).
|
||||
|
||||
**Success criteria phase 3:** manual testing sheet (Section: Test Plan)
|
||||
passes end-to-end. `./gradlew test` green.
|
||||
|
||||
#### Phase 4 — Documentation & PR description
|
||||
|
||||
13. Update PR description's "Behaviour" section to reflect the outbox flow.
|
||||
14. Add a top-level "Damus relay: production defaults verified clean" line
|
||||
so Vitor doesn't have to look.
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
**A. Do the outbox refactor in a follow-up PR.** Rejected by user: the same
|
||||
files (WoTService, FeedMetadataCoordinator, Main.kt) also need the review
|
||||
fixes, so a two-PR split would double the churn in the same seams.
|
||||
|
||||
**B. Skip Phase 1 (10002 discovery) and read the local cache only.** Would
|
||||
break for cold-start accounts with no cached 10002s. Only works for
|
||||
"warm-cache" sessions, defeating Vitor's ask on first login.
|
||||
|
||||
**C. Adopt `AmethystDefaults.DefaultIndexerRelayList` as the new index-relay
|
||||
default.** The current branch keeps `{nos.lol, nostr.wine, noswhere,
|
||||
primal.net}` for continuity. Adopting the Purple Pages / Coracle / etc. set
|
||||
is a user-visible behaviour change deserving its own review. Deferred to a
|
||||
follow-up ticket. Documented in `PreferencesIndexRelays.kt:85-92` already;
|
||||
no action here.
|
||||
|
||||
**D. Use `graperank` as Vitor idly mused in the follow-up comment.** Not
|
||||
actionable in this PR — it's a musing about extending `amy`, not a review
|
||||
change. Called out here so the item doesn't get lost, but leave for a
|
||||
future ticket.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
```
|
||||
Login
|
||||
└─ Main.kt:1541 LaunchedEffect binds localCache.accountPubkey
|
||||
└─ (Fix 1: must run BEFORE the block below)
|
||||
└─ Main.kt:893 launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) }
|
||||
└─ per-event: localCache.justConsumeMyOwnEvent → consumeContactList
|
||||
└─ before fix: stamps lastContactListByAuthor with null accountPubkey
|
||||
└─ after fix: stamp only inside self-branch, or after ordering guarantee
|
||||
|
||||
Login (parallel)
|
||||
└─ Main.kt:1559 collect(followedUsers) →
|
||||
└─ WoTService.onFollowSetChange
|
||||
└─ ops.trySend(FollowSet) → writerLoop → handleFollowSet
|
||||
└─ Fix 2: MAX_FOLLOWS → clear myFollows + disabled=true, return
|
||||
└─ if !disabled: OutboxDispatcher.fetchKind3Only(follows)
|
||||
└─ Phase 1: REQ 10002 on index relays
|
||||
└─ OutboxCacheGateway.onOutboxDiscovered → DesktopLocalCache.consumeAdvertisedRelayList
|
||||
└─ Phase 2a: RelayListRecommendationProcessor.reliableRelaySetFor
|
||||
└─ Phase 2b: per-relay REQ kind=[0,3] authors=[relay's users]
|
||||
└─ OutboxCacheGateway.onDiscoveredEvent → LocalCache.consume path
|
||||
└─ consumeContactList (fixed) → _contactListEvents.tryEmit
|
||||
└─ WoTService.applyKind3 → handleKind3 → updateScore
|
||||
└─ Phase 3: fallback for missing-10002 authors → index-relay REQ
|
||||
└─ WoTService.markReadyOnce → _isReady.value = true → badge composables recompose
|
||||
|
||||
Account switch
|
||||
└─ Main.kt:874 localCache.clear() → resets lastContactListByAuthor
|
||||
└─ Fix 3: WoTService.close() → cancels writer, drops ops channel
|
||||
└─ OutboxDispatcher scope cancels → in-flight REQs unsubscribe
|
||||
```
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
- `client.subscribe` failure inside `OutboxDispatcher` → swallowed at the
|
||||
per-relay coroutine level, logged, moves on. The overall `withTimeoutOrNull`
|
||||
ensures the caller never blocks past its budget.
|
||||
- `AdvertisedRelayListEvent.writeRelaysNorm()` returning null (author has a
|
||||
10002 but empty write list) → falls through to Phase 3 fallback.
|
||||
- Cache consume path errors (e.g. corrupt event) → existing `LocalCache`
|
||||
behavior; not new.
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- Between `WoTService.close()` and `OutboxDispatcher` scope cancel there's a
|
||||
small window where a pending REQ EOSE could arrive at a torn-down service.
|
||||
Mitigation: `OutboxCacheGateway.onDiscoveredEvent` and
|
||||
`WoTService.applyKind3` must be null-guarded against the "already-closed"
|
||||
state — WoTService's writerLoop naturally handles this (channel closed →
|
||||
loop exits).
|
||||
- Fix 1 requires Main.kt reordering; if the reorder is done wrong and pubkey
|
||||
bind is *later* than hydration, the bug recurs silently. Test:
|
||||
`DesktopLocalCacheHydrationTest.regressionOrderingProtection`.
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
`amy wot sync` and Desktop login both consume the same `OutboxDispatcher`,
|
||||
so any protocol change propagates. Android is a future consumer — the
|
||||
plan intentionally lives in commons/commonMain so wiring Android on top
|
||||
is a Main.kt-equivalent + gateway impl.
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. Cold-start login, well-connected account (~350 follows, ~90% with 10002):
|
||||
Phase 1 completes, Phase 2 fetches only from write relays, Phase 3 kicks
|
||||
in for the ~10% no-10002 authors, WoT ready < 5 s, badges render.
|
||||
2. Cold-start login, ~4000-follow account: MAX_FOLLOWS trips → dispatcher
|
||||
skipped, `markReadyOnce()` immediately, no badges, no REQ traffic.
|
||||
3. Cold-start login, all index relays unreachable: overall timeout fires,
|
||||
`markReadyOnce()`; on next `followedUsers` emission, `inFlight` is empty
|
||||
(thanks to fix 5) so a retry happens.
|
||||
4. Mid-session follow: single-author `fetchKind3Only({newPubkey})` uses cache
|
||||
hit if `cachedOutbox(newPubkey) != null`, else does one Phase-1 REQ.
|
||||
5. Account switch: `WoTService.close()` runs; opening the same account again
|
||||
creates a fresh instance without leaking the previous writer coroutine.
|
||||
6. `amy wot sync` on a headless VM with only the OS event store: writes
|
||||
10002 + kind 3 events to disk; second run of `amy wot get <hex>` returns
|
||||
the correct hydrated score.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] `DesktopLocalCache.consumeContactList` no longer stamps
|
||||
`lastContactListByAuthor` for the self path unless `accountPubkey` is
|
||||
set and the event matches. Regression test exists.
|
||||
- [ ] `WoTService` exposes `isDisabled: StateFlow<Boolean>`; caller
|
||||
(Main.kt) skips OutboxDispatcher when disabled.
|
||||
- [ ] `WoTService` implements `AutoCloseable`; account-switch path calls
|
||||
`close()`.
|
||||
- [ ] `FeedMetadataCoordinator.loadKind3Batched` and
|
||||
`loadMetadataBatched` retry on timeout (pubkeys not promoted to
|
||||
`succeeded`).
|
||||
- [ ] Both `loadKind3Batched` and `loadMetadataBatched` use single-writer
|
||||
EOSE aggregation (no `MutableSet` shared across dispatchers).
|
||||
- [ ] `OutboxDispatcher.fetchKind3Only` and `fetchKind0And3` exist in
|
||||
commons/commonMain with test coverage for the four scenarios in
|
||||
"Integration Test Scenarios".
|
||||
- [ ] `Main.kt` login path uses `OutboxDispatcher` for kind-3 seeding
|
||||
(WoT + follow-set metadata).
|
||||
- [ ] `amy wot sync` uses `OutboxDispatcher`; `--json` output additively
|
||||
gains `kind10002_received`, `kind3_received`, `fallback_authors`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- [ ] `./gradlew test` green.
|
||||
- [ ] `./gradlew spotlessApply` clean before commit.
|
||||
- [ ] No production default relay list on this branch references
|
||||
`relay.damus.io` (already verified; keep it verified after refactor).
|
||||
- [ ] No use of `java.util.concurrent` / JVM-only `synchronized {}` in
|
||||
`commons/commonMain/`.
|
||||
- [ ] KDoc for `WoTService` accurately describes SnapshotStateMap
|
||||
per-key isolation.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Manual regression sheet covering integration scenarios 1-6 above.
|
||||
- [ ] `amy wot sync --json` sample output attached to PR description.
|
||||
- [ ] Follow-list-wipe regression covered by an automated test that
|
||||
hydrates a cached kind-3 before binding pubkey and asserts nothing is
|
||||
poisoned.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- WoT badge coverage on real accounts (Vitor's expected win): jump from
|
||||
"index-relay-published authors only" to "any author with a 10002" —
|
||||
measured by running the desktop app before/after and diffing the badge
|
||||
count on a fixed follow-set.
|
||||
- Zero follow-list-wipe reports in the two weeks after merge (davotoula
|
||||
finding 1 was worst-case data loss).
|
||||
- Zero index-relay REQ traffic for kind-0/kind-3 authors that publish a
|
||||
10002. Measurable by wireshark on a test build.
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- Quartz `AdvertisedRelayListEvent` + `RelayListRecommendationProcessor` —
|
||||
already exist, reused verbatim.
|
||||
- `INostrClient.subscribe(subId, filters, listener)` — already exists.
|
||||
- `DesktopLocalCache` needs a new `consumeAdvertisedRelayList(event, relay)`
|
||||
method — mirrors existing `consumeContactList` structure.
|
||||
- `OutboxRelayLoader` — moved from `amethyst/` to `commons/commonMain`;
|
||||
Android continues to compile because it only depends on things
|
||||
already in commons/quartz.
|
||||
|
||||
No new third-party libraries introduced. No `libs.versions.toml` change.
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Outbox refactor changes badge counts on live user accounts unexpectedly | Med | Med | Keep the Phase-3 fallback path so no author gets *worse* coverage than today. Manual A/B test on maintainer's account before merge. |
|
||||
| `RelayListRecommendationProcessor.reliableRelaySetFor` picks pathologically many relays for a fragmented follow set | Low | Low | Algorithm already caps by second-pass "at least 2 relays per author" rule. Add a hard `MAX_RELAYS_PER_FETCH` (say 40) as a belt-and-braces guard. |
|
||||
| Concurrent EOSE handshake rewrite introduces a new bug | Low | High | Test `FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks` with a fake client firing EOSE from three dispatchers 1000× to catch ordering assumptions. |
|
||||
| Bug fix 1 (Main.kt reordering) breaks another consumer that read localCache before accountPubkey bind | Med | Med | Grep for all `localCache.accountPubkey` reads; verify none pre-date the bind. If any, thread the pubkey through as a parameter. |
|
||||
| Adopting `AutoCloseable` on `WoTService` misleads callers into thinking it's `use`-scoped | Low | Low | Comment on `close()` says "call from account-switch/dispose only; instance lives for the account session". |
|
||||
| `amy wot sync --json` schema change breaks downstream scripts | Med | Low | Additive fields only, no renames. Document in `cli/plans/*` if a plan exists there. |
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
- One engineer, ~2-3 days including tests + manual regression.
|
||||
- Test-relay access: can use `wss://nos.lol` and Purple Pages for real
|
||||
Phase 1 verification.
|
||||
- Access to a mega-follow test account (>2000 follows) to verify Fix 2.
|
||||
- Access to an account with a well-populated 10002 network to verify
|
||||
Phase 2 does what we think.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Android wiring: `AndroidApp` currently doesn't wire WoTService. When it
|
||||
does, it can lean on the same `OutboxDispatcher` — expected diff is
|
||||
Main-equivalent + a `LocalCache` gateway.
|
||||
- Graperank scoring (Vitor's follow-up musing): if `amy wot` grows a scoring
|
||||
strategy plugin API, `OutboxDispatcher` remains unchanged; only the
|
||||
post-fetch aggregation layer inside `WoTService` changes.
|
||||
- Adopting `AmethystDefaults.DefaultIndexerRelayList`: separate ticket.
|
||||
Deserves its own review because it's a user-visible behaviour change.
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- Update this plan's status to `completed` post-merge; write a short
|
||||
"solutions" note if the accountPubkey race surprised us elsewhere.
|
||||
- Update PR description "Behaviour" section to reflect outbox path.
|
||||
- Update `commons/ARCHITECTURE.md` "where does my code go?" section with a
|
||||
one-line entry for `OutboxDispatcher`.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **PR review comments:**
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892248528 (davotoula, bugs 1+2)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892272000 (davotoula, bugs 3-6 impact on Android)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892302009 (vitorpamplona, outbox directive)
|
||||
https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892686911 (vitorpamplona, Graperank musing — out of scope)
|
||||
|
||||
### Internal References
|
||||
|
||||
- Kind-10002 parser: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt`
|
||||
- Relay-cover algorithm: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt`
|
||||
- Existing Android outbox loader: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt`
|
||||
- WoT service (bugs 2-4): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`
|
||||
- Feed metadata coordinator (bugs 5-6): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt`
|
||||
- Cache race (bug 1): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:509-530`
|
||||
- Main wiring: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:1541-1568, 764-768, 863-874`
|
||||
- amy WoT: `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`
|
||||
- Index relay persistence: `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`
|
||||
- Baseline plan the PR extended: `desktopApp/plans/2026-07-01-feat-desktop-wot-score-plan.md`
|
||||
|
||||
### External References
|
||||
|
||||
- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md
|
||||
- Original plan for the current PR: `docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md`
|
||||
|
||||
### Related Work
|
||||
|
||||
- PR #3483 (this PR): https://github.com/vitorpamplona/amethyst/pull/3483
|
||||
- Prior WoT badge PR (base for this branch): `feat/desktop-wot-score`
|
||||
|
||||
## Unanswered questions
|
||||
|
||||
- Per-relay timeout budget — 4 s picked from thin air. Real number?
|
||||
- Should the fallback in Phase 3 also hit the account's own home/search
|
||||
relays, matching Android's `pickRelaysToLoadUsers` cascade? Or index-only?
|
||||
- WoTService.close() called from account-switch — what's the canonical
|
||||
disposal hook on Desktop? DesktopIAccount teardown?
|
||||
- amy wot sync `--json` schema — is `fallback_authors` the right name, or
|
||||
match Android naming?
|
||||
- Should `OutboxDispatcher` be a per-account singleton (like WoTService) or
|
||||
short-lived per fetch? Leaning singleton for the dedup set.
|
||||
- Do we want to persist the "author has no 10002" fact so we skip Phase 1
|
||||
for them on next login? Requires a small persistent map — worth it?
|
||||
- Should Fix 1 (accountPubkey race) be split into its own hotfix commit
|
||||
before the outbox refactor lands, so backporters have a clean cherry-pick?
|
||||
+44
@@ -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.commons.defaults
|
||||
|
||||
/**
|
||||
* Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050).
|
||||
*
|
||||
* Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver]
|
||||
* via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an
|
||||
* AUTH event back to the user's identity key (security review F-01: an
|
||||
* authenticated indexer fan-out turns "indexer learns we queried for pubkey X"
|
||||
* into "indexer learns Amethyst user U queried for pubkey X").
|
||||
*
|
||||
* Set selected for known kind:10050 indexing coverage; `purplepag.es` is
|
||||
* deliberately excluded (metadata indexer, weak kind:10050 coverage).
|
||||
*/
|
||||
object DefaultDmIndexerRelays {
|
||||
val RELAYS: List<String> =
|
||||
listOf(
|
||||
"wss://relay.nos.social",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://purplerelay.com",
|
||||
)
|
||||
}
|
||||
@@ -114,6 +114,24 @@ class User(
|
||||
|
||||
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
|
||||
|
||||
/**
|
||||
* Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17
|
||||
* inbox relays (kind:10050) and never falls back to the NIP-65 read
|
||||
* marker (kind:10002).
|
||||
*
|
||||
* Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in
|
||||
* the recipient's kind:10050 — the NIP-65 read fallback in
|
||||
* [dmInboxRelays] is a UI-convenience heuristic that leaks the DM
|
||||
* metadata to relays the recipient did not designate for DMs. Any code
|
||||
* that decides "can I actually deliver a NIP-17 wrap to this user"
|
||||
* should call this strict variant; UI hints and probe-time bootstrap
|
||||
* paths may continue to use the lenient one.
|
||||
*
|
||||
* Returns `null` when the recipient has no published kind:10050 (or an
|
||||
* empty one) — callers treat this as "unreachable via NIP-17".
|
||||
*/
|
||||
fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null }
|
||||
|
||||
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay()
|
||||
|
||||
fun allUsedRelaysOrNull() = relays?.allOrNull()
|
||||
|
||||
+1
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
|
||||
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
|
||||
* is asked rather than surprised.
|
||||
*/
|
||||
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
|
||||
val REASONABLE_SIGN_KINDS: Set<Int> =
|
||||
setOf(
|
||||
TextNoteEvent.KIND, // 1 — short text notes & replies
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.commons.privacylock
|
||||
|
||||
/**
|
||||
* Routes gated by the privacy lock.
|
||||
*
|
||||
* A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes
|
||||
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
|
||||
* idle-timer, and leave-route transitions apply independently per route.
|
||||
*/
|
||||
enum class LockScope { Messages, Wallet }
|
||||
+2
-2
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface PrivacyLockSettings {
|
||||
val lockEnabled: StateFlow<Boolean>
|
||||
val inactivityTimer: StateFlow<InactivityTimer>
|
||||
val redactionLevel: StateFlow<DmRedactionLevel>
|
||||
val dmRedactionLevel: StateFlow<DmRedactionLevel>
|
||||
val firstRunCardSeen: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ interface PrivacyLockSettings {
|
||||
|
||||
fun setInactivityTimer(timer: InactivityTimer)
|
||||
|
||||
fun setRedactionLevel(level: DmRedactionLevel)
|
||||
fun setDmRedactionLevel(level: DmRedactionLevel)
|
||||
|
||||
fun setFirstRunCardSeen(seen: Boolean)
|
||||
|
||||
|
||||
+41
-14
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-global state holder for the Messages privacy lock.
|
||||
* App-global state holder for a single privacy-lock [scope].
|
||||
*
|
||||
* One instance per gated route (Messages, Wallet, …) is provided via
|
||||
* [LocalPrivacyLockState] at the App composition root. All instances share
|
||||
* the same [PrivacyLockSettings] — one master `lockEnabled` flag enables
|
||||
* every scope together — but each scope keeps its own [LockState] and its
|
||||
* own idle-timer [Job] so unlock, leave-route, and inactivity transitions
|
||||
* apply independently per route.
|
||||
*
|
||||
* - Single instance per app, provided via [LocalMessagesLockState] at the
|
||||
* App composition root.
|
||||
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
|
||||
* so the first composition sees [LockState.Locked] without flashing
|
||||
* content (deep-link race fix, plan §Security Hardening H1).
|
||||
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
|
||||
* can read `state.value` synchronously without subscribing.
|
||||
*/
|
||||
class MessagesLockState(
|
||||
class PrivacyLockState(
|
||||
val scope: LockScope,
|
||||
private val settings: PrivacyLockSettings,
|
||||
private val scope: CoroutineScope,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
private val seed: LockState =
|
||||
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
|
||||
@@ -64,12 +72,12 @@ class MessagesLockState(
|
||||
} else if (mutableState.value is LockState.Disabled) {
|
||||
mutableState.value = LockState.Locked
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
|
||||
.onEach { _ ->
|
||||
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
/** Resets the inactivity timer. No-op unless currently Unlocked. */
|
||||
@@ -90,7 +98,7 @@ class MessagesLockState(
|
||||
* Mark the session as authenticated. Transitions from either
|
||||
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
|
||||
* (first-run banner path — enabling the lock while the user is
|
||||
* actively in Messages should NOT flash the lock screen).
|
||||
* actively in a gated route should NOT flash the lock screen).
|
||||
* No-op if already [LockState.Unlocked]. Starts the idle timer.
|
||||
*/
|
||||
fun onUnlockSuccess() {
|
||||
@@ -105,7 +113,8 @@ class MessagesLockState(
|
||||
|
||||
/**
|
||||
* Triggered when biometric / OS credential is permanently unavailable.
|
||||
* Auto-disables the lock so the user can keep accessing Messages.
|
||||
* Auto-disables the lock (flips every scope to [LockState.Disabled]
|
||||
* via the shared setting) so the user can keep accessing gated routes.
|
||||
*/
|
||||
fun onCredentialUnavailable() {
|
||||
cancelIdleTimer()
|
||||
@@ -118,6 +127,10 @@ class MessagesLockState(
|
||||
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
|
||||
* doubling each further failure, capped at 5 min.
|
||||
*
|
||||
* Backoff state is shared across scopes — a mistyped password on the
|
||||
* Wallet gate locks out the Messages gate too (and vice versa). This is
|
||||
* intentional anti-brute-force behaviour.
|
||||
*
|
||||
* @param nowMs current epoch millis (injected for testability).
|
||||
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
|
||||
* null when no lockout yet applies.
|
||||
@@ -148,7 +161,7 @@ class MessagesLockState(
|
||||
cancelIdleTimer()
|
||||
val millis = settings.inactivityTimer.value.millis ?: return
|
||||
idleTimerJob =
|
||||
scope.launch {
|
||||
coroutineScope.launch {
|
||||
delay(millis)
|
||||
if (mutableState.value is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Locked
|
||||
@@ -162,8 +175,22 @@ class MessagesLockState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Provided once at the App composition root. */
|
||||
val LocalMessagesLockState =
|
||||
compositionLocalOf<MessagesLockState> {
|
||||
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
/**
|
||||
* Provided once at the App composition root. Map keyed by [LockScope]; every
|
||||
* scope must have an entry (see [lockStateFor] which throws when missing).
|
||||
*/
|
||||
val LocalPrivacyLockState =
|
||||
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
|
||||
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor used inside gate composables. Reads the map from the
|
||||
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
|
||||
* Throws if the scope was not registered at the App root.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun lockStateFor(scope: LockScope): PrivacyLockState =
|
||||
LocalPrivacyLockState.current[scope]
|
||||
?: error("PrivacyLockState for $scope not registered at App root")
|
||||
+158
-9
@@ -33,13 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Coordinates metadata and reactions loading for feed items.
|
||||
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
|
||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||
private val queuedBoostedIds = mutableSetOf<HexKey>()
|
||||
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
|
||||
|
||||
// Batched paths only — pubkeys currently in-flight in a batched REQ.
|
||||
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
|
||||
// and queuedKind3Pubkeys (which record "asked and at least one relay
|
||||
// returned EOSE") so a batch that times out with zero events can be
|
||||
// retried on the next call — see PR #3483 review finding 5.
|
||||
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
||||
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Start processing the subscription queue.
|
||||
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
|
||||
/**
|
||||
* Fast-path: batched metadata subscription for visible-viewport authors.
|
||||
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
|
||||
*
|
||||
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
|
||||
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
|
||||
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
|
||||
* retry — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadMetadataBatched(
|
||||
pubkeys: List<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
) {
|
||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) return
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
inFlightBatchedMetadata.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filter =
|
||||
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
|
||||
)
|
||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
||||
val subId = newSubId()
|
||||
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eoseReceived.add(relay)
|
||||
if (eoseReceived.size >= indexRelays.size) {
|
||||
allEose.complete(Unit)
|
||||
}
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched kind-3 (follow list) subscription. Used by the WoT service
|
||||
* to fetch the follow lists of every account the active user follows,
|
||||
* so friends-of-friends counts can be computed.
|
||||
*
|
||||
* Chunks authors into ≤100 per Filter within a single subscription
|
||||
* so relays with per-filter author caps (nostr-rs-relay defaults to
|
||||
* ~100) don't silently truncate the batch. Aggregates EOSE across
|
||||
* chunks and calls [onEose] once (or after [timeoutMs]).
|
||||
*
|
||||
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
|
||||
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
|
||||
* unreachable — common on flaky mobile networks) they roll out of
|
||||
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
|
||||
* — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadKind3Batched(
|
||||
pubkeys: Collection<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
onEose: () -> Unit = {},
|
||||
) {
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) {
|
||||
onEose()
|
||||
return
|
||||
}
|
||||
inFlightBatchedKind3.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filters =
|
||||
newPubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = indexRelays.associateWith { filters }
|
||||
val subId = newSubId()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedKind3Pubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
||||
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
|
||||
priorityQueue.clear()
|
||||
queuedPubkeys.clear()
|
||||
queuedNoteIds.clear()
|
||||
queuedKind3Pubkeys.clear()
|
||||
inFlightBatchedMetadata.clear()
|
||||
inFlightBatchedKind3.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates EOSE notifications from per-relay `onEose` callbacks
|
||||
* (which the client may dispatch on `Dispatchers.IO`) via a
|
||||
* [Channel]. The consumer coroutine is the sole reader/writer of the
|
||||
* `seen` set, eliminating the race the previous `mutableSetOf` +
|
||||
* shared-state check had — see PR #3483 review finding 6.
|
||||
*
|
||||
* [awaitAll] blocks up to [timeoutMs] and returns the number of
|
||||
* relays that EOSE'd (may be less than [target] on timeout). The
|
||||
* count feeds the retry decision in the batched loaders.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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.commons.relayClient.auth
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
/**
|
||||
* Inline AUTH approval banner.
|
||||
*
|
||||
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
|
||||
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
|
||||
* with the user's choice, which the parent (typically a coordinator)
|
||||
* uses to complete the underlying [PendingAuthApproval.decision]
|
||||
* deferred and persist the scope.
|
||||
*
|
||||
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
|
||||
* (a future iteration may expand them on click — keep simple for now).
|
||||
*
|
||||
* The component is platform-agnostic and lives in `commons` so Android
|
||||
* and Desktop can render the same UX once the wire-up is built on each
|
||||
* platform.
|
||||
*/
|
||||
@Composable
|
||||
fun AuthApprovalBanner(
|
||||
pending: List<PendingAuthApproval>,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = pending.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val visible = pending.take(3)
|
||||
val hidden = pending.size - visible.size
|
||||
|
||||
visible.forEach { approval ->
|
||||
AuthApprovalRow(approval = approval, onResolve = onResolve)
|
||||
}
|
||||
|
||||
if (hidden > 0) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthApprovalRow(
|
||||
approval: PendingAuthApproval,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = approval.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (approval.pendingCount > 1) {
|
||||
"requires authentication for ${approval.pendingCount} messages"
|
||||
} else {
|
||||
"requires authentication to deliver this message"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
|
||||
Text("Once", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
|
||||
Text("Always", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
|
||||
Text("Never", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.commons.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Persisted scope for an AUTH approval decision.
|
||||
*
|
||||
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
|
||||
* persist via [AuthApprovalStore].
|
||||
*/
|
||||
enum class AuthApprovalScope {
|
||||
/** Approve this session; don't persist. */
|
||||
ONCE,
|
||||
|
||||
/** Approve indefinitely (or until the store's TTL expires the row). */
|
||||
ALWAYS,
|
||||
|
||||
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier verdict for a single AUTH challenge.
|
||||
*
|
||||
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
|
||||
* the policy hands back a [CompletableDeferred] that the UI banner completes
|
||||
* once the user picks `[Once] [Always] [Never]`.
|
||||
*/
|
||||
sealed interface AuthApprovalDecision {
|
||||
/** Auto-sign the AUTH event for this relay. */
|
||||
data object Allow : AuthApprovalDecision
|
||||
|
||||
/** Silently drop the AUTH challenge. */
|
||||
data object Block : AuthApprovalDecision
|
||||
|
||||
/**
|
||||
* Suspend the signer until the user resolves the prompt.
|
||||
*
|
||||
* @property pending populated with the user's choice when the banner is
|
||||
* actioned. The signer awaits this deferred; if it resolves to
|
||||
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
|
||||
*/
|
||||
data class Pending(
|
||||
val pending: CompletableDeferred<AuthApprovalScope>,
|
||||
) : AuthApprovalDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending tier-2 AUTH approval surfaced to the user.
|
||||
*
|
||||
* Created when the policy decides a challenge needs user consent. Subscribers
|
||||
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
|
||||
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
|
||||
*
|
||||
* `pendingCount` lets the banner coalesce multiple challenges from the same
|
||||
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
|
||||
* rather than stacking duplicate banners.
|
||||
*/
|
||||
data class PendingAuthApproval(
|
||||
val relayUrl: NormalizedRelayUrl,
|
||||
val decision: CompletableDeferred<AuthApprovalScope>,
|
||||
val pendingCount: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
|
||||
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
|
||||
*
|
||||
* `getScope` returns `null` if no decision is recorded for the relay.
|
||||
*/
|
||||
interface AuthApprovalStore {
|
||||
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
|
||||
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
|
||||
|
||||
/**
|
||||
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
|
||||
* the policy caches them in-memory for the current session only.
|
||||
*/
|
||||
suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
)
|
||||
|
||||
/** Wipe all persisted approvals. Called on account delete / logout. */
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AuthApprovalStore] used as a development scaffold and as the
|
||||
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
|
||||
* with `ONCE` scope live here for the session and are dropped on logout.
|
||||
*/
|
||||
class InMemoryAuthApprovalStore : AuthApprovalStore {
|
||||
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
|
||||
private val lock = Mutex()
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = lock.withLock { scopes[relayUrl] }
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
lock.withLock { scopes[relayUrl] = scope }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
lock.withLock { scopes.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
|
||||
* and the actual signer.
|
||||
*
|
||||
* Two tiers:
|
||||
*
|
||||
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
|
||||
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
|
||||
* no prompt. These are relays the user has already declared they trust.
|
||||
* - **Tier 2 (prompt):** anything else, with the exception of relays that
|
||||
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
|
||||
* [onPromptRequired] and suspend until the user resolves the
|
||||
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
|
||||
* `BLOCKED`, persist via the store.
|
||||
*
|
||||
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
|
||||
* decision, or surfaced to the user. There is no silent third path.
|
||||
*
|
||||
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
|
||||
* account-level pre-approval. Recomputed by the caller on Account state
|
||||
* changes. Tier 1 if the challenger is in this set.
|
||||
* @property store persistence layer (SQLite-backed in production, in-memory in
|
||||
* tests).
|
||||
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
|
||||
* surfaced to the UI. The UI subscribes to this side-channel and completes
|
||||
* the contained [CompletableDeferred] with the user's pick.
|
||||
*/
|
||||
class AuthApprovalPolicy(
|
||||
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
|
||||
val store: AuthApprovalStore,
|
||||
val onPromptRequired: (PendingAuthApproval) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Decide what to do with an AUTH challenge from `relayUrl`.
|
||||
*
|
||||
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
|
||||
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
|
||||
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
|
||||
* unknown relays.
|
||||
*/
|
||||
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
|
||||
// Persisted decision wins over tier-1: if user explicitly blocked a
|
||||
// relay that happens to also be in their outbox, respect the block.
|
||||
when (store.getScope(relayUrl)) {
|
||||
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
|
||||
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
|
||||
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
if (relayUrl in selfApprovedRelays()) {
|
||||
return AuthApprovalDecision.Allow
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<AuthApprovalScope>()
|
||||
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
|
||||
return AuthApprovalDecision.Pending(deferred)
|
||||
}
|
||||
|
||||
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
|
||||
suspend fun recordDecision(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
|
||||
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
|
||||
store.setScope(relayUrl, scope)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.commons.relayClient.nip17Dm
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery.
|
||||
*
|
||||
* Three-layer lookup, in order:
|
||||
*
|
||||
* 1. **LocalCache hit** — the caller has already seen the user's kind:10050
|
||||
* via the normal feed subscription pipeline. Cheapest; no I/O.
|
||||
* 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey
|
||||
* within the TTL. Avoids re-querying indexers when the user opens a
|
||||
* conversation list and clicks several recipients in sequence.
|
||||
* 3. **Indexer fan-out** — query a curated set of indexer relays for the
|
||||
* user's kind:10050 via [RecipientRelayFetcher]. The client passed in
|
||||
* here MUST be an **unauthenticated** instance (no [RelayAuthenticator]
|
||||
* attached) — otherwise an indexer's AUTH challenge would extract an
|
||||
* identity-key signature from the user, turning the metadata leak
|
||||
* "indexer learns who we want to DM" into "indexer learns user U wants
|
||||
* to DM pubkey X".
|
||||
*
|
||||
* Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST
|
||||
* land on relays in the recipient's kind:10050; this resolver never
|
||||
* substitutes the NIP-65 read marker as a fallback, because doing so leaks
|
||||
* DMs to relays the recipient did not explicitly designate for DMs.
|
||||
*
|
||||
* Empty result is the canonical "we don't know where to send" signal — the
|
||||
* caller should refuse to publish rather than fall back to its own relays
|
||||
* (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]).
|
||||
*
|
||||
* @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator
|
||||
* attached. Use a dedicated instance — do NOT pass the app's primary
|
||||
* client.
|
||||
* @property indexerRelays Curated indexer set. Typically
|
||||
* [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays].
|
||||
* @property localLookup Callback the resolver invokes first to check the
|
||||
* LocalCache — returns the user's current kind:10050 list or null if
|
||||
* unknown. Allows commons/headless callers to plug in a CLI-safe lookup.
|
||||
* @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open
|
||||
* question; configurable here for tests.
|
||||
* @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial
|
||||
* memory; matches typical active-conversation count for power users.
|
||||
*/
|
||||
class DmInboxRelayResolver(
|
||||
private val unauthenticatedClient: INostrClient,
|
||||
private val indexerRelays: Set<NormalizedRelayUrl>,
|
||||
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
|
||||
private val cacheTtlMs: Long = 60 * 60 * 1_000L,
|
||||
private val cacheSize: Int = 100,
|
||||
private val nowMs: () -> Long = {
|
||||
kotlin.time.Clock.System
|
||||
.now()
|
||||
.toEpochMilliseconds()
|
||||
},
|
||||
) {
|
||||
private data class Entry(
|
||||
val relays: List<NormalizedRelayUrl>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val cache = linkedMapOf<HexKey, Entry>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither
|
||||
* the LocalCache nor the indexer fan-out yielded a kind:10050.
|
||||
*/
|
||||
suspend fun resolve(pubkey: HexKey): List<NormalizedRelayUrl> {
|
||||
localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
|
||||
val now = nowMs()
|
||||
mutex.withLock {
|
||||
cache[pubkey]?.let { entry ->
|
||||
if (entry.expiresAtMs > now) {
|
||||
// Refresh LRU order on hit
|
||||
cache.remove(pubkey)
|
||||
cache[pubkey] = entry
|
||||
return entry.relays
|
||||
} else {
|
||||
cache.remove(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indexerRelays.isEmpty()) return emptyList()
|
||||
|
||||
val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays)
|
||||
// Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical
|
||||
// "unreachable" signal; caller refuses to publish.
|
||||
val relays = lists.dmInbox
|
||||
|
||||
mutex.withLock {
|
||||
cache[pubkey] = Entry(relays, now + cacheTtlMs)
|
||||
while (cache.size > cacheSize) {
|
||||
cache.remove(cache.keys.iterator().next())
|
||||
}
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
/** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */
|
||||
suspend fun invalidate(pubkey: HexKey) {
|
||||
mutex.withLock { cache.remove(pubkey) }
|
||||
}
|
||||
|
||||
/** Wipe the entire cache — e.g. on account switch. */
|
||||
suspend fun clear() {
|
||||
mutex.withLock { cache.clear() }
|
||||
}
|
||||
}
|
||||
+25
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +64,10 @@ data class ProfilePictureUrl(
|
||||
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
|
||||
* @param loadRobohash Whether to generate robohash (false = show generic icon)
|
||||
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
|
||||
* @param badge Optional overlay drawn on top of the avatar (bottom-right by
|
||||
* convention). Used by Desktop for the WoT trust-score chip; Android call
|
||||
* sites leave it null. When null the avatar renders as before (no extra
|
||||
* `Box` wrapper).
|
||||
*/
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
@@ -73,7 +79,26 @@ fun UserAvatar(
|
||||
loadProfilePicture: Boolean = true,
|
||||
loadRobohash: Boolean = true,
|
||||
useThumbnailCache: Boolean = false,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
if (badge != null) {
|
||||
Box(modifier = modifier.size(size)) {
|
||||
UserAvatar(
|
||||
userHex = userHex,
|
||||
pictureUrl = pictureUrl,
|
||||
size = size,
|
||||
modifier = Modifier,
|
||||
contentDescription = contentDescription,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
useThumbnailCache = useThumbnailCache,
|
||||
badge = null,
|
||||
)
|
||||
badge()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val avatarModifier =
|
||||
remember(size, modifier) {
|
||||
modifier
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
|
||||
/**
|
||||
* A card displaying user search result with avatar, name, and nip05/pubkey.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*
|
||||
* @param badge Optional overlay drawn on top of the avatar (bottom-right
|
||||
* by convention). Used by Desktop for the WoT trust-score chip; Android
|
||||
* call sites leave it null. Forwarded to [UserAvatar].
|
||||
*/
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
@@ -73,6 +79,7 @@ fun UserSearchCard(
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = stringResource(Res.string.accessibility_user_avatar),
|
||||
badge = badge,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ enum class PromptResult {
|
||||
|
||||
/**
|
||||
* Credential surface permanently unavailable on this device — caller
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable].
|
||||
*/
|
||||
Unavailable,
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState
|
||||
|
||||
/**
|
||||
* Observes pointer events on the Initial pass — does NOT consume them, so
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
* since they're not user input — preserves the "walked-away-from-desk"
|
||||
* protection per brainstorm resolved Q.
|
||||
*/
|
||||
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
|
||||
fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier =
|
||||
this.pointerInput(state) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate].
|
||||
* Runs the async [CredentialPrompter] path (biometric / OS credential on
|
||||
* Android + iOS). Desktop platforms use a password-input inline lock screen
|
||||
* instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`.
|
||||
*
|
||||
* Kept `internal` so the only public entry points are the per-scope Gates.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LockScreen(
|
||||
scope: LockScope,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
unlockLabel: String,
|
||||
) {
|
||||
val lockState = lockStateFor(scope)
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = unlockLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-91
@@ -20,40 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Messages route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Branch selection happens SYNCHRONOUSLY in composition — no
|
||||
* [LaunchedEffect] guard — so the chat content composable never enters
|
||||
* composition while [LockState.Locked]. Closes the deep-link race
|
||||
* (plan §Security Hardening H1).
|
||||
* [androidx.compose.runtime.LaunchedEffect] guard — so the chat content
|
||||
* composable never enters composition while [LockState.Locked]. Closes the
|
||||
* deep-link race (plan §Security Hardening H1).
|
||||
*
|
||||
* The gate is an overlay, NOT a wrapper that disposes content. While
|
||||
* locked, the [content] lambda is not invoked at all; on unlock, the
|
||||
@@ -61,12 +42,14 @@ import kotlinx.coroutines.launch
|
||||
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
|
||||
* For plain `remember` state, drafts are cleared — accept this trade-off.
|
||||
*
|
||||
* The gate also fires [MessagesLockState.onLeaveRoute] from its
|
||||
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
|
||||
* The gate also fires
|
||||
* [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute]
|
||||
* from its [DisposableEffect.onDispose] block, so navigating away locks
|
||||
* immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
@@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked -> LockScreen()
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Messages,
|
||||
title = "Messages locked",
|
||||
subtitle = "Unlock to read or send messages.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "Messages locked",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Unlock to read or send messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = "Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Wallet route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the
|
||||
* deep-link race, draft persistence, and leave-route semantics. Only the
|
||||
* [LockScope] and the lock-screen copy differ.
|
||||
*
|
||||
* Desktop apps use the platform-specific `DesktopWalletLockGate` (password
|
||||
* input inline, no async CredentialPrompter round-trip); Android + iOS
|
||||
* front ends use this composable directly.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = lockStateFor(LockScope.Wallet)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
onDispose { lockState.onLeaveRoute() }
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Wallet,
|
||||
title = "Wallet locked",
|
||||
subtitle = "Unlock to see your balance and send or receive sats.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ fun SigningAwareButton(
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
when (signingState.state) {
|
||||
is SigningOpState.Pending -> {
|
||||
is SigningOpState.Pending, is SigningOpState.Progress -> {
|
||||
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
|
||||
+36
-1
@@ -37,11 +37,29 @@ sealed class SigningOpState {
|
||||
|
||||
data object Pending : SigningOpState()
|
||||
|
||||
/**
|
||||
* Signing is in flight AND has a known step count — typically a NIP-17
|
||||
* group send via a remote signer (bunker), where the UI can usefully show
|
||||
* "Encrypting via remote signer ([current] of [total])".
|
||||
*
|
||||
* Treated as Pending for all is-pending checks via [isPending] below;
|
||||
* existing callers that branch on `is Pending` keep working unchanged.
|
||||
* New callers can render the counter when [SigningOpState] is `Progress`.
|
||||
*/
|
||||
data class Progress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val label: String? = null,
|
||||
) : SigningOpState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : SigningOpState()
|
||||
}
|
||||
|
||||
/** True when signing is in flight, regardless of whether step counts are known. */
|
||||
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
|
||||
|
||||
/**
|
||||
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
|
||||
* Observe [globalState] from a screen-level composable to show a persistent status bar.
|
||||
@@ -86,7 +104,7 @@ class SigningState {
|
||||
private set
|
||||
|
||||
suspend fun <T> execute(block: suspend () -> T): T? {
|
||||
if (state is SigningOpState.Pending) return null
|
||||
if (state.isPending()) return null
|
||||
state = SigningOpState.Pending
|
||||
GlobalSigningStatus.onPending()
|
||||
errorMessage = null
|
||||
@@ -114,6 +132,23 @@ class SigningState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the in-flight signing state with a progress counter. Use during
|
||||
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
|
||||
* show the user how far the in-flight op has progressed.
|
||||
*
|
||||
* Only takes effect while [state] is Pending or Progress — no-op
|
||||
* otherwise so callers don't have to gate on Idle/Error themselves.
|
||||
*/
|
||||
fun updateProgress(
|
||||
current: Int,
|
||||
total: Int,
|
||||
label: String? = null,
|
||||
) {
|
||||
if (!state.isPending()) return
|
||||
state = SigningOpState.Progress(current, total, label)
|
||||
}
|
||||
|
||||
private fun setError(message: String) {
|
||||
errorMessage = message
|
||||
state = SigningOpState.Error(message)
|
||||
|
||||
+15
@@ -87,6 +87,21 @@ fun SigningStatusBar(
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Progress -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
val label = opState.label ?: "Signing"
|
||||
Text(
|
||||
text = "$label (${opState.current} of ${opState.total})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Error -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
+72
-10
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Slim shared state for DM message composition.
|
||||
@@ -56,6 +59,19 @@ class ChatNewMessageState(
|
||||
val account: IAccount,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
/**
|
||||
* Optional resolver for probing kind:10050 via curated indexer relays
|
||||
* when a peer's DM inbox isn't in the local cache. When provided,
|
||||
* [updateRecipientRelayStatus] falls through to the resolver on a cache
|
||||
* miss so the pre-send UI stops falsely reporting "recipient has no DM
|
||||
* relay list" for accounts whose 10050 sits on an indexer we haven't
|
||||
* subscribed to yet.
|
||||
*
|
||||
* When null (default, e.g. Android's ChatNewMessageViewModel which
|
||||
* hasn't been wired to a resolver yet), behaviour matches the
|
||||
* cache-only strict check.
|
||||
*/
|
||||
private val dmInboxResolver: (suspend (HexKey) -> List<NormalizedRelayUrl>?)? = null,
|
||||
) {
|
||||
private val _message = MutableStateFlow(TextFieldValue(""))
|
||||
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
|
||||
@@ -86,20 +102,66 @@ class ChatNewMessageState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all recipients have DM relay lists.
|
||||
* Messages can only be sent via NIP-17, so recipients must have
|
||||
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
|
||||
* Check whether every participant in the current room is reachable via
|
||||
* NIP-17 — i.e. has a published kind:10050 that lists at least one
|
||||
* relay.
|
||||
*
|
||||
* Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict],
|
||||
* kind:10050 only) instead of the lenient [dmInboxRelays] that falls
|
||||
* back to the NIP-65 read marker — the send path also uses strict
|
||||
* resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and
|
||||
* disagreement here caused the pre-send UI to green-light sends that
|
||||
* would then fail at send time.
|
||||
*
|
||||
* Two-phase check:
|
||||
*
|
||||
* 1. Synchronous cache check — every peer's kind:10050 sits in
|
||||
* [cache]. If all present with at least one relay, unblock
|
||||
* immediately.
|
||||
* 2. Async resolver probe (only when [dmInboxResolver] is provided) —
|
||||
* for peers whose 10050 isn't cached, kick off a curated-indexer
|
||||
* fan-out. If any peer's relays turn up, update the flag to
|
||||
* unblock the composer without requiring the user to restart the
|
||||
* conversation view.
|
||||
*
|
||||
* The blocking flag is set optimistically during the probe so the
|
||||
* user still sees the warning until we've confirmed the peer is
|
||||
* genuinely unreachable via NIP-17. This preserves the "don't allow
|
||||
* silent-fail sends" invariant.
|
||||
*/
|
||||
fun updateRecipientRelayStatus() {
|
||||
val currentRoom = _room.value
|
||||
if (currentRoom != null) {
|
||||
_recipientsMissingDmRelays.value =
|
||||
currentRoom.users.any { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelays().isNullOrEmpty()
|
||||
}
|
||||
} else {
|
||||
if (currentRoom == null) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
val missing =
|
||||
currentRoom.users.filter { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelaysStrict().isNullOrEmpty()
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss → block optimistically, then probe indexers for the
|
||||
// missing peers. If any of them turn up a kind:10050, unblock.
|
||||
_recipientsMissingDmRelays.value = true
|
||||
val resolver = dmInboxResolver ?: return
|
||||
|
||||
scope.launch {
|
||||
val stillMissing =
|
||||
missing.any { hexKey ->
|
||||
val fanOut = resolver(hexKey)
|
||||
fanOut.isNullOrEmpty()
|
||||
}
|
||||
// The room may have changed while we were probing; only apply
|
||||
// the result if we're still looking at the same conversation.
|
||||
if (_room.value == currentRoom) {
|
||||
_recipientsMissingDmRelays.value = stillMissing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.commons.wot
|
||||
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/**
|
||||
* Compose-observable score service. Provided by the Desktop app at App
|
||||
* root when a user is logged in. Left null on Android and while logged
|
||||
* out — leaf composables branch on `LocalWoTService.current == null` to
|
||||
* skip the WoT rendering path.
|
||||
*
|
||||
* The badge-hide predicates (self, already-followed) are read from
|
||||
* `commons.moderation.LocalSpamExemptKeys` — the same set already
|
||||
* provided by the hashtag-spam filter.
|
||||
*/
|
||||
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
|
||||
compositionLocalOf { null }
|
||||
|
||||
/**
|
||||
* Whether the WoT service has finished its initial batch fetch (or the
|
||||
* 2s startup timeout has elapsed). Read once at the App root via
|
||||
* [WoTService.isReady] and provided down as a scalar so leaf composables
|
||||
* don't each spawn a Flow collector.
|
||||
*/
|
||||
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
|
||||
compositionLocalOf { false }
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.commons.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
|
||||
* event cache. Desktop and `amy` each provide their own implementation —
|
||||
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
|
||||
* amy local store on the CLI side.
|
||||
*
|
||||
* The dispatcher only needs three capabilities:
|
||||
*
|
||||
* 1. Peek at what kind-10002 events are already stored so it can skip
|
||||
* Phase-1 discovery for authors whose write-relay list is already
|
||||
* known (from hydration or a previous session's fetch).
|
||||
* 2. Ingest a kind-10002 that just came back from an index relay so
|
||||
* subsequent lookups don't re-fetch it.
|
||||
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
|
||||
* relay so the platform cache/UI can pick it up through the usual
|
||||
* consume path.
|
||||
*
|
||||
* Every method must be idempotent — the dispatcher may re-fire the same
|
||||
* event through the gateway if two relays happen to return the same
|
||||
* addressable event.
|
||||
*/
|
||||
interface OutboxCacheGateway {
|
||||
/**
|
||||
* Returns the currently-cached kind-10002 event for [pubkey], or null
|
||||
* if the platform cache doesn't have one yet.
|
||||
*/
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
|
||||
/**
|
||||
* Called for every kind-10002 the dispatcher receives during Phase 1.
|
||||
* The gateway should route it through its normal consume path so the
|
||||
* event is stored, deduped by createdAt, and picked up by any state
|
||||
* holders observing the addressable-notes cache.
|
||||
*/
|
||||
fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called for every kind-0 (metadata) or kind-3 (contact list) the
|
||||
* dispatcher receives during Phase 2 or Phase 3. The gateway should
|
||||
* route it through its normal consume path — this is how new profile
|
||||
* metadata and follow lists reach downstream consumers like the WoT
|
||||
* service and the UI.
|
||||
*/
|
||||
fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* 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.commons.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
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.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
|
||||
* set of authors using the NIP-65 **outbox model**:
|
||||
*
|
||||
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
|
||||
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
|
||||
* already-cached 10002s from [OutboxCacheGateway].
|
||||
*
|
||||
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
|
||||
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
|
||||
* minimal, popularity-based set of relays that covers every author.
|
||||
* Open one subscription per recommended relay, filtered to that
|
||||
* relay's authors, for kind-0 and/or kind-3.
|
||||
*
|
||||
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
|
||||
* never returned, fall back to the index-relay REQ (preserves the
|
||||
* current behaviour so a coldish account doesn't lose signal).
|
||||
*
|
||||
* The dispatcher is single-scoped (one instance per account) so its dedup
|
||||
* set survives across follow-set diffs. Call [clear] on account switch.
|
||||
*
|
||||
* @param client shared [INostrClient] used for every subscription
|
||||
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
|
||||
* @param indexRelays lazy accessor so a change through the settings UI
|
||||
* takes effect on next fetch without recreating the
|
||||
* dispatcher
|
||||
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
|
||||
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
|
||||
* the plan (2026-07-06): 4 s.
|
||||
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
|
||||
* a phase getting stuck. Under the plan: 8 s.
|
||||
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
|
||||
* relays after the [RelayListRecommendationProcessor]
|
||||
* chooses them, to keep fan-out predictable
|
||||
*/
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>,
|
||||
private val gateway: OutboxCacheGateway,
|
||||
private val perRelayTimeoutMs: Long = 4_000L,
|
||||
private val overallTimeoutMs: Long = 20_000L,
|
||||
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
|
||||
) {
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-3 for this session
|
||||
* (Phase 1 or Phase 2 returned events for them). Skipping a second
|
||||
* fetch is safe because a churn event from a subsequent kind-3
|
||||
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
|
||||
* via other subscriptions (feed, notifications).
|
||||
*/
|
||||
private val kind3Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-0 for this session.
|
||||
*/
|
||||
private val kind0Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Currently-in-flight authors — prevents rapid re-fire of the same
|
||||
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
|
||||
* timeout rolls out of this set (allowing retry) instead of
|
||||
* permanently marking the pubkey as done.
|
||||
*/
|
||||
private val kind3InFlight = mutableSetOf<HexKey>()
|
||||
private val kind0InFlight = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Outcome counters. All values are aggregated across every phase of
|
||||
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
|
||||
* observability; `amy wot sync --json` also emits them so a caller
|
||||
* can measure whether the outbox path is doing the work vs the
|
||||
* fallback path.
|
||||
*/
|
||||
data class Result(
|
||||
val authorsRequested: Int,
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val outboxCoveredAuthors: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
|
||||
* relay when known, falling back to index relays otherwise. Suspends
|
||||
* until every phase EOSEs or times out.
|
||||
*/
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
|
||||
* pipeline; a single per-outbox-relay subscription pulls both kinds
|
||||
* so we don't double the connection count.
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-0 only. Used by the metadata preloader when it decides
|
||||
* to bypass the index-relay batch for a specific author (e.g. a
|
||||
* profile screen visit where the author's outbox is already cached).
|
||||
*/
|
||||
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
|
||||
|
||||
/**
|
||||
* Drop every dedup marker. Call on account switch so a fresh account
|
||||
* doesn't inherit the previous account's "already fetched" state.
|
||||
*/
|
||||
fun clear() {
|
||||
kind3Succeeded.clear()
|
||||
kind0Succeeded.clear()
|
||||
kind3InFlight.clear()
|
||||
kind0InFlight.clear()
|
||||
}
|
||||
|
||||
private suspend fun run(
|
||||
authors: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
if (authors.isEmpty()) return zeroResult(0)
|
||||
|
||||
val newForKind3 =
|
||||
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
|
||||
val newForKind0 =
|
||||
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
|
||||
|
||||
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
|
||||
return zeroResult(authors.size)
|
||||
}
|
||||
|
||||
kind3InFlight.addAll(newForKind3)
|
||||
kind0InFlight.addAll(newForKind0)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
withTimeoutOrNull(overallTimeoutMs) {
|
||||
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
|
||||
}
|
||||
if (result == null) {
|
||||
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
|
||||
zeroResult(authors.size)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
kind3InFlight.removeAll(newForKind3)
|
||||
kind0InFlight.removeAll(newForKind0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRun(
|
||||
allAuthors: Set<HexKey>,
|
||||
newForKind3: Set<HexKey>,
|
||||
newForKind0: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
val relayCounts = FetchCounters()
|
||||
val relaysConfigured = indexRelays()
|
||||
val newTargets = (newForKind3 + newForKind0)
|
||||
|
||||
// Split into "have cached 10002" vs "need Phase 1".
|
||||
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
val toDiscover = mutableSetOf<HexKey>()
|
||||
for (author in newTargets) {
|
||||
val write =
|
||||
gateway
|
||||
.cachedOutbox(author)
|
||||
?.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
|
||||
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
|
||||
"indexRelays=${relaysConfigured.size}"
|
||||
}
|
||||
|
||||
// Phase 1 — discover kind-10002 on the index relays. runPhase1
|
||||
// returns pubkey → list of (event, relay) so we can pick the
|
||||
// newest event (some relays return outdated 10002s).
|
||||
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
|
||||
phase1Events.forEach { (pubkey, results) ->
|
||||
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
|
||||
gateway.onOutboxDiscovered(newest.first, newest.second)
|
||||
val write =
|
||||
newest.first
|
||||
.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) discovered[pubkey] = write
|
||||
}
|
||||
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
|
||||
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val outboxMap = cachedOutbox + discovered
|
||||
val authorsWithOutbox = outboxMap.keys
|
||||
val fallbackAuthors = newTargets - authorsWithOutbox
|
||||
|
||||
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
|
||||
// recommended relays are subscribed in a single call so the pool
|
||||
// fans out in parallel; a per-relay 4 s timeout bounds the wait
|
||||
// regardless of how many relays the recommendation set contains.
|
||||
val kind3BeforePhase2 = relayCounts.kind3
|
||||
val kind0BeforePhase2 = relayCounts.kind0
|
||||
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
|
||||
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
|
||||
val phase2FilterMap =
|
||||
recommendations
|
||||
.mapNotNull { rec ->
|
||||
val authorsForThisRelay =
|
||||
rec.users.intersect(
|
||||
if (includeKind0 && includeKind3) {
|
||||
newTargets
|
||||
} else if (includeKind3) {
|
||||
newForKind3
|
||||
} else {
|
||||
newForKind0
|
||||
},
|
||||
)
|
||||
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isEmpty()) return@mapNotNull null
|
||||
rec.relay to
|
||||
authorsForThisRelay.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
|
||||
if (phase2FilterMap.isNotEmpty()) {
|
||||
runPhase2Or3(phase2FilterMap, counters = relayCounts)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
|
||||
}
|
||||
|
||||
// Phase 3 — index-relay fallback for authors with no 10002.
|
||||
val kind3BeforePhase3 = relayCounts.kind3
|
||||
val kind0BeforePhase3 = relayCounts.kind0
|
||||
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isNotEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
|
||||
val filters =
|
||||
fallbackAuthors.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
val phase3FilterMap = relaysConfigured.associateWith { filters }
|
||||
runPhase2Or3(phase3FilterMap, counters = relayCounts)
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Promote to succeeded — a completed run means we've asked; even if
|
||||
// an author had no publishable data we don't need to keep pounding
|
||||
// relays every follow-set change.
|
||||
kind3Succeeded.addAll(newForKind3)
|
||||
kind0Succeeded.addAll(newForKind0)
|
||||
|
||||
return Result(
|
||||
authorsRequested = allAuthors.size,
|
||||
kind10002Received = relayCounts.kind10002,
|
||||
kind3Received = relayCounts.kind3,
|
||||
kind0Received = relayCounts.kind0,
|
||||
outboxCoveredAuthors = authorsWithOutbox.size,
|
||||
fallbackAuthors = fallbackAuthors.size,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private class FetchCounters {
|
||||
var kind10002 = 0
|
||||
var kind3 = 0
|
||||
var kind0 = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
|
||||
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
|
||||
* (currently ignored but recorded for future retry telemetry).
|
||||
*/
|
||||
private suspend fun runPhase1(
|
||||
pubkeys: Set<HexKey>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
|
||||
val filters =
|
||||
pubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = relays.associateWith { filters }
|
||||
|
||||
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
|
||||
val gate = BatchEoseGate(scope, target = relays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
|
||||
received
|
||||
.getOrPut(event.pubKey) { mutableListOf() }
|
||||
.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
return received to eosedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 or Phase 3 helper. Opens a single subscription that
|
||||
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
|
||||
* relay filters; Phase 3 uses the index-relay set with a shared
|
||||
* fallback filter). All relays are subscribed in parallel — the
|
||||
* per-relay timeout bounds the total wait regardless of relay count.
|
||||
*/
|
||||
private suspend fun runPhase2Or3(
|
||||
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
counters: FetchCounters,
|
||||
) {
|
||||
val gate = BatchEoseGate(scope, target = filterMap.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
when (event.kind) {
|
||||
MetadataEvent.KIND -> counters.kind0++
|
||||
ContactListEvent.KIND -> counters.kind3++
|
||||
}
|
||||
gateway.onDiscoveredEvent(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
}
|
||||
|
||||
private fun zeroResult(requested: Int) =
|
||||
Result(
|
||||
authorsRequested = requested,
|
||||
kind10002Received = 0,
|
||||
kind3Received = 0,
|
||||
kind0Received = 0,
|
||||
outboxCoveredAuthors = 0,
|
||||
fallbackAuthors = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
|
||||
* one — duplicated locally instead of exported to keep the fix scope
|
||||
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
|
||||
* (typically `Dispatchers.IO`) so we funnel them through a Channel
|
||||
* and let a single consumer coroutine own the `seen` set.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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.commons.wot
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Friends-of-friends trust score computed from the active user's follow
|
||||
* graph. For every pubkey X the score is the count of accounts in the
|
||||
* active user's follow set who also follow X.
|
||||
*
|
||||
* ## Reactivity model
|
||||
*
|
||||
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
|
||||
* that read a **single key** (`scores[pubkey]`) recompose only when that
|
||||
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
|
||||
* and applies whether or not the writer wraps in a snapshot block.
|
||||
* Consumers that iterate the map or read `size` recompose on **any**
|
||||
* mutation.
|
||||
*
|
||||
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
|
||||
* an op's writes into a single Compose commit — so a Kind3 op that
|
||||
* touches N reverse-index targets emits one invalidation, not N. It does
|
||||
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
|
||||
* own semantics.
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* All internal state is mutated from a single writer coroutine
|
||||
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
|
||||
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
|
||||
* from different threads are serialized without extra locking.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* Call [close] on account switch / logout so the writer coroutine exits
|
||||
* and the ops channel is released. Post-close ops are silently dropped.
|
||||
*/
|
||||
@Stable
|
||||
class WoTService(
|
||||
private val scope: CoroutineScope,
|
||||
/** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */
|
||||
private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* Sparse per-pubkey score map. Entries with count 0 are removed
|
||||
* (not stored as 0) to keep the Compose subscriber tracking tight.
|
||||
* Callers should read as `scores[pubkey] ?: 0`.
|
||||
*/
|
||||
private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
|
||||
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
|
||||
|
||||
// Reverse index: target pubkey → set of my-follows who follow them.
|
||||
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
|
||||
// Per-follower cached follow set (excluding self / follower itself).
|
||||
// Enables diff-based updates when a follower republishes their kind-3.
|
||||
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
|
||||
|
||||
private var myFollows: Set<HexKey> = emptySet()
|
||||
private var selfPubkey: HexKey? = null
|
||||
private var readyMarked = false
|
||||
private var disabled = false
|
||||
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
|
||||
|
||||
private val _isDisabled = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
|
||||
* scoring has been shut off. Callers that dispatch the batch kind-3
|
||||
* REQ must gate on this — a disabled service silently accepts and
|
||||
* ignores all subsequent [applyKind3] calls, so a caller that keeps
|
||||
* flooding kind-3s wastes bandwidth for nothing.
|
||||
*/
|
||||
val isDisabled: StateFlow<Boolean> = _isDisabled.asStateFlow()
|
||||
|
||||
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch(writerDispatcher) { writerLoop() }
|
||||
}
|
||||
|
||||
/** Update the active user's follow set (and self pubkey). */
|
||||
fun onFollowSetChange(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
ops.trySend(Op.FollowSet(newFollows, newSelf))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a kind-3 event for a followed pubkey. Ignored when the
|
||||
* event's author isn't in the current follow set. Follow lists are
|
||||
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
|
||||
* hostile publisher.
|
||||
*/
|
||||
fun applyKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
val bounded =
|
||||
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
|
||||
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
|
||||
} else {
|
||||
follows
|
||||
}
|
||||
ops.trySend(Op.Kind3(follower, bounded))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the service as ready to render badges. Idempotent — subsequent
|
||||
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
|
||||
* REQ, or from a startup-timeout fallback, whichever fires first.
|
||||
*/
|
||||
fun markReadyOnce() {
|
||||
ops.trySend(Op.MarkReady)
|
||||
}
|
||||
|
||||
/** Clear all state. Used on logout / account switch. */
|
||||
fun clear() {
|
||||
ops.trySend(Op.Clear)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain [Map] snapshot of current scores for headless
|
||||
* callers (e.g. the amy CLI) that don't run inside a Compose
|
||||
* composition. O(N) copy from the underlying [SnapshotStateMap].
|
||||
*/
|
||||
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
|
||||
|
||||
private sealed interface Op {
|
||||
data class FollowSet(
|
||||
val newFollows: Set<HexKey>,
|
||||
val newSelf: HexKey?,
|
||||
) : Op
|
||||
|
||||
data class Kind3(
|
||||
val follower: HexKey,
|
||||
val follows: Set<HexKey>,
|
||||
) : Op
|
||||
|
||||
data object MarkReady : Op
|
||||
|
||||
data object Clear : Op
|
||||
}
|
||||
|
||||
private suspend fun writerLoop() {
|
||||
for (op in ops) {
|
||||
Snapshot.withMutableSnapshot {
|
||||
when (op) {
|
||||
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
|
||||
is Op.Kind3 -> handleKind3(op.follower, op.follows)
|
||||
Op.MarkReady -> handleMarkReady()
|
||||
Op.Clear -> handleClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFollowSet(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
// Guardrail — massive follow lists don't produce a useful WoT signal.
|
||||
// Do this BEFORE assigning myFollows so applyKind3's `follower in
|
||||
// myFollows` gate doesn't accidentally credit anyone once the
|
||||
// caller keeps pumping kind-3s in (a caller that fails to gate on
|
||||
// isDisabled would otherwise fully repopulate reverseIndex/_scores
|
||||
// and defeat the guardrail — see PR #3483 review finding 2).
|
||||
if (newFollows.size > MAX_FOLLOWS) {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = newSelf
|
||||
disabled = true
|
||||
_isDisabled.value = true
|
||||
handleMarkReady()
|
||||
return
|
||||
}
|
||||
|
||||
val removed = myFollows - newFollows
|
||||
myFollows = newFollows
|
||||
selfPubkey = newSelf
|
||||
// Follow set is back within limits (or was already) — re-enable if
|
||||
// we had previously flipped disabled=true.
|
||||
if (disabled) {
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
// Uncredit any follower we're no longer following.
|
||||
removed.forEach { follower ->
|
||||
val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach
|
||||
prevFollows.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
// Added followers will be credited when their kind-3 arrives via applyKind3.
|
||||
}
|
||||
|
||||
private fun handleKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
if (disabled) return
|
||||
if (follower !in myFollows) return
|
||||
|
||||
val old = perFollowerSnapshot[follower] ?: emptySet()
|
||||
val excluded = setOfNotNull(follower, selfPubkey)
|
||||
val effective = follows - excluded
|
||||
val added = effective - old
|
||||
val removed = old - effective
|
||||
perFollowerSnapshot[follower] = effective
|
||||
|
||||
added.forEach { target ->
|
||||
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
|
||||
updateScore(target)
|
||||
}
|
||||
removed.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMarkReady() {
|
||||
if (!readyMarked) {
|
||||
readyMarked = true
|
||||
_isReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClear() {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = null
|
||||
readyMarked = false
|
||||
_isReady.value = false
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the writer coroutine and release the ops channel. Call from
|
||||
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
|
||||
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
|
||||
* on a closed [Channel] fails without throwing).
|
||||
*
|
||||
* Idempotent; safe to call multiple times.
|
||||
*/
|
||||
override fun close() {
|
||||
ops.close()
|
||||
}
|
||||
|
||||
private fun updateScore(target: HexKey) {
|
||||
val n = reverseIndex[target]?.size ?: 0
|
||||
if (n > 0) _scores[target] = n else _scores.remove(target)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Skip WoT entirely for accounts following more than this many pubkeys. */
|
||||
const val MAX_FOLLOWS = 2000
|
||||
|
||||
/** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */
|
||||
const val MAX_FOLLOWS_PER_EVENT = 5000
|
||||
}
|
||||
}
|
||||
+83
-19
@@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MessagesLockStateTest {
|
||||
class PrivacyLockStateTest {
|
||||
private class FakeSettings(
|
||||
lockEnabled: Boolean = false,
|
||||
timer: InactivityTimer = InactivityTimer.OneMin,
|
||||
password: String? = null,
|
||||
) : PrivacyLockSettings {
|
||||
private val mutableLockEnabled = MutableStateFlow(lockEnabled)
|
||||
private val mutableTimer = MutableStateFlow(timer)
|
||||
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
|
||||
private val mutableFirstRunSeen = MutableStateFlow(false)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(password)
|
||||
private val mutableFailedAttempts = MutableStateFlow(0)
|
||||
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
@@ -61,7 +63,7 @@ class MessagesLockStateTest {
|
||||
mutableTimer.value = timer
|
||||
}
|
||||
|
||||
override fun setRedactionLevel(level: DmRedactionLevel) {
|
||||
override fun setDmRedactionLevel(level: DmRedactionLevel) {
|
||||
mutableRedaction.value = level
|
||||
}
|
||||
|
||||
@@ -71,6 +73,8 @@ class MessagesLockStateTest {
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
// Mirror the production cascade — no credential means no gate.
|
||||
if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
@@ -86,7 +90,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_enabled_seeds_to_locked() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, state.state.value)
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_disabled_seeds_to_disabled() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
}
|
||||
|
||||
@@ -102,7 +106,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L)
|
||||
@@ -113,7 +117,7 @@ class MessagesLockStateTest {
|
||||
fun leave_route_locks_immediately() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
state.onLeaveRoute()
|
||||
@@ -124,7 +128,7 @@ class MessagesLockStateTest {
|
||||
fun toggling_lock_off_transitions_to_disabled() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
settings.setLockEnabled(false)
|
||||
@@ -135,7 +139,7 @@ class MessagesLockStateTest {
|
||||
fun never_timer_does_not_fire() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneHour.millis!! * 2)
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -145,7 +149,7 @@ class MessagesLockStateTest {
|
||||
fun user_interaction_resets_idle_timer() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
|
||||
state.onUserInteraction()
|
||||
@@ -159,7 +163,7 @@ class MessagesLockStateTest {
|
||||
fun credential_unavailable_disables_lock() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onCredentialUnavailable()
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
@@ -169,11 +173,11 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_from_disabled_transitions_to_unlocked() =
|
||||
runTest {
|
||||
// First-run banner path: user enables lock + sets password while
|
||||
// already viewing Messages. State is Disabled at that moment, and
|
||||
// we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// already viewing a gated route. State is Disabled at that moment,
|
||||
// and we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// screen right after enabling.
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -183,7 +187,7 @@ class MessagesLockStateTest {
|
||||
fun failed_attempts_below_threshold_do_not_trip_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) {
|
||||
assertEquals(null, state.onFailedUnlockAttempt(now))
|
||||
@@ -199,7 +203,7 @@ class MessagesLockStateTest {
|
||||
fun fifth_failure_trips_base_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) {
|
||||
state.onFailedUnlockAttempt(now)
|
||||
@@ -212,7 +216,7 @@ class MessagesLockStateTest {
|
||||
fun lockout_doubles_and_caps_at_maximum() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// 5th failure → base (30s)
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
@@ -230,7 +234,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_clears_backoff_state() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
assertTrue(settings.lockedUntilEpochMs.value != null)
|
||||
@@ -239,4 +243,64 @@ class MessagesLockStateTest {
|
||||
assertEquals(null, settings.lockedUntilEpochMs.value)
|
||||
assertEquals(0, settings.failedUnlockAttempts.value)
|
||||
}
|
||||
|
||||
// ---- Wallet-lock reuse additions ----
|
||||
|
||||
@Test
|
||||
fun two_scopes_have_independent_lock_state() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onLeaveRoute()
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failed_unlock_counter_is_shared_across_scopes() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// Three failures on Messages, two on Wallet → shared counter hits 5
|
||||
repeat(3) { messages.onFailedUnlockAttempt(now) }
|
||||
repeat(2) { wallet.onFailedUnlockAttempt(now) }
|
||||
assertEquals(
|
||||
PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES,
|
||||
settings.failedUnlockAttempts.value,
|
||||
)
|
||||
// The 5th failure trips the base lockout regardless of which scope
|
||||
// it came from — either scope now sees the countdown.
|
||||
assertEquals(
|
||||
now + PrivacyLockSettings.LOCKOUT_BASE_MS,
|
||||
settings.lockedUntilEpochMs.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearing_password_cascades_to_disable_the_master_lock() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true, password = "salt\$hash")
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
// User clears the password from Settings → cascade fires
|
||||
settings.setPasswordHashed(null)
|
||||
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
assertEquals(LockState.Disabled, messages.state.value)
|
||||
assertEquals(LockState.Disabled, wallet.state.value)
|
||||
assertNull(settings.passwordHashed.value)
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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.commons.relayClient.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* End-to-end exercise of the AUTH stack: policy classification +
|
||||
* RelayAuthEvent.build template + real NostrSignerInternal signing.
|
||||
*
|
||||
* This is the lambda-level shape that [DesktopAuthCoordinator]'s
|
||||
* `signWithAllLoggedInUsers` calls into. It isolates the policy/signer
|
||||
* round-trip from the live websocket layer (which has its own coverage
|
||||
* in `geode/.../KtorRelayTest.kt` against a Ktor mock relay).
|
||||
*
|
||||
* Together with the existing AuthApprovalPolicyTest (classifier),
|
||||
* PoolEventOutboxStateTest (auth-required carve-out), and
|
||||
* GiftWrapRelayHintTest (NIP-17 hint placement), this covers the AUTH
|
||||
* pipeline at unit granularity — the geode Ktor tests handle the
|
||||
* websocket-level round-trip.
|
||||
*/
|
||||
class AuthApprovalEndToEndTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val ownInbox = NormalizedRelayUrl("wss://own.inbox/")
|
||||
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
|
||||
private val challenge = "test-challenge-abc123"
|
||||
|
||||
private fun newPolicy(
|
||||
ownSet: Set<NormalizedRelayUrl> = setOf(ownInbox),
|
||||
onPrompt: (PendingAuthApproval) -> Unit = {},
|
||||
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
|
||||
val store = InMemoryAuthApprovalStore()
|
||||
return AuthApprovalPolicy(
|
||||
selfApprovedRelays = { ownSet },
|
||||
store = store,
|
||||
onPromptRequired = onPrompt,
|
||||
) to store
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator's lambda shape, distilled. Returns the signed AUTH event
|
||||
* (or null on Block / not-signed-by-policy).
|
||||
*/
|
||||
private suspend fun signWithPolicy(
|
||||
relay: NormalizedRelayUrl,
|
||||
policy: AuthApprovalPolicy,
|
||||
): RelayAuthEvent? {
|
||||
val template = RelayAuthEvent.build(relay, challenge)
|
||||
val relayFromTemplate = template.tags.firstNotNullOfOrNull(RelayTag::parse)
|
||||
assertEquals(relay, relayFromTemplate, "RelayAuthEvent.build must round-trip via RelayTag.parse")
|
||||
return when (val decision = policy.classify(relay)) {
|
||||
AuthApprovalDecision.Allow -> signer.sign(template)
|
||||
AuthApprovalDecision.Block -> null
|
||||
is AuthApprovalDecision.Pending -> {
|
||||
val resolved = decision.pending.await()
|
||||
if (resolved != AuthApprovalScope.ONCE) policy.recordDecision(relay, resolved)
|
||||
if (resolved == AuthApprovalScope.BLOCKED) null else signer.sign(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier1OwnInboxAutoSignsValidAuthEvent() =
|
||||
runTest {
|
||||
val (policy, _) = newPolicy()
|
||||
val signed = signWithPolicy(ownInbox, policy)
|
||||
assertNotNull(signed)
|
||||
assertEquals(RelayAuthEvent.KIND, signed.kind)
|
||||
assertEquals(signer.pubKey, signed.pubKey)
|
||||
assertEquals(challenge, signed.challenge())
|
||||
assertEquals(ownInbox, signed.relay())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2UnknownPromptsAndOnceResolutionSigns() =
|
||||
runTest {
|
||||
var prompted: PendingAuthApproval? = null
|
||||
val (policy, _) = newPolicy(onPrompt = { prompted = it })
|
||||
|
||||
coroutineScope {
|
||||
// Concurrent: lambda suspends inside policy.classify; we
|
||||
// resolve the deferred from outside as the banner UI would.
|
||||
val deferred = async { signWithPolicy(unknown, policy) }
|
||||
yieldUntilNotNull { prompted }
|
||||
prompted!!.decision.complete(AuthApprovalScope.ONCE)
|
||||
|
||||
val signed = deferred.await()
|
||||
assertNotNull(signed)
|
||||
assertEquals(unknown, signed.relay())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2BlockedResolutionReturnsNullAndPersists() =
|
||||
runTest {
|
||||
var prompted: PendingAuthApproval? = null
|
||||
val (policy, store) = newPolicy(onPrompt = { prompted = it })
|
||||
|
||||
coroutineScope {
|
||||
val deferred = async { signWithPolicy(unknown, policy) }
|
||||
yieldUntilNotNull { prompted }
|
||||
prompted!!.decision.complete(AuthApprovalScope.BLOCKED)
|
||||
|
||||
val signed = deferred.await()
|
||||
assertNull(signed)
|
||||
assertEquals(AuthApprovalScope.BLOCKED, store.getScope(unknown))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tier2AlwaysPersistsAndSkipsPromptNextTime() =
|
||||
runTest {
|
||||
var promptCount = 0
|
||||
val (policy, store) =
|
||||
newPolicy(onPrompt = {
|
||||
it.decision.complete(AuthApprovalScope.ALWAYS)
|
||||
promptCount++
|
||||
})
|
||||
|
||||
// First call: prompts and resolves to ALWAYS.
|
||||
val first = signWithPolicy(unknown, policy)
|
||||
assertNotNull(first)
|
||||
assertEquals(1, promptCount)
|
||||
assertEquals(AuthApprovalScope.ALWAYS, store.getScope(unknown))
|
||||
|
||||
// Second call: should NOT prompt again.
|
||||
val second = signWithPolicy(unknown, policy)
|
||||
assertNotNull(second)
|
||||
assertEquals(1, promptCount, "ALWAYS persisted — no second prompt")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <T> yieldUntilNotNull(crossinline supplier: () -> T?): T {
|
||||
repeat(100) {
|
||||
supplier()?.let { return it }
|
||||
yield()
|
||||
}
|
||||
error("supplier never produced a value within 100 yields")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user