Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0233f8bc3 |
+251
-1
@@ -139,7 +139,142 @@ See [`plans/app-review-methodology.md`](plans/app-review-methodology.md) for ful
|
||||
|
||||
## Publishing
|
||||
|
||||
Stack events (kind 30267) are published to `wss://relay.zapstore.dev` using the [`publish_stack.py`](scripts/publish_stack.py) script, which signs via `qrexec` (Qubes OS `nostr_signer`).
|
||||
Stack events (kind 30267) are published to `wss://relay.zapstore.dev` using the [`publish_all.py`](publish_all.py) script, which signs via `qrexec` (Qubes OS `nostr_signer`).
|
||||
|
||||
## Zapstore Display Requirements
|
||||
|
||||
The Zapstore Android app ([source](../zapstore_app_stacks/references/zapstore/)) filters which stacks appear on the home screen. A kind 30267 stack event must satisfy **all three** of these conditions to appear with apps:
|
||||
|
||||
### Quick Reference: The Three Rules
|
||||
|
||||
1. **`h` tag = community pubkey** — The app filters by `#h` matching `acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84`. Set to `""` to hide a stack from the app.
|
||||
|
||||
2. **`content` = empty string** — The app treats any non-empty `content` as encrypted/private data. If `content` is non-empty, the app skips the `a` tags and shows "no apps". Always use `content: ""` for public stacks.
|
||||
|
||||
3. **`a` tags reference real kind 32267 events** — Each `a` tag must be `32267:<real-developer-pubkey>:<package-id>` pointing to an actual kind 32267 event on the relay. Apps without kind 32267 events won't appear in the stack.
|
||||
|
||||
### Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30267,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "privacy-approved-health-fitness"],
|
||||
["t", "app-stack"],
|
||||
["name", "Privacy Approved — Health & Fitness"],
|
||||
["description", "Health tracking, diet, and fitness apps..."],
|
||||
["f", "android-arm64-v8a"],
|
||||
["h", "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"],
|
||||
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:org.nutritionfacts.dailydozen", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.antoniegil.astronia", "wss://relay.zapstore.dev"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Tags
|
||||
|
||||
| Tag | Value | Purpose |
|
||||
|-----|-------|---------|
|
||||
| `h` | `acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84` | **Zapstore community pubkey** — the app filters by `#h` matching this value. Set to empty string to hide the stack from the zapstore app. |
|
||||
| `f` | `android-arm64-v8a` | Platform filter — the app filters by `#f` matching the device platform |
|
||||
| `d` | e.g. `privacy-approved-nostr-clients` | Unique identifier for the stack |
|
||||
| `name` | e.g. `Privacy Approved — Nostr Clients` | Display name |
|
||||
| `description` | Human-readable description | Shown in stack details |
|
||||
| `a` | `32267:<pubkey>:<identifier>` | References to kind 32267 app events — **must point to real events on the relay** |
|
||||
|
||||
### The `h` Tag (Community Pubkey)
|
||||
|
||||
The Zapstore app's [`app_stacks_screen.dart`](../zapstore_app_stacks/references/zapstore/lib/screens/app_stacks_screen.dart) queries stacks with:
|
||||
|
||||
```dart
|
||||
Map<String, Set<String>> get _tags => {
|
||||
'#f': {platform},
|
||||
'#h': {kZapstoreCommunityPubkey},
|
||||
};
|
||||
```
|
||||
|
||||
Where `kZapstoreCommunityPubkey` is defined in [`app_constants.dart`](../zapstore_app_stacks/references/zapstore/lib/constants/app_constants.dart) as:
|
||||
|
||||
```
|
||||
acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84
|
||||
```
|
||||
|
||||
(npub: `npub14nl2afh9zsswsp5043zxe2w304afaa496gxe8z2w2rlw84ys92zqlnjx5u`)
|
||||
|
||||
**If the `h` tag is empty or doesn't match this value, the stack will NOT appear in the Zapstore app.**
|
||||
|
||||
### The `content` Field (Must Be Empty)
|
||||
|
||||
The Zapstore app checks `stack.content.isNotEmpty` to determine if a stack is **encrypted/private**. If the content is non-empty, the app attempts to decrypt it and parse private app IDs from it. If decryption fails, it shows "no apps" or "could not be decrypted" instead of reading the `a` tags.
|
||||
|
||||
**The `content` field must be an empty string `""` for public stacks.** The stack name and description are read from the `name` and `description` tags, not from the content field.
|
||||
|
||||
### The `a` Tags (App References)
|
||||
|
||||
The `a` tags in stacks reference kind 32267 (Software Application) events. The Zapstore app resolves these references to display app cards within each stack. The `a` tag format is:
|
||||
|
||||
```
|
||||
32267:<app-developer-pubkey>:<android-package-identifier>
|
||||
```
|
||||
|
||||
The pubkey must be the **actual app developer's pubkey** who published the kind 32267 event — not the stack publisher's pubkey. If the referenced kind 32267 event doesn't exist on the relay, the app won't show that app in the stack.
|
||||
|
||||
A lookup file of app identifiers to real developer pubkeys is maintained at [`app_pubkeys.json`](app_pubkeys.json), fetched from `wss://relay.zapstore.dev`.
|
||||
|
||||
### Stack Comments (Kind 1111)
|
||||
|
||||
The Zapstore app displays comments under each stack. Comments are kind 1111 events (not kind 1 text notes) with an `A` tag referencing the stack's addressable ID.
|
||||
|
||||
The zapstore app queries comments in [`comments_section.dart`](../zapstore_app_stacks/references/zapstore/lib/widgets/comments_section.dart) with:
|
||||
|
||||
```dart
|
||||
query<Comment>(
|
||||
tags: {
|
||||
'#A': {stack.id}, // stack.id = "30267:<pubkey>:<d-tag>"
|
||||
},
|
||||
source: LocalAndRemoteSource(stream: true, relays: 'AppCatalog'),
|
||||
)
|
||||
```
|
||||
|
||||
#### Comment Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "LLM Privacy Review: Both apps have been scanned...",
|
||||
"tags": [
|
||||
["A", "30267:<stack-pubkey>:<stack-d-tag>", "wss://relay.zapstore.dev"],
|
||||
["K", "30267"]
|
||||
],
|
||||
"created_at": 1785840000,
|
||||
"pubkey": "<commenter-pubkey>"
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Value | Purpose |
|
||||
|-----|-------|---------|
|
||||
| `A` | `30267:<pubkey>:<d-tag>` | Addressable reference to the stack event (NIP-22 root) |
|
||||
| `K` | `30267` | Root kind (the kind of the referenced event) |
|
||||
|
||||
#### Important: Kind 1 is Rejected
|
||||
|
||||
`relay.zapstore.dev` does **not** accept kind 1 (text note) events. Comments **must** use kind 1111. The relay's allowed kinds are: `[0 5 11 1111 1984 9735 10222 3063 30063 30509 30267 30078 32267]`.
|
||||
|
||||
#### Use Case: LLM Review Comments
|
||||
|
||||
Comments can be used to attach LLM privacy review summaries to stacks. The comment content is free text (supports markdown via the app's note parser). This allows posting review results like:
|
||||
|
||||
> "LLM Privacy Review: Both apps in this stack have been scanned for tracking SDKs, excessive permissions, and privacy concerns. No issues found — both are approved at privacy level 1."
|
||||
|
||||
### Relay Acceptance Rules
|
||||
|
||||
| Relay | Kind 30267 (Stacks) | Kind 30078 (App Definitions) | Kind 32267 (App Listings) | Kind 1111 (Comments) |
|
||||
|-------|---------------------|------------------------------|---------------------------|----------------------|
|
||||
| `wss://relay.zapstore.dev` | ✅ Accepts (requires `a` tags use kind 32267) | ❌ Rejects `d: app-*` (only allows `zapstore-device-*`) | ✅ Accepts | ✅ Accepts |
|
||||
| `wss://nos.lol` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
|
||||
| `wss://relay.primal.net` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
|
||||
| `wss://laantungir.net/relay` | ✅ Accepts | ✅ Accepts | ✅ Accepts | ✅ Accepts |
|
||||
|
||||
## Nostr Kinds Reference
|
||||
|
||||
@@ -152,10 +287,125 @@ The following Nostr event kinds are relevant to this project:
|
||||
| **30078** | App-specific data | Generic parameterized replaceable event. Already used by Zapstore for device state, bookmarks, etc. Candidate for storing per-app review results from LLM agents. | Planned for reviews |
|
||||
| **3063** | Software Asset | APK metadata including hash, size, version code, platform. Used to track current version info for reviewed apps. | Used for version tracking |
|
||||
| **30063** | Release artifact set | Group of artifacts for a software release. Links to kind 3063 assets and kind 32267 app events. | Reference |
|
||||
| **1111** | Comment | Purplebase comment kind (NIP-22). Used by the Zapstore app for stack and app comments. References the root event via `A` tag. **Not** kind 1 (text note) — `relay.zapstore.dev` rejects kind 1. | Used for stack comments |
|
||||
| **1986** | Relay reviews | Existing review kind, but scoped to Nostr relays rather than software apps. | Not applicable |
|
||||
|
||||
See [`references/registry-of-kinds/schema.yaml`](references/registry-of-kinds/schema.yaml) for the full kinds registry and [`references/nips/51.md`](references/nips/51.md) for list/set definitions.
|
||||
|
||||
## How Kinds Interact on Zapstore
|
||||
|
||||
The diagram below shows how the different Nostr event kinds relate to each other
|
||||
in the zapstore ecosystem — from app discovery to installation to review.
|
||||
|
||||
```
|
||||
ZAPSTORE APP (Android)
|
||||
═════════════════════
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ DISCOVER │ │ INSTALL │ │ UPDATE │
|
||||
│ (browse) │ │ (download│ │ (notify) │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
┌─────▼──────────────▼──────────────▼─────┐
|
||||
│ NOSTR RELAY │
|
||||
│ (relay.zapstore.dev) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ KIND 30267 (App Stack/Curation) │ │
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • d: privacy-approved-nostr-clients│ │
|
||||
│ │ • h: <community pubkey> │ │
|
||||
│ │ • f: android-arm64-v8a │ │
|
||||
│ │ • a: 32267:<pubkey>:<app-id> ─────┼──┼──┐
|
||||
│ │ • content: "" (must be empty!) │ │ │
|
||||
│ └────────────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────────────┐ │ │
|
||||
│ │ KIND 32267 (Software Application)│◄─┼──┘
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • d: com.vitorpamplona.amethyst │ │
|
||||
│ │ • name: Amethyst │ │
|
||||
│ │ • f: android-arm64-v8a │ │
|
||||
│ │ • Published by APP DEVELOPER │ │
|
||||
│ └───────────────┬────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ (linked via) │
|
||||
│ │ │
|
||||
│ ┌───────────────▼────────────────────┐ │
|
||||
│ │ KIND 30063 (Release Artifact Set)│ │
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • a: 32267:<pubkey>:<app-id> │ │
|
||||
│ │ • e: <kind 3063 event id> ────────┼──┼──┐
|
||||
│ │ • Groups assets for a release │ │ │
|
||||
│ └────────────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────────────┐ │ │
|
||||
│ │ KIND 3063 (Software Asset/APK) │◄─┼──┘
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • i: com.vitorpamplona.amethyst │ │
|
||||
│ │ • version: 1.13.1 │ │
|
||||
│ │ • versionCode: 11301 │ │
|
||||
│ │ • x: <SHA256 hash of APK> │ │
|
||||
│ │ • url: https://cdn.zapstore.dev/..│ │
|
||||
│ │ • size: 84849702 │ │
|
||||
│ │ • f: android-arm64-v8a │ │
|
||||
│ │ • THIS IS THE INSTALLABLE BINARY │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ KIND 1111 (Comment/Review) │ │
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • A: 30267:<pubkey>:<stack-d-tag> │ │
|
||||
│ │ • K: 30267 │ │
|
||||
│ │ • content: "LLM Privacy Review..."│ │
|
||||
│ │ • Appears under stack in app │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ KIND 30078 (App-Specific Data) │ │
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ • t: app-definition │ │
|
||||
│ │ • d: app-<identifier> │ │
|
||||
│ │ • Our app catalog metadata │ │
|
||||
│ │ • (NOT used by zapstore app — │ │
|
||||
│ │ │ only our frontend uses these) │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
|
||||
|
||||
OUR PRIVACY REVIEW FLOW
|
||||
═══════════════════════
|
||||
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ GITEA │ │ AI AGENT│ │ KIND │ │ KIND │
|
||||
│ MIRROR │────▶│ SCANS │────▶│ 3063 │────▶│ 1111 │
|
||||
│ │ │ SOURCE │ │ (APK │ │ COMMENT│
|
||||
│ Source │ │ CODE │ │ HASH) │ │ ON │
|
||||
│ code │ │ │ │ │ │ STACK │
|
||||
└─────────┘ └─────────┘ └─────────┘ └─────────┘
|
||||
│
|
||||
│ If approved,
|
||||
│ app is included
|
||||
│ in kind 30267
|
||||
│ stack
|
||||
▼
|
||||
┌───────────┐
|
||||
│ KIND 30267│
|
||||
│ (Stack) │
|
||||
│ a: 32267 │
|
||||
└───────────┘
|
||||
|
||||
KEY INSIGHT: An app must have BOTH kind 32267 AND kind 3063
|
||||
events on the relay to be installable on zapstore. Without
|
||||
kind 3063, there is no APK to download, no version to display,
|
||||
and nothing for AI agents to verify.
|
||||
```
|
||||
|
||||
## Kind 30078 Event Data Model
|
||||
|
||||
We use **kind 30078** (App-specific data) events for two purposes: app definitions and app reviews. The frontend queries the Nostr relay directly for these events on page load, rather than reading a static file.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,3 +147,46 @@ flowchart TD
|
||||
- No HTML changes to the template shell
|
||||
- No CSS additions
|
||||
- No removal of existing functionality
|
||||
|
||||
## Corrections Discovered During Implementation
|
||||
|
||||
### 1. Stack `a` Tags Must Use Kind 32267 (Not 30078)
|
||||
|
||||
The relay `wss://relay.zapstore.dev` **validates** that `a` tags in kind 30267 events reference kind **32267** (Software Application). Using kind 30078 results in `invalid app identifier: expected kind 32267, got 30078`.
|
||||
|
||||
The `a` tags in stacks reference the **original app listing events** (kind 32267) published by app developers, not the app-definition events (kind 30078) that the frontend loads. The frontend independently subscribes to kind 30078 `#t: app-definition` and groups them by the `#t` category tag matching the stack's `d` tag — it does not follow the `a` tags.
|
||||
|
||||
**Current state in** [`app-stacks/publish_all.py`](../app-stacks/publish_all.py:431):
|
||||
```python
|
||||
["a", f"32267:{pubkey}:{app_id}", "wss://relay.zapstore.dev"]
|
||||
```
|
||||
|
||||
The pubkey is still a placeholder (the signer's own pubkey) since we don't have the actual per-app pubkeys from the original kind 32267 events. This is acceptable because the frontend doesn't use the `a` tags for display — it uses the `#t` category tag on the app-definition events.
|
||||
|
||||
### 2. Stack Events Should Have JSON Content
|
||||
|
||||
The plan specifies `content: JSON.stringify({ name, description })` but the original `publish_all.py` used `content: ""`. The frontend reads name/description from tags, so this is non-critical, but it's inconsistent with the spec.
|
||||
|
||||
**Fixed in** [`app-stacks/publish_all.py`](../app-stacks/publish_all.py:435):
|
||||
```python
|
||||
"content": json.dumps({"name": stack["name"], "description": stack["description"]}),
|
||||
```
|
||||
|
||||
### 3. `relay.zapstore.dev` Rejects Kind 30078 with `d: app-*`
|
||||
|
||||
The relay `wss://relay.zapstore.dev` has a whitelist for kind 30078 `d` tags — only `zapstore-device-state` and `zapstore-device-key-backup` are accepted. App definitions with `d: app-<identifier>` are rejected with `invalid 'd' tag`.
|
||||
|
||||
**Workaround:** App definitions are published to the other 3 relays (`nos.lol`, `primal.net`, `laantungir.net/relay`). The frontend's NDK subscription queries user-configured relays, so it will find them as long as the user has at least one of these relays configured.
|
||||
|
||||
### 4. Published Event Counts
|
||||
|
||||
| Event Type | Expected | Published | Relays |
|
||||
|-----------|----------|-----------|--------|
|
||||
| App Stacks (kind 30267) | 20 | 20 | All 4 relays |
|
||||
| App Definitions (kind 30078) | 182 | 174 | 3 relays (not zapstore.dev) |
|
||||
|
||||
The 8 missing app definitions are duplicates (e.g., `com.flux` appears in both "Productivity & Notes" and "Finance & Budgeting", `com.unciv.app` in both "Utilities & Tools" and "Games"). The script overwrites the previous event with the same `d` tag, so only the last one persists.
|
||||
|
||||
### 5. Old Stack Had No `#t: app-stack` Tag
|
||||
|
||||
The previously published stack (`privacy-approved-notes`) was missing the `["t", "app-stack"]` tag entirely. All 20 new stacks include it, which is required for the frontend's subscription filter `#t: ['app-stack']` to find them.
|
||||
|
||||
@@ -26,6 +26,21 @@ RELAYS = [
|
||||
"wss://relay.primal.net",
|
||||
]
|
||||
|
||||
# Zapstore community pubkey — required in the `h` tag for stacks to appear
|
||||
# in the Zapstore Android app. See app-stacks/README.md for details.
|
||||
KZAPSTORE_COMMUNITY_PUBKEY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"
|
||||
|
||||
# Load real app developer pubkeys (kind 32267 events) so stack `a` tags
|
||||
# reference events that actually exist on the relay.
|
||||
# Generated by fetching kind 32267 events from wss://relay.zapstore.dev.
|
||||
import os
|
||||
_PUBKEYS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app_pubkeys.json")
|
||||
try:
|
||||
with open(_PUBKEYS_PATH) as f:
|
||||
APP_PUBKEYS = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
APP_PUBKEYS = {}
|
||||
|
||||
# ── All 20 stacks with their apps ──────────────────────────────────
|
||||
|
||||
STACKS = [
|
||||
@@ -238,6 +253,7 @@ STACKS = [
|
||||
"d": "privacy-approved-health-fitness",
|
||||
"name": "Privacy Approved — Health & Fitness",
|
||||
"description": "Health tracking, diet, and fitness apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
|
||||
"visible": True, # Only this stack is visible on the Zapstore app (test stack)
|
||||
"apps": [
|
||||
("org.nutritionfacts.dailydozen", "Daily Dozen"),
|
||||
("com.antoniegil.astronia", "Astronia"),
|
||||
@@ -418,22 +434,36 @@ async def main():
|
||||
print(f"\n2. Publishing {len(STACKS)} app stacks...")
|
||||
for i, stack in enumerate(STACKS, 1):
|
||||
created_at = int(time.time())
|
||||
# Only stacks with the community pubkey in the `h` tag appear in the
|
||||
# Zapstore app. Set "visible": True in the stack dict to make it visible.
|
||||
h_tag = KZAPSTORE_COMMUNITY_PUBKEY if stack.get("visible") else ""
|
||||
tags = [
|
||||
["d", stack["d"]],
|
||||
["t", "app-stack"],
|
||||
["name", stack["name"]],
|
||||
["description", stack["description"]],
|
||||
["f", "android-arm64-v8a"],
|
||||
["h", ""],
|
||||
["h", h_tag],
|
||||
]
|
||||
# Add a tag for each app (using kind 32267 references from old project)
|
||||
# Since we don't have the exact pubkeys, we use placeholder references
|
||||
# Add a tag for each app (kind 32267 Software Application references)
|
||||
# relay.zapstore.dev validates that a-tags in kind 30267 events reference kind 32267.
|
||||
# Use real app developer pubkeys from app_pubkeys.json where available;
|
||||
# skip apps that don't have a kind 32267 event on the relay.
|
||||
included = 0
|
||||
skipped = 0
|
||||
for app_id, app_name in stack["apps"]:
|
||||
tags.append(["a", f"32267:{pubkey}:{app_id}", "wss://relay.zapstore.dev"])
|
||||
real_pubkey = APP_PUBKEYS.get(app_id)
|
||||
if real_pubkey:
|
||||
tags.append(["a", f"32267:{real_pubkey}:{app_id}", "wss://relay.zapstore.dev"])
|
||||
included += 1
|
||||
else:
|
||||
skipped += 1
|
||||
if skipped:
|
||||
print(f" [{i}/{len(STACKS)}] {stack['name']}: {included} apps included, {skipped} skipped (no kind 32267 event)")
|
||||
|
||||
event = {
|
||||
"kind": 30267,
|
||||
"content": "",
|
||||
"content": "", # Must be empty — zapstore treats non-empty content as encrypted
|
||||
"tags": tags,
|
||||
"created_at": created_at,
|
||||
"pubkey": pubkey,
|
||||
|
||||
@@ -18,6 +18,10 @@ QREXEC_TARGET = "nostr_signer"
|
||||
QREXEC_SERVICE = "qubes.NsignerRpc"
|
||||
RELAY = "wss://relay.zapstore.dev"
|
||||
|
||||
# Zapstore community pubkey — required in the `h` tag for stacks to appear
|
||||
# in the Zapstore Android app. See app-stacks/README.md for details.
|
||||
KZAPSTORE_COMMUNITY_PUBKEY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"
|
||||
|
||||
|
||||
def qrexec_call(request):
|
||||
"""Send a framed JSON-RPC request via qrexec and return the response."""
|
||||
@@ -70,14 +74,14 @@ async def main():
|
||||
created_at = int(time.time())
|
||||
event = {
|
||||
"kind": 30267,
|
||||
"content": "",
|
||||
"content": "", # Must be empty — zapstore treats non-empty content as encrypted
|
||||
"tags": [
|
||||
["d", "privacy-approved-nostr-clients"],
|
||||
["t", "app-stack"],
|
||||
["name", "Privacy Approved — Nostr Clients"],
|
||||
["description", "Nostr-native social and communication clients that passed privacy review. No tracking SDKs, minimal permissions, open source."],
|
||||
["f", "android-arm64-v8a"],
|
||||
["h", ""],
|
||||
["h", KZAPSTORE_COMMUNITY_PUBKEY],
|
||||
["a", "32267:aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b:com.vitorpamplona.amethyst", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d:com.greenart7c3.nostrsigner", "wss://relay.zapstore.dev"],
|
||||
["a", "32267:e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb:com.wisp.app", "wss://relay.zapstore.dev"],
|
||||
|
||||
+161
-57
@@ -201,6 +201,11 @@
|
||||
let stacks = [];
|
||||
let stackSub = null;
|
||||
let stacksLoaded = false;
|
||||
|
||||
// App versions state (kind 3063 Software Asset events)
|
||||
let appVersions = {}; // identifier -> { version, size, url, created_at }
|
||||
let assetSub = null;
|
||||
let assetsLoaded = false;
|
||||
|
||||
/*
|
||||
AUTH STATE MODEL (Template reference)
|
||||
@@ -570,34 +575,12 @@
|
||||
'<div><button class="btn" onclick="doCreateStack()">Create Stack</button></div>' +
|
||||
'</div>';
|
||||
|
||||
// === STACKS SECTION ===
|
||||
var stacksHtml = '<div style="margin-bottom:15px;">' +
|
||||
'<div style="font-weight:bold;font-size:14px;margin-bottom:8px;">App Stacks <span style="font-size:12px;color:var(--muted-color);">(' + stacks.length + ')</span></div>';
|
||||
if (stacks.length === 0) {
|
||||
stacksHtml += '<div style="font-size:12px;color:var(--muted-color);margin-bottom:6px;">No stacks yet.</div>';
|
||||
} else {
|
||||
stacks.forEach(function(s) {
|
||||
stacksHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:8px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<div style="font-weight:bold;">' + esc(s.name) + '</div>' +
|
||||
'<div style="color:var(--muted-color);">d: ' + esc(s.dTag) + '</div>' +
|
||||
(s.description ? '<div style="color:var(--muted-color);">' + esc(s.description) + '</div>' : '') +
|
||||
'<div style="color:var(--muted-color);font-size:11px;">' + s.appRefs.length + ' app(s) · by ' + s.pubkey.substring(0, 8) + '…' + s.pubkey.substring(60) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
stacksHtml += '</div>';
|
||||
|
||||
// === APP DEFINITIONS SECTION ===
|
||||
// Collect unique categories from loaded apps
|
||||
var categories = {};
|
||||
apps.forEach(function(a) { if (a.category) categories[a.category] = true; });
|
||||
var categoryList = Object.keys(categories).sort();
|
||||
var datalistOptions = categoryList.map(function(c) {
|
||||
return '<option value="' + esc(c) + '">';
|
||||
// === PUBLISH APP DEFINITION FORM ===
|
||||
// Collect unique stack d-tags from loaded stacks for the dropdown
|
||||
var stackOptions = stacks.map(function(s) {
|
||||
return '<option value="' + esc(s.dTag) + '">' + esc(s.name) + '</option>';
|
||||
}).join('');
|
||||
|
||||
// Publish form with datalist for category
|
||||
var formHtml =
|
||||
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
|
||||
'<div style="font-weight:bold;margin-bottom:10px;">Publish App Definition</div>' +
|
||||
@@ -605,50 +588,128 @@
|
||||
'<input id="pubRepoUrl" type="text" placeholder="Gitea Repo URL (e.g. https://git.laantungir.net/owner/repo)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<input id="pubCategory" list="pubCategoryList" type="text" placeholder="Category (e.g. nostr-clients, wallets)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'<datalist id="pubCategoryList">' + datalistOptions + '</datalist>' +
|
||||
'<select id="pubStack" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||
'<option value="">Select an app stack...</option>' + stackOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div><button class="btn" onclick="doPublish()">Publish</button></div>' +
|
||||
'</div>';
|
||||
|
||||
// Group apps by category
|
||||
var grouped = {};
|
||||
// === STACKS WITH APPS ===
|
||||
// Build a map of stack d-tag -> apps
|
||||
var appsByStack = {};
|
||||
apps.forEach(function(a) {
|
||||
var cat = a.category || 'uncategorized';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(a);
|
||||
if (!appsByStack[cat]) appsByStack[cat] = [];
|
||||
appsByStack[cat].push(a);
|
||||
});
|
||||
var catOrder = Object.keys(grouped).sort();
|
||||
|
||||
var listHtml;
|
||||
if (apps.length === 0) {
|
||||
listHtml = '<div style="text-align:center;padding:20px;color:var(--muted-color);">No app definitions found yet.</div>';
|
||||
var contentHtml = '';
|
||||
if (stacks.length === 0 && apps.length === 0) {
|
||||
contentHtml = '<div style="text-align:center;padding:20px;color:var(--muted-color);">No stacks or apps yet. Create a stack above, then publish apps into it.</div>';
|
||||
} else {
|
||||
listHtml = '<div style="margin-bottom:10px;font-size:13px;">' + apps.length + ' app' + (apps.length !== 1 ? 's' : '') + '</div>';
|
||||
catOrder.forEach(function(cat) {
|
||||
var catApps = grouped[cat];
|
||||
listHtml +=
|
||||
'<div style="margin-bottom:12px;">' +
|
||||
'<div style="font-weight:bold;font-size:13px;margin-bottom:6px;border-bottom:1px solid var(--border-color);padding-bottom:3px;">' + esc(cat) + ' (' + catApps.length + ')</div>';
|
||||
catApps.forEach(function(a) {
|
||||
listHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:10px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<div style="font-weight:bold;font-size:13px;margin-bottom:4px;">' + esc(a.name) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">description:</span> ' + esc(a.description || '—') + '</div>' +
|
||||
'<div><span style="color:var(--muted-color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
|
||||
// Show stacks that have apps, then stacks without apps, then apps without stacks
|
||||
var stacksWithApps = [];
|
||||
var stacksWithoutApps = [];
|
||||
stacks.forEach(function(s) {
|
||||
if (appsByStack[s.dTag] && appsByStack[s.dTag].length > 0) {
|
||||
stacksWithApps.push(s);
|
||||
} else {
|
||||
stacksWithoutApps.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
// Render stacks with apps
|
||||
stacksWithApps.forEach(function(s) {
|
||||
var stackApps = appsByStack[s.dTag] || [];
|
||||
contentHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);margin-bottom:10px;">' +
|
||||
'<div style="font-weight:bold;font-size:14px;padding:10px 12px;border-bottom:1px solid var(--border-color);cursor:pointer;" onclick="toggleStackApps(this)">' +
|
||||
esc(s.name) + ' <span style="font-size:12px;color:var(--muted-color);">(' + stackApps.length + ')</span>' +
|
||||
' <span style="font-size:11px;color:var(--muted-color);">▼</span>' +
|
||||
'</div>' +
|
||||
'<div class="stackAppsBody">';
|
||||
stackApps.forEach(function(a) {
|
||||
contentHtml +=
|
||||
'<div style="border-bottom:1px solid var(--border-color);font-size:12px;">' +
|
||||
'<div style="padding:8px 12px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick="toggleAppDetail(this)">' +
|
||||
'<span><span style="font-weight:bold;">' + esc(a.name) + '</span> <span style="color:var(--muted-color);">' + esc(appVersions[a.identifier] ? 'v' + appVersions[a.identifier].version : a.identifier) + '</span></span>' +
|
||||
'<span style="font-size:11px;color:var(--muted-color);">▶</span>' +
|
||||
'</div>' +
|
||||
'<div class="appDetailBody" style="display:none;padding:0 12px 8px 12px;font-size:12px;color:var(--muted-color);">' +
|
||||
'<div><span style="color:var(--color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
|
||||
'<div><span style="color:var(--color);">version:</span> ' + esc(appVersions[a.identifier] ? appVersions[a.identifier].version : '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
|
||||
'<div><span style="color:var(--color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">description:</span> ' + esc(a.description || '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
listHtml += '</div>';
|
||||
contentHtml += '</div></div>';
|
||||
});
|
||||
|
||||
// Render stacks without apps
|
||||
stacksWithoutApps.forEach(function(s) {
|
||||
contentHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:10px 12px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<div style="font-weight:bold;">' + esc(s.name) + '</div>' +
|
||||
'<div style="color:var(--muted-color);font-size:11px;">' + esc(s.dTag) + ' — no apps yet</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
// Apps without any matching stack
|
||||
var orphanApps = [];
|
||||
apps.forEach(function(a) {
|
||||
if (!stacks.some(function(s) { return s.dTag === a.category; })) {
|
||||
orphanApps.push(a);
|
||||
}
|
||||
});
|
||||
if (orphanApps.length > 0) {
|
||||
contentHtml +=
|
||||
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);margin-bottom:10px;">' +
|
||||
'<div style="font-weight:bold;font-size:14px;padding:10px 12px;border-bottom:1px solid var(--border-color);">Uncategorized <span style="font-size:12px;color:var(--muted-color);">(' + orphanApps.length + ')</span></div>';
|
||||
orphanApps.forEach(function(a) {
|
||||
contentHtml +=
|
||||
'<div style="border-bottom:1px solid var(--border-color);font-size:12px;">' +
|
||||
'<div style="padding:8px 12px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick="toggleAppDetail(this)">' +
|
||||
'<span><span style="font-weight:bold;">' + esc(a.name) + '</span> <span style="color:var(--muted-color);">' + esc(appVersions[a.identifier] ? 'v' + appVersions[a.identifier].version : a.identifier) + '</span></span>' +
|
||||
'<span style="font-size:11px;color:var(--muted-color);">▶</span>' +
|
||||
'</div>' +
|
||||
'<div class="appDetailBody" style="display:none;padding:0 12px 8px 12px;font-size:12px;color:var(--muted-color);">' +
|
||||
'<div><span style="color:var(--color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
|
||||
'<div><span style="color:var(--color);">version:</span> ' + esc(appVersions[a.identifier] ? appVersions[a.identifier].version : '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
|
||||
'<div><span style="color:var(--color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">description:</span> ' + esc(a.description || '—') + '</div>' +
|
||||
'<div><span style="color:var(--color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
contentHtml += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Single wrapper div so #divBody flexbox sees only one child
|
||||
divBody.innerHTML = '<div>' + createStackHtml + stacksHtml + formHtml + listHtml + '</div>';
|
||||
divBody.innerHTML = '<div>' + createStackHtml + formHtml + contentHtml + '</div>';
|
||||
}
|
||||
|
||||
// Toggle stack body collapse
|
||||
window.toggleStackApps = function(header) {
|
||||
var body = header.nextElementSibling;
|
||||
var isHidden = body.style.display === 'none';
|
||||
body.style.display = isHidden ? '' : 'none';
|
||||
header.querySelector('span:last-child').textContent = isHidden ? '▼' : '▶';
|
||||
};
|
||||
|
||||
// Toggle app detail expand
|
||||
window.toggleAppDetail = function(row) {
|
||||
var body = row.nextElementSibling;
|
||||
var isHidden = body.style.display === 'none' || body.style.display === '';
|
||||
body.style.display = isHidden ? 'block' : 'none';
|
||||
row.querySelector('span:last-child').textContent = isHidden ? '▼' : '▶';
|
||||
};
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function parseGiteaUrl(url) {
|
||||
@@ -694,17 +755,20 @@
|
||||
// Expose doPublish globally for onclick handler
|
||||
window.doPublish = async function() {
|
||||
const repoUrl = document.getElementById('pubRepoUrl').value.trim();
|
||||
if (!repoUrl) { alert('Enter a Gitea repo URL.'); return; }
|
||||
if (!repoUrl) { setStatus('Enter a Gitea repo URL.'); return; }
|
||||
const parsed = parseGiteaUrl(repoUrl);
|
||||
if (!parsed) { alert('Invalid repo URL. Use format: https://git.example.com/owner/repo'); return; }
|
||||
if (!parsed) { setStatus('Invalid repo URL. Use format: https://git.example.com/owner/repo'); return; }
|
||||
|
||||
const stackSelect = document.getElementById('pubStack');
|
||||
const stackDTag = stackSelect ? stackSelect.value : '';
|
||||
if (!stackDTag) { setStatus('Select an app stack.'); return; }
|
||||
|
||||
// Derive name from repo name: my-cool-app -> My Cool App
|
||||
const name = parsed.repo.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||
// Derive identifier from repo name: my-cool-app -> com.gitea.my_cool_app
|
||||
const identifier = 'com.gitea.' + parsed.repo.replace(/-/g, '_');
|
||||
const category = document.getElementById('pubCategory').value.trim() || 'uncategorized';
|
||||
|
||||
await publishAppDefinition(name, identifier, repoUrl, category, parsed.giteaPath);
|
||||
await publishAppDefinition(name, identifier, repoUrl, stackDTag, parsed.giteaPath);
|
||||
};
|
||||
|
||||
function subscribeAppDefinitions() {
|
||||
@@ -764,6 +828,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
APP VERSION FUNCTIONS (kind 3063 Software Asset)
|
||||
================================================================ */
|
||||
|
||||
function parseAppAsset(evt) {
|
||||
const tags = evt.tags || [];
|
||||
const identifier = getTagValue(tags, 'i') || '';
|
||||
const version = getTagValue(tags, 'version') || '';
|
||||
const size = getTagValue(tags, 'size') || '';
|
||||
const urls = tags.filter(function(t) { return t[0] === 'url'; }).map(function(t) { return t[1]; });
|
||||
return { identifier, version, size, url: urls[0] || '', created_at: evt.created_at || 0 };
|
||||
}
|
||||
|
||||
function subscribeAppAssets() {
|
||||
console.log('[app-stacks] Subscribing to app assets (kind 3063)...');
|
||||
assetSub = subscribe({ kinds: [3063], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function initAppAssetListener() {
|
||||
window.addEventListener('ndkEvent', function(event) {
|
||||
var evt = event.detail;
|
||||
if (evt.kind !== 3063) return;
|
||||
var parsed = parseAppAsset(evt);
|
||||
if (!parsed.identifier) return;
|
||||
// Keep the latest version (highest created_at)
|
||||
var existing = appVersions[parsed.identifier];
|
||||
if (!existing || parsed.created_at > existing.created_at) {
|
||||
appVersions[parsed.identifier] = parsed;
|
||||
if (assetsLoaded) renderApps();
|
||||
}
|
||||
});
|
||||
window.addEventListener('ndkEose', function() {
|
||||
if (!assetsLoaded) { assetsLoaded = true; renderApps(); }
|
||||
});
|
||||
}
|
||||
|
||||
async function publishAppStack(name, description) {
|
||||
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to publish.'); return; } }
|
||||
var dTag = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
@@ -869,6 +969,10 @@
|
||||
// Set up app-stack event listener and subscription
|
||||
initAppStackListener();
|
||||
subscribeAppStacks();
|
||||
|
||||
// Set up app-asset (kind 3063) listener and subscription for version info
|
||||
initAppAssetListener();
|
||||
subscribeAppAssets();
|
||||
|
||||
// Optional UX note for public mode pages.
|
||||
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.96",
|
||||
"VERSION_NUMBER": "0.7.96",
|
||||
"BUILD_DATE": "2026-08-03T22:46:27.122Z"
|
||||
"VERSION": "v0.7.97",
|
||||
"VERSION_NUMBER": "0.7.97",
|
||||
"BUILD_DATE": "2026-08-04T13:32:52.287Z"
|
||||
}
|
||||
|
||||
+157
-27
@@ -90268,6 +90268,84 @@ function normalizeRelayUrl(relayUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing-slash-tolerant relay lookup for ndk.pool.relays.
|
||||
*
|
||||
* NDK's internal normalizeRelayUrl() appends a trailing slash to every pool
|
||||
* key (e.g. "wss://host/relay/"), while the worker's normalizeRelayUrl()
|
||||
* (new URL().toString()) does NOT. This mismatch caused pool.get(noSlashUrl)
|
||||
* to miss, so reconnect/data requests silently failed with "Relay not found
|
||||
* in pool". This helper tries the exact key, the slash-appended form, and a
|
||||
* canonical (slash-stripped) comparison across all pool entries.
|
||||
*
|
||||
* @param {string} relayUrl - URL as received from the page (may lack trailing slash)
|
||||
* @returns {object|undefined} The NDKRelay instance, or undefined if not found
|
||||
*/
|
||||
function getRelayFromPool(relayUrl) {
|
||||
if (!relayUrl || !ndk || !ndk.pool || !ndk.pool.relays) return undefined;
|
||||
|
||||
const direct = ndk.pool.relays.get(relayUrl);
|
||||
if (direct) return direct;
|
||||
|
||||
let normalized = relayUrl;
|
||||
try {
|
||||
normalized = new URL(relayUrl).toString();
|
||||
} catch (_error) {
|
||||
normalized = String(relayUrl);
|
||||
}
|
||||
|
||||
const slashForm = normalized.endsWith('/') ? normalized : normalized + '/';
|
||||
const slashHit = ndk.pool.relays.get(slashForm) || ndk.pool.relays.get(normalized);
|
||||
if (slashHit) return slashHit;
|
||||
|
||||
// Canonical fallback: compare with trailing slash stripped on BOTH sides.
|
||||
const stripSlash = (u) => (typeof u === 'string' ? u.replace(/\/$/, '') : u);
|
||||
const target = stripSlash(normalized);
|
||||
for (const [poolUrl, poolRelay] of ndk.pool.relays.entries()) {
|
||||
try {
|
||||
if (stripSlash(new URL(poolUrl).toString()) === target) {
|
||||
return poolRelay;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed pool URL entries
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing-slash-tolerant lookup of a relay's type ('read' | 'write' | 'both')
|
||||
* from the relayTypes map. relayTypes stores the raw kind 10002 tag value
|
||||
* (often without a trailing slash), so a no-slash/slash mismatch can hide the
|
||||
* entry. Returns null when the relay is not in the user's relay list.
|
||||
*
|
||||
* @param {string} relayUrl
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getRelayType(relayUrl) {
|
||||
if (!relayUrl) return null;
|
||||
const direct = relayTypes.get(relayUrl);
|
||||
if (direct) return direct;
|
||||
|
||||
let normalized = relayUrl;
|
||||
try {
|
||||
normalized = new URL(relayUrl).toString();
|
||||
} catch (_error) {
|
||||
normalized = String(relayUrl);
|
||||
}
|
||||
|
||||
const slashForm = normalized.endsWith('/') ? normalized : normalized + '/';
|
||||
const slashHit = relayTypes.get(slashForm) || relayTypes.get(normalized);
|
||||
if (slashHit) return slashHit;
|
||||
|
||||
const stripSlash = (u) => (typeof u === 'string' ? u.replace(/\/$/, '') : u);
|
||||
const target = stripSlash(normalized);
|
||||
for (const [storedUrl, storedType] of relayTypes.entries()) {
|
||||
if (stripSlash(storedUrl) === target) return storedType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRelayFrame(message) {
|
||||
if (Array.isArray(message)) return message;
|
||||
if (typeof message !== 'string') return null;
|
||||
@@ -96788,19 +96866,29 @@ function handleGetRelayData(requestId, port) {
|
||||
// Keep original URL if parsing fails
|
||||
}
|
||||
|
||||
let relay = ndk.pool.relays.get(normalizedUrl) || ndk.pool.relays.get(url);
|
||||
// Trailing-slash-tolerant lookup: NDK pool keys are stored with a
|
||||
// trailing slash (e.g. "wss://host/relay/") while relayTypes stores
|
||||
// the raw tag value (often no slash). getRelayFromPool() reconciles
|
||||
// both forms so the table gets the real relay.url and status instead
|
||||
// of falling back to status: 0 with the no-slash URL.
|
||||
let relay = getRelayFromPool(url) || getRelayFromPool(normalizedUrl);
|
||||
|
||||
// Defensive fallback: compare canonical URL strings across pool keys
|
||||
if (!relay && normalizedUrl) {
|
||||
for (const [poolUrl, poolRelay] of ndk.pool.relays.entries()) {
|
||||
try {
|
||||
if (new URL(poolUrl).toString() === normalizedUrl) {
|
||||
relay = poolRelay;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore malformed pool URL entries
|
||||
}
|
||||
// Write-only relays are intentionally excluded from the main pool by
|
||||
// updateRelays(), so they would otherwise show status: 0 / "-" here.
|
||||
// Add them to the pool on demand so they connect and the table can
|
||||
// report their real status. (REQ frames are still blocked for
|
||||
// write-only relays in attachRelayEventListeners, so this does not turn
|
||||
// them into read relays.)
|
||||
if (!relay && type === 'write' && typeof ndk.addExplicitRelay === 'function') {
|
||||
const slashForm = normalizedUrl && normalizedUrl.endsWith('/')
|
||||
? normalizedUrl
|
||||
: (normalizedUrl || url) + '/';
|
||||
try {
|
||||
const added = ndk.addExplicitRelay(slashForm);
|
||||
relay = added || getRelayFromPool(slashForm) || getRelayFromPool(normalizedUrl);
|
||||
if (relay) attachRelayEventListeners(relay);
|
||||
} catch (_addError) {
|
||||
// Leave relay undefined; table falls back to status: 0 below.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96897,21 +96985,10 @@ function handleReconnectRelay(relayUrl, port) {
|
||||
// Keep original relayUrl when parsing fails
|
||||
}
|
||||
|
||||
let relay = ndk.pool.relays.get(normalizedRelayUrl) || ndk.pool.relays.get(relayUrl);
|
||||
|
||||
// Defensive fallback: compare canonical URL strings across pool keys
|
||||
if (!relay && normalizedRelayUrl) {
|
||||
for (const [poolUrl, poolRelay] of ndk.pool.relays.entries()) {
|
||||
try {
|
||||
if (new URL(poolUrl).toString() === normalizedRelayUrl) {
|
||||
relay = poolRelay;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore malformed pool URL entries
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trailing-slash-tolerant lookup: NDK pool keys are stored with a trailing
|
||||
// slash (e.g. "wss://host/relay/") while the page often passes the no-slash
|
||||
// form. getRelayFromPool() handles both forms plus a canonical fallback.
|
||||
let relay = getRelayFromPool(relayUrl) || getRelayFromPool(normalizedRelayUrl);
|
||||
|
||||
if (relay) {
|
||||
const resolvedRelayUrl = relay.url || normalizedRelayUrl || relayUrl;
|
||||
@@ -96965,6 +97042,59 @@ function handleReconnectRelay(relayUrl, port) {
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
// Relay is not in the main pool. Write-only relays are intentionally
|
||||
// excluded from ndk.pool.relays (see updateRelays), so a manual
|
||||
// reconnect request for one must add it to the pool on demand before
|
||||
// we can connect it. This lets the relays.html reconnect button work
|
||||
// for write-only relays like wss://laantungir.net/relay.
|
||||
const relayType = getRelayType(relayUrl) || getRelayType(normalizedRelayUrl);
|
||||
const isKnownWriteOnly = relayType === 'write';
|
||||
|
||||
if (isKnownWriteOnly && typeof ndk.addExplicitRelay === 'function') {
|
||||
const slashForm = normalizedRelayUrl.endsWith('/')
|
||||
? normalizedRelayUrl
|
||||
: normalizedRelayUrl + '/';
|
||||
console.log('[Worker] Write-only relay not in pool; adding on demand for reconnect:', slashForm);
|
||||
try {
|
||||
const added = ndk.addExplicitRelay(slashForm);
|
||||
const addedRelay = added || getRelayFromPool(slashForm) || getRelayFromPool(normalizedRelayUrl);
|
||||
if (addedRelay) {
|
||||
attachRelayEventListeners(addedRelay);
|
||||
const resolvedRelayUrl = addedRelay.url || slashForm;
|
||||
const onConnect = () => {
|
||||
console.log('[Worker] ✅ Write-only relay connected on demand:', resolvedRelayUrl);
|
||||
addedRelay.off('connect', onConnect);
|
||||
addedRelay.off('error', onError);
|
||||
};
|
||||
const onError = (error) => {
|
||||
console.error('[Worker] ❌ Write-only relay error during on-demand connect:', resolvedRelayUrl, error);
|
||||
broadcastRelayEvent(addedRelay.url, 'reconnect-error', {
|
||||
name: error?.name || null,
|
||||
message: error?.message || error?.toString?.() || String(error),
|
||||
code: error?.code,
|
||||
source: 'manual-reconnect-write-only'
|
||||
}, 'relay');
|
||||
addedRelay.off('connect', onConnect);
|
||||
addedRelay.off('error', onError);
|
||||
};
|
||||
addedRelay.once('connect', onConnect);
|
||||
addedRelay.once('error', onError);
|
||||
|
||||
markRelayConnectAttempt(resolvedRelayUrl);
|
||||
broadcastRelayEvent(resolvedRelayUrl, 'reconnect-attempt', {
|
||||
source: 'manual-reconnect-write-only',
|
||||
targetUrl: normalizeRelayUrl(resolvedRelayUrl)
|
||||
}, 'client');
|
||||
addedRelay.connect().catch((e) => {
|
||||
console.error('[Worker] ❌ Connect() threw error for write-only relay:', resolvedRelayUrl, e);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (addError) {
|
||||
console.error('[Worker] Failed to add write-only relay to pool:', slashForm, addError);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[Worker] Relay not found in pool:', relayUrl, 'normalized:', normalizedRelayUrl);
|
||||
console.log('[Worker] Available relays:', Array.from(ndk.pool.relays.keys()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user