mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
test(napplet): add on-device test harness (napplet + publish script)
A self-contained napplet (tools/napplet-test/index.html) that calls every window.napplet.* API and renders each result on screen, for verifying the NIP-5D host end to end on a real device — including the new identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and resource.bytes nostr: paths. publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D named-napplet event (kind 35129) via nak; README documents the flow and a per-feature verification checklist. Tooling only — no app code or deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Napplet test harness
|
||||
|
||||
A self-contained napplet for **on-device verification** of Amethyst's NIP-5D host — it calls every
|
||||
`window.napplet.*` API and shows each result on screen, so you can confirm the whole
|
||||
shim → shell → broker → consent round-trip (and the newer `identity.getList/getZaps/getBadges`,
|
||||
`identity.onChanged`, `keys.onAction`, and `resource.bytes` `nostr:` paths) works on a real device.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` — the napplet. Read-only checks run on load; publish/upload/pay are behind buttons;
|
||||
live pushes (`identity.changed`, `keys.action`) land in the top banner.
|
||||
- `publish.sh` — uploads `index.html` to a Blossom server and publishes the napplet event.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [`nak`](https://github.com/fiatjaf/nak) (signs + publishes the events), `curl`, and `sha256sum`
|
||||
(or `shasum`/`openssl`).
|
||||
- A Blossom server that accepts BUD-02 uploads (e.g. `https://blossom.primal.net`,
|
||||
`https://cdn.satellite.earth`, or your own).
|
||||
- Your **nsec** — use the **same key you're logged in as in Amethyst**, so the napplet appears under
|
||||
your account and the identity reads (`getProfile`, `getFollows`, …) have data.
|
||||
|
||||
## Publish
|
||||
|
||||
```bash
|
||||
cd tools/napplet-test
|
||||
./publish.sh --sec nsec1yourkey... --server https://blossom.primal.net \
|
||||
--relay wss://relay.damus.io --relay wss://nos.lol
|
||||
```
|
||||
|
||||
Then verify it resolves (optional):
|
||||
|
||||
```bash
|
||||
amy napplet fetch <your-pubkey-hex> --d napplet-test
|
||||
```
|
||||
|
||||
## Open it in Amethyst
|
||||
|
||||
Build & install the debug app and watch the logs:
|
||||
|
||||
```bash
|
||||
./gradlew :amethyst:installPlayDebug
|
||||
adb logcat -s NappletHostActivity NappletBrokerService NappletContentServer
|
||||
```
|
||||
|
||||
Logged in as the publishing key, find **"Napplet Test Harness"** in your Apps / Napplets list (or its
|
||||
feed card) and tap **Open**. It launches in the sandboxed `:napplet` process.
|
||||
|
||||
## What to verify
|
||||
|
||||
- **On load:** each read row turns green. `shell.supports(identity)` = true, `(bogus)` = false. Every
|
||||
capability prompts for consent the first time.
|
||||
- **identity.getList/getZaps/getBadges:** open your own profile first so the cache has your lists /
|
||||
zaps / badges, then relaunch — the rows show your data (empty arrays are valid if you have none).
|
||||
- **identity.onChanged:** with the napplet open, switch accounts (or log out) → the banner shows
|
||||
`identity.changed → <pubkey>`. It must NOT fire on the initial load.
|
||||
- **keys.onAction:** with a hardware keyboard (or `adb shell input keyevent 47` for "S" while holding
|
||||
Ctrl), press **Ctrl+S** → banner shows `keys.action → save fired`, and the keystroke is consumed.
|
||||
- **resource.bytes `nostr:`:** paste a `nostr:nevent1…` / `note1…` / `naddr1…` / `npub1…` and run →
|
||||
it returns the event JSON as a blob. Also try an `https://…` image URL.
|
||||
- **Side effects (deliberate):** `relay.publish` signs+broadcasts a note as you; `upload.blob` uploads
|
||||
a tiny blob; `value.payInvoice` pays a BOLT-11 invoice (needs a connected wallet). Each prompts.
|
||||
- **Security:** the applet has no direct network (a plain `fetch()` inside it fails — CSP
|
||||
`connect-src 'none'`); an undeclared capability is denied even if you'd allow it.
|
||||
|
||||
## Notes
|
||||
|
||||
- `publish.sh` uses `nak`'s `-t key=val1;val2` multi-element tag syntax (e.g.
|
||||
`path=/index.html;<hash>`). If your `nak` version differs, adjust accordingly.
|
||||
- Re-running `publish.sh` replaces the same addressable event (`d=napplet-test`).
|
||||
@@ -0,0 +1,159 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Napplet test harness — a single-file napplet that exercises every window.napplet.* API and
|
||||
renders the result of each on screen, so the Android host can be verified end to end on a device.
|
||||
|
||||
Read-only checks run automatically on load (each triggers a one-time consent prompt per capability).
|
||||
Side-effectful ops (publish / upload / pay) are behind buttons so you trigger them deliberately.
|
||||
Live pushes (identity.changed, keys.action) appear in the banner at the top when they fire.
|
||||
|
||||
Publish it with publish.sh, then open it from Amethyst (your Apps / Napplets list, or its feed card).
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>Napplet Test Harness</title>
|
||||
<style>
|
||||
body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 12px; background: #fff; color: #111; }
|
||||
h1 { font-size: 16px; margin: 0 0 8px; }
|
||||
#banner { min-height: 1.4em; padding: 8px; margin-bottom: 10px; border-radius: 8px; background: #eef; color: #224; font-weight: 600; }
|
||||
.row { padding: 6px 8px; border-bottom: 1px solid #eee; display: flex; gap: 8px; align-items: baseline; }
|
||||
.name { flex: 0 0 12em; font-weight: 600; }
|
||||
.val { flex: 1; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.ok { color: #0a0; } .err { color: #c00; } .pending { color: #888; }
|
||||
section { margin-top: 14px; }
|
||||
input, button { font: inherit; padding: 6px 8px; margin: 2px 0; }
|
||||
input { width: 100%; box-sizing: border-box; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Napplet Test Harness</h1>
|
||||
<div id="banner">Waiting for live pushes (switch account → identity.changed; press Ctrl+S → keys.action)…</div>
|
||||
<div id="results"></div>
|
||||
|
||||
<section>
|
||||
<h1>Side-effectful (tap to run)</h1>
|
||||
<input id="pub" placeholder="note content to publish (relay.publish, signs as you)" />
|
||||
<button onclick="doPublish()">relay.publish</button>
|
||||
<hr/>
|
||||
<input id="res" placeholder="resource.bytes URL — https://… or nostr:nevent1…/note1…/npub1…" />
|
||||
<button onclick="doResource()">resource.bytes</button>
|
||||
<hr/>
|
||||
<button onclick="doUpload()">upload.blob (uploads a tiny text blob)</button>
|
||||
<hr/>
|
||||
<input id="inv" placeholder="BOLT-11 invoice for value.payInvoice" />
|
||||
<button onclick="doPay()">value.payInvoice</button>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
var results = document.getElementById('results');
|
||||
var banner = document.getElementById('banner');
|
||||
|
||||
function row(name) {
|
||||
var r = document.createElement('div'); r.className = 'row';
|
||||
var n = document.createElement('div'); n.className = 'name'; n.textContent = name;
|
||||
var v = document.createElement('div'); v.className = 'val pending'; v.textContent = '…';
|
||||
r.appendChild(n); r.appendChild(v); results.appendChild(r);
|
||||
return v;
|
||||
}
|
||||
function show(v, ok, value) {
|
||||
v.className = 'val ' + (ok ? 'ok' : 'err');
|
||||
v.textContent = (ok ? '' : 'ERROR: ') + (typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
// Run an async napplet call and render pass/fail.
|
||||
function check(name, fn) {
|
||||
var v = row(name);
|
||||
try {
|
||||
Promise.resolve(fn()).then(function (out) { show(v, true, out === undefined ? 'ok' : out); },
|
||||
function (e) { show(v, false, (e && e.message) || String(e)); });
|
||||
} catch (e) { show(v, false, (e && e.message) || String(e)); }
|
||||
}
|
||||
function flash(msg) { banner.textContent = msg + ' (' + new Date().toLocaleTimeString() + ')'; }
|
||||
|
||||
function run() {
|
||||
if (!window.napplet) { banner.textContent = 'window.napplet is MISSING — shim not injected!'; return; }
|
||||
|
||||
// ---- shell negotiation ----
|
||||
check('shell.supports(identity)', function () { return napplet.shell.supports('identity'); });
|
||||
check('shell.supports(bogus)', function () { return napplet.shell.supports('bogus'); });
|
||||
|
||||
// ---- identity (read-only) ----
|
||||
check('identity.getPublicKey', function () { return napplet.identity.getPublicKey(); });
|
||||
check('identity.getProfile', function () { return napplet.identity.getProfile(); });
|
||||
check('identity.getRelays', function () { return napplet.identity.getRelays(); });
|
||||
check('identity.getFollows', function () { return napplet.identity.getFollows().then(trunc); });
|
||||
check('identity.getMutes', function () { return napplet.identity.getMutes().then(trunc); });
|
||||
check('identity.getBlocked', function () { return napplet.identity.getBlocked().then(trunc); });
|
||||
check('identity.getList(bookmarks)', function () { return napplet.identity.getList('bookmarks').then(trunc); });
|
||||
check('identity.getZaps', function () { return napplet.identity.getZaps().then(trunc); });
|
||||
check('identity.getBadges', function () { return napplet.identity.getBadges().then(trunc); });
|
||||
|
||||
// identity.onChanged → live push when you switch / log out
|
||||
try {
|
||||
napplet.identity.onChanged(function (pubkey) { flash('identity.changed → ' + (pubkey || '(logged out)')); });
|
||||
row('identity.onChanged').textContent = 'registered — switch account to fire';
|
||||
} catch (e) { show(row('identity.onChanged'), false, e.message); }
|
||||
|
||||
// ---- relay (read) ----
|
||||
check('relay.query(kind1 limit3)', function () {
|
||||
return napplet.relay.query({ kinds: [1], limit: 3 }).then(function (evs) { return (evs ? evs.length : 0) + ' events'; });
|
||||
});
|
||||
check('relay.subscribe(kind1)', function () {
|
||||
return new Promise(function (resolve) {
|
||||
var n = 0;
|
||||
var sub = napplet.relay.subscribe({ kinds: [1], limit: 5 }, function () { n++; }, function () { sub.close(); resolve(n + ' events, EOSE ok'); });
|
||||
setTimeout(function () { try { sub.close(); } catch (_) {} resolve(n + ' events (timeout)'); }, 8000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- storage round-trip ----
|
||||
check('storage round-trip', function () {
|
||||
var key = 'k' + Date.now();
|
||||
return napplet.storage.setItem(key, 'v1')
|
||||
.then(function () { return napplet.storage.getItem(key); })
|
||||
.then(function (got) {
|
||||
if (got !== 'v1') throw new Error('getItem returned ' + got);
|
||||
return napplet.storage.keys();
|
||||
})
|
||||
.then(function (keys) { return napplet.storage.removeItem(key).then(function () { return 'set/get/keys(' + keys.length + ')/remove ok'; }); });
|
||||
});
|
||||
|
||||
// ---- keys: register Ctrl+S, fire via onAction ----
|
||||
try {
|
||||
napplet.keys.registerAction({ id: 'save', label: 'Save', defaultKey: 'Ctrl+S' }).then(function (reg) {
|
||||
show(row('keys.registerAction'), true, reg);
|
||||
});
|
||||
napplet.keys.onAction('save', function () { flash('keys.action → save fired'); });
|
||||
} catch (e) { show(row('keys.registerAction'), false, e.message); }
|
||||
}
|
||||
|
||||
function trunc(x) { var s = JSON.stringify(x); return s.length > 200 ? s.slice(0, 200) + '…' : s; }
|
||||
|
||||
// ---- side-effectful handlers ----
|
||||
function doPublish() {
|
||||
var c = document.getElementById('pub').value || ('napplet test ' + Date.now());
|
||||
check('relay.publish', function () { return napplet.relay.publish({ kind: 1, content: c, tags: [] }).then(function (ev) { return 'published ' + ev.id; }); });
|
||||
}
|
||||
function doResource() {
|
||||
var u = document.getElementById('res').value;
|
||||
if (!u) { flash('enter a URL first'); return; }
|
||||
check('resource.bytes(' + u.slice(0, 24) + '…)', function () {
|
||||
return napplet.resource.bytes(u).then(function (blob) { return blob.size + ' bytes, type=' + blob.type; });
|
||||
});
|
||||
}
|
||||
function doUpload() {
|
||||
var bytes = new TextEncoder().encode('napplet upload test ' + Date.now());
|
||||
check('upload.blob', function () { return napplet.upload.blob(bytes, 'text/plain').then(function (url) { return url; }); });
|
||||
}
|
||||
function doPay() {
|
||||
var inv = document.getElementById('inv').value;
|
||||
if (!inv) { flash('enter an invoice first'); return; }
|
||||
check('value.payInvoice', function () { return napplet.value.payInvoice(inv).then(function (pre) { return 'preimage ' + pre; }); });
|
||||
}
|
||||
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Publish the test napplet (index.html) so it can be opened in Amethyst on a device:
|
||||
# 1. uploads index.html to a Blossom server (BUD-02 signed upload),
|
||||
# 2. publishes a NIP-5D named-napplet event (kind 35129) whose manifest pins
|
||||
# /index.html to the blob's sha256 and declares the capabilities it uses.
|
||||
#
|
||||
# Requirements: nak (https://github.com/fiatjaf/nak), curl, and sha256sum (or shasum/openssl).
|
||||
#
|
||||
# Usage:
|
||||
# ./publish.sh --sec nsec1... --server https://blossom.example [--relay wss://... ]... [--id napplet-test]
|
||||
#
|
||||
# The secret key can also come from $NOSTR_SECRET_KEY or $NSEC. Relays default to a couple of
|
||||
# public ones if none are given. Use the SAME key you are logged in as in Amethyst, so the napplet
|
||||
# shows up under your own account and the identity reads have data.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
HTML="$HERE/index.html"
|
||||
ID="napplet-test"
|
||||
SERVER=""
|
||||
SEC="${NOSTR_SECRET_KEY:-${NSEC:-}}"
|
||||
RELAYS=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--sec) SEC="$2"; shift 2 ;;
|
||||
--server) SERVER="${2%/}"; shift 2 ;;
|
||||
--relay) RELAYS+=("$2"); shift 2 ;;
|
||||
--id) ID="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$SEC" ] || { echo "ERROR: no secret key (--sec / \$NOSTR_SECRET_KEY / \$NSEC)" >&2; exit 2; }
|
||||
[ -n "$SERVER" ] || { echo "ERROR: no --server (a Blossom base URL, e.g. https://blossom.primal.net)" >&2; exit 2; }
|
||||
command -v nak >/dev/null || { echo "ERROR: nak not found (https://github.com/fiatjaf/nak)" >&2; exit 2; }
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 2; }
|
||||
[ ${#RELAYS[@]} -gt 0 ] || RELAYS=(wss://relay.damus.io wss://nos.lol)
|
||||
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null; then sha256sum | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null; then shasum -a 256 | cut -d' ' -f1
|
||||
else openssl dgst -sha256 | sed 's/.* //'; fi
|
||||
}
|
||||
b64() { base64 | tr -d '\n'; }
|
||||
|
||||
HASH="$(sha256 < "$HTML")"
|
||||
echo "index.html sha256 : $HASH"
|
||||
|
||||
# --- 1. Blossom upload (BUD-02: a kind-24242 'upload' auth event in the Authorization header) ---
|
||||
EXP=$(( $(date +%s) + 3600 ))
|
||||
AUTH_JSON="$(nak event -k 24242 --sec "$SEC" -t "t=upload" -t "x=$HASH" -t "expiration=$EXP" -c "Upload napplet test")"
|
||||
AUTH_B64="$(printf '%s' "$AUTH_JSON" | b64)"
|
||||
|
||||
echo "Uploading to $SERVER/upload …"
|
||||
UP="$(curl -sS -X PUT "$SERVER/upload" \
|
||||
-H "Authorization: Nostr $AUTH_B64" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @"$HTML")"
|
||||
echo " server said: $UP"
|
||||
|
||||
echo "Verifying $SERVER/$HASH is retrievable …"
|
||||
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$SERVER/$HASH")"
|
||||
[ "$CODE" = "200" ] || { echo "ERROR: blob not retrievable (HTTP $CODE). Check the upload response above." >&2; exit 1; }
|
||||
echo " OK (HTTP 200)"
|
||||
|
||||
# --- 2. NIP-5A aggregate hash over the one path: sha256("<filehash> /index.html\n") ---
|
||||
AGG="$(printf '%s /index.html\n' "$HASH" | sha256)"
|
||||
echo "aggregate hash : $AGG"
|
||||
|
||||
# --- 3. Publish the NIP-5D named napplet (kind 35129) ---
|
||||
echo "Publishing napplet event (kind 35129, d=$ID) to: ${RELAYS[*]}"
|
||||
nak event -k 35129 --sec "$SEC" \
|
||||
-d "$ID" \
|
||||
-t "path=/index.html;$HASH" \
|
||||
-t "x=$AGG;aggregate" \
|
||||
-t "server=$SERVER" \
|
||||
-t "requires=identity" \
|
||||
-t "requires=relay" \
|
||||
-t "requires=storage" \
|
||||
-t "requires=value" \
|
||||
-t "requires=resource" \
|
||||
-t "requires=upload" \
|
||||
-t "requires=keys" \
|
||||
-t "title=Napplet Test Harness" \
|
||||
-t "description=Exercises every napplet.* API for on-device verification." \
|
||||
"${RELAYS[@]}"
|
||||
|
||||
PUB="$(nak key public "$SEC")"
|
||||
echo
|
||||
echo "Done. Author pubkey: $PUB"
|
||||
echo "Verify resolution with amy:"
|
||||
echo " amy napplet fetch $PUB --d $ID"
|
||||
echo "Then in Amethyst (logged in as this key): open your Apps / Napplets list and tap \"Napplet Test Harness\"."
|
||||
Reference in New Issue
Block a user