Files
client/app-stacks/publish_all.py
T

539 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Publish all 20 app stacks and all app definitions with Gitea mirror URLs.
Uses n_signer via Qubes qrexec (nostr_index=0) to sign events,
then publishes to all configured relays.
Usage:
python3 app-stacks/publish_all.py
"""
import asyncio
import json
import os
import struct
import subprocess
import sys
import time
import websockets
QREXEC_TARGET = "nostr_signer"
QREXEC_SERVICE = "qubes.NsignerRpc"
RELAYS = [
"wss://relay.zapstore.dev",
"wss://laantungir.net/relay",
"wss://nos.lol",
"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.
_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 = [
{
"d": "privacy-approved-bitcoin-lightning-wallets",
"name": "Privacy Approved — Bitcoin & Lightning Wallets",
"description": "Self-custodial Bitcoin and Lightning wallet apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("app.zeusln.zeus", "ZEUS"),
("io.nunchuk.android", "Nunchuk"),
("com.cakewallet.cake_wallet", "Cake Wallet"),
("com.getalby.mobile", "Alby Go"),
("com.blitzwallet", "Blitz Wallet"),
("io.bluewallet.bluewallet", "BlueWallet"),
("org.electrum.electrum", "Electrum"),
("com.oubli.wallet", "Oubli"),
("com.kilombino.mercasats", "MercaSats"),
("com.magius.lightningreaction", "Lightning Reaction"),
],
},
{
"d": "privacy-approved-nostr-clients",
"name": "Privacy Approved — Nostr Clients",
"description": "Nostr-native social and communication clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.vitorpamplona.amethyst", "Amethyst"),
("com.greenart7c3.nostrsigner", "Amber"),
("com.wisp.app", "Wisp"),
("com.darkwisp.app", "Dark Wisp"),
("org.parres.whitenoise", "White Noise"),
("com.nospeak.app", "Nospeak"),
("org.nostr.nostrord", "Nostrord"),
("com.shosho.app", "Shosho"),
("com.traveltelly.app", "TravelTelly"),
("app.nostrmail.client", "Nmail"),
("co.openvine.app", "Divine"),
("com.yakihonne.yakihonne", "YakiHonne"),
("com.nostrnests.app", "Nests"),
("app.nostria.twa", "Nostria"),
("dev.zapstore.alpha", "Zapstore Alpha"),
("com.pearcal", "PearCal"),
("com.pearcircle", "PearCircle"),
("pub.ditto.app", "Ditto"),
("social.flotilla", "Flotilla"),
],
},
{
"d": "privacy-approved-secure-messaging",
"name": "Privacy Approved — Secure Messaging",
"description": "End-to-end encrypted messaging and email apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("chat.simplex.app", "SimpleX"),
("eu.siacs.conversations", "Conversations"),
("chat.delta", "Delta Chat"),
("org.atalk.android", "aTalk"),
("im.vector.app", "Element"),
("io.element.android.x", "Element X"),
("net.thunderbird.android", "Thunderbird"),
("eu.faircode.email", "FairEmail"),
("de.tutao.tutanota", "Tuta"),
("com.quietmobile", "Quiet"),
("spam.blocker", "SpamBlocker"),
("com.afkanerd.deku", "Deku SMS"),
],
},
{
"d": "privacy-approved-vpn-privacy-tools",
"name": "Privacy Approved — VPN & Privacy Tools",
"description": "VPN clients, firewalls, and network privacy tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("org.torproject.android", "Orbot"),
("net.mullvad.mullvadvpn", "Mullvad VPN"),
("com.tailscale.ipn", "Tailscale"),
("org.amnezia.vpn", "AmneziaVPN"),
("ch.protonvpn.android", "Proton VPN"),
("com.celzero.bravedns", "Rethink"),
("com.emanuelef.remote_capture", "PCAPdroid"),
("com.zaneschepke.wireguardautotunnel", "WG Tunnel"),
("io.github.romanvht.byedpi", "ByeDPI"),
("xyz.malkki.neostumbler", "NeoStumbler"),
("eu.darken.sdmse", "SD Maid"),
("pan.alexander.tordnscrypt.stable", "InviZible Pro"),
("com.flashsphere.privatednsqs", "Private DNS Quick Setting"),
],
},
{
"d": "privacy-approved-password-managers-auth",
"name": "Privacy Approved — Password Managers & Auth",
"description": "Password managers, 2FA authenticators, and identity tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.x8bit.bitwarden", "Bitwarden"),
("com.kunzisoft.keepass.libre", "KeePassDX"),
("com.beemdevelopment.aegis", "Aegis"),
("pl.lebihan.authnkey", "Authnkey"),
("io.ente.auth.independent", "Ente Auth"),
("io.privkey.keep", "Keep"),
("net.aliasvault.app", "AliasVault"),
("com.jksalcedo.passvault", "PassVault"),
("com.jksalcedo.librefind", "LibreFind"),
],
},
{
"d": "privacy-approved-maps-navigation",
"name": "Privacy Approved — Maps & Navigation",
"description": "Offline maps, navigation, and location tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("app.organicmaps.web", "Organic Maps"),
("net.osmand.plus", "OsmAnd~"),
("de.westnordost.streetcomplete", "StreetComplete"),
("com.comaps.app", "CoMaps"),
("org.owntracks.android", "OwnTracks"),
("net.osmtracker", "OSMTracker"),
],
},
{
"d": "privacy-approved-media-players-streaming",
"name": "Privacy Approved — Media Players & Streaming",
"description": "Video and music players, streaming clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("org.videolan.vlc", "VLC"),
("org.schabi.newpipe", "NewPipe"),
("infinityloop1309.newpipeenhanced", "PipePipe"),
("org.oxycblt.auxio", "Auxio"),
("com.gokadzev.musify", "Musify"),
("com.metrolist.music", "Metrolist"),
("io.freetubeapp.freetube", "FreeTube"),
("app.zaptrax", "Zaptrax"),
("app.zappix", "Zappix"),
("app.marlboroadvance.mpvex", "mpvEx"),
("com.github.k1rakishou.chan", "KurobaEx"),
],
},
{
"d": "privacy-approved-productivity-notes",
"name": "Privacy Approved — Productivity & Notes",
"description": "Note-taking, task management, and productivity tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.flux", "Flux"),
("com.streetwriters.notesnook", "Notesnook"),
("io.github.quillpad", "Quillpad"),
("com.adilhanney.saber", "Saber"),
("at.bitfire.davdroid", "DAVx⁵"),
("com.superproductivity.superproductivity", "Super Productivity"),
("eu.fliegendurst.triliumdroid", "Trilium Notes"),
("ch.martinstoeckli.silentnotes", "SilentNotes"),
("com.dtonon.manent", "Manent"),
("jp.godzhigella.meiso", "Meiso"),
("com.nahnah.florid", "Florid"),
("com.plektos.app", "Plektos"),
("io.nurunuru.app", "Urn"),
("com.shub39.grit", "Grit"),
("com.electricdreams.numo", "Numo"),
("me.timeto.app", "timeto.me"),
("com.ferrarid.converterpro", "Converter NOW"),
],
},
{
"d": "privacy-approved-file-management-cloud-sync",
"name": "Privacy Approved — File Management & Cloud Sync",
"description": "Cloud storage, file sync, and file management tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.nextcloud.client", "Nextcloud"),
("com.github.catfriend1.syncthingfork", "Syncthing-Fork"),
("com.seafile.seadroid2", "Seafile"),
("com.example.file_transfer", "File transfer"),
("org.primftpd", "primitive ftpd"),
("de.astubenbord.paperless_mobile", "Paperless Mobile"),
("com.viscouspot.gitsync", "GitSync"),
("eu.heili.wormhole", "wormhole"),
("lu.knaff.alain.saf_sftp", "SFTP Documents Provider"),
("com.akylas.documentscanner", "OSS Document Scanner"),
],
},
{
"d": "privacy-approved-browsers",
"name": "Privacy Approved — Browsers",
"description": "Privacy-focused web browsers that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.duckduckgo.mobile.android", "DuckDuckGo"),
("com.cromite.app", "Cromite"),
],
},
{
"d": "privacy-approved-social-media",
"name": "Privacy Approved — Social Media",
"description": "Federated and alternative social media clients that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("org.joinmastodon.android", "Mastodon"),
("tw.nekomimi.nekogram", "Nekogram"),
("ml.docilealligator.infinityforreddit.plus", "Infinity+"),
("com.newsblur", "NewsBlur"),
("dev.msfjarvis.claw.android", "Claw"),
("com.streamatico.polymarketviewer", "Polymarket Viewer"),
],
},
{
"d": "privacy-approved-finance-budgeting",
"name": "Privacy Approved — Finance & Budgeting",
"description": "Personal finance, expense tracking, and budgeting apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.flux", "Flow"),
("com.pennywiseai.tracker", "Pennywise AI Tracker"),
("com.github.premnirmal.tickerwidget.dev", "Dev Stocks Widget"),
("com.pearguard", "PearGuard"),
("network.mostro.app", "Mostro"),
("eu.domob.shopt2", "Shopt"),
],
},
{
"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"),
],
},
{
"d": "privacy-approved-education-reference",
"name": "Privacy Approved — Education & Reference",
"description": "Learning, dictionary, and reference tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("org.freecodecamp", "freeCodeCamp"),
("de.felixnuesse.disky", "freeDictionary"),
("org.eu.mumulhl.ciyue", "Ciyue"),
("com.tavultesoft.kmapro", "Keyman"),
("com.hardbacknutter.nevertoomanybooks", "NeverTooManyBooks"),
("de.doen1el.calibrewebcompanion", "Calibre Web Companion"),
("io.github.adithya_jayan.myrepertoirapp.fdroid", "Repertoire"),
("org.listenbrainz.android", "ListenBrainz"),
("com.kgurgul.cpuinfo", "CPU Info"),
("com.mkulesh.micromath.plus", "microMathematics Plus"),
],
},
{
"d": "privacy-approved-photography-image-tools",
"name": "Privacy Approved — Photography & Image Tools",
"description": "Camera, photo editing, scanning, and gallery apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("tech.lolli.toolbox", "Image Toolbox"),
("org.fairscan.app", "FairScan"),
("ua.com.radiokot.photoprism", "PhotoPrism"),
("com.kjxbyz.picguard", "PicGuard"),
("com.dot.gallery", "Gallery apps"),
("com.deniscerri.ytdl", "YTDLnis"),
("me.elcaju", "ElCaju"),
],
},
{
"d": "privacy-approved-utilities-tools",
"name": "Privacy Approved — Utilities & Tools",
"description": "System utilities, converters, and general-purpose tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("de.markusfisch.android.binaryeye", "Binary Eye"),
("com.kgurgul.cpuinfo", "CPU Info"),
("com.ferrarid.converterpro", "Converter NOW"),
("me.hackerchick.catima", "Catima"),
("app.lawnchair.lawnicons", "Lawnicons"),
("app.simple.peri", "Peristyle"),
("org.exbin.bined.editor.android", "BinEd"),
("com.dergoogler.mmrl", "MMRL"),
("io.github.samolego.canta", "Canta"),
("deltazero.amarok.foss", "Amarok"),
("com.rama.mako", "Mako"),
("com.shub39.rush", "Rush"),
("helium314.keyboard", "HeliBoard"),
("com.best.deskclock", "DeskClock"),
("com.acszo.redomi", "Redomi"),
("com.arn.scrobble", "Scrobble"),
("com.nononsenseapps.feeder.play", "Feeder"),
("io.github.sms2email.sms2email", "SMS2Email"),
("app.simple.inure", "Inure"),
("com.unciv.app", "Unciv"),
],
},
{
"d": "privacy-approved-games",
"name": "Privacy Approved — Games",
"description": "Open source games across genres that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.shatteredpixel.shatteredpixeldungeon", "Shattered Pixel Dungeon"),
("org.wesnoth.wesnoth", "Wesnoth"),
("de.sesu8642.feudaltactics", "Feudal Tactics"),
("com.agateau.burgerparty", "Burger Party"),
("de.chadenas.cpudefense", "ChipDefense"),
("de.z11.roboyard", "Roboyard"),
("com.damasclash", "Damas Clash"),
("com.unciv.app", "Unciv"),
],
},
{
"d": "privacy-approved-communication-non-nostr",
"name": "Privacy Approved — Communication (Non-Nostr)",
"description": "Voice/video calls, remote desktop, and messaging apps that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("org.telegram.messenger.web", "Telegram"),
("com.carriez.flutter_hbb", "RustDesk"),
("io.unsigned.sideband", "Sideband"),
("com.geeksville.mesh", "Meshtastic"),
],
},
{
"d": "privacy-approved-development-tools",
"name": "Privacy Approved — Development Tools",
"description": "Code editors, Git clients, and developer tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("com.foxdebug.acode", "Acode"),
("com.viscouspot.gitsync", "GitSync"),
("org.exbin.bined.editor.android", "BinEd"),
("org.freecodecamp", "freeCodeCamp"),
("zed.rainxch.githubstore", "GitHub Store"),
("com.inspiredandroid.kai", "Kai 9000"),
],
},
{
"d": "privacy-approved-calendar-scheduling",
"name": "Privacy Approved — Calendar & Scheduling",
"description": "Calendar apps, scheduling, and time management tools that passed privacy review. No tracking SDKs, minimal permissions, open source.",
"apps": [
("app.formstr.calendar", "Calendar by Form*"),
("com.pearcal", "PearCal"),
("me.timeto.app", "timeto.me"),
("com.blankdev.sidestep", "Sidestep"),
],
},
]
# ── Gitea mirror URL lookup ────────────────────────────────────────
def gitea_url(identifier):
return f"https://laantungir.net/git/zapstore-mirror/{identifier}"
# ── n_signer qrexec helpers ────────────────────────────────────────
def qrexec_call(request):
framed = struct.pack(">I", len(json.dumps(request).encode())) + json.dumps(request).encode()
proc = subprocess.run(
["qrexec-client-vm", QREXEC_TARGET, QREXEC_SERVICE],
input=framed, capture_output=True, timeout=30
)
if proc.returncode != 0:
raise Exception(f"qrexec failed: {proc.stderr.decode()}")
resp_len = struct.unpack(">I", proc.stdout[:4])[0]
return json.loads(proc.stdout[4:4 + resp_len])
# ── Relay publish helper ───────────────────────────────────────────
async def publish_event(signed_event, label=""):
async def publish_one(relay):
try:
async with websockets.connect(relay, max_size=10_000_000, open_timeout=10) as ws:
await ws.send(json.dumps(["EVENT", signed_event]))
while True:
resp = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(resp)
if data[0] == "OK" and data[1] == signed_event["id"]:
return relay, data[2]
except Exception:
return relay, False
results = await asyncio.gather(*[publish_one(r) for r in RELAYS])
ok = sum(1 for _, a in results if a)
total = len(results)
print(f" {label}Published to {ok}/{total} relays")
return ok > 0
# ── Main ───────────────────────────────────────────────────────────
async def main():
print("=" * 60)
print("Publishing all app stacks and app definitions")
print("=" * 60)
# Get pubkey
print("\n1. Getting pubkey from n_signer...")
req = {"id": "1", "method": "nostr_get_public_key", "params": [{"nostr_index": 0}]}
resp = qrexec_call(req)
if "error" in resp:
print(f" Error: {resp['error']}")
sys.exit(1)
pubkey = resp["result"]
print(f" Pubkey: {pubkey}")
results = {"stacks": 0, "stacks_failed": 0, "apps": 0, "apps_failed": 0}
# ── Publish all stacks ──────────────────────────────────────────
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_tag],
]
# 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"]:
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": "", # Must be empty — zapstore treats non-empty content as encrypted
"tags": tags,
"created_at": created_at,
"pubkey": pubkey,
}
req = {"id": "2", "method": "nostr_sign_event", "params": [json.dumps(event), {"nostr_index": 0}]}
resp = qrexec_call(req)
if "error" in resp:
print(f" [{i}/{len(STACKS)}] ❌ {stack['name']}: {resp['error']}")
results["stacks_failed"] += 1
continue
signed = json.loads(resp["result"])
print(f" [{i}/{len(STACKS)}] {stack['name']} ({len(stack['apps'])} apps) id={signed['id'][:16]}...")
await publish_event(signed, " ")
results["stacks"] += 1
# ── Publish all app definitions ─────────────────────────────────
total_apps = sum(len(s["apps"]) for s in STACKS)
print(f"\n3. Publishing {total_apps} app definitions...")
app_num = 0
for stack in STACKS:
for app_id, app_name in stack["apps"]:
app_num += 1
repo_url = gitea_url(app_id)
created_at = int(time.time())
event = {
"kind": 30078,
"content": json.dumps({
"name": app_name,
"identifier": app_id,
"repository": repo_url,
"description": "",
}),
"tags": [
["d", "app-" + app_id],
["t", "app-definition"],
["t", stack["d"]],
["name", app_name],
["repository", repo_url],
["gitea", app_id],
],
"created_at": created_at,
"pubkey": pubkey,
}
req = {"id": "3", "method": "nostr_sign_event", "params": [json.dumps(event), {"nostr_index": 0}]}
resp = qrexec_call(req)
if "error" in resp:
print(f" [{app_num}/{total_apps}] ❌ {app_name}: {resp['error']}")
results["apps_failed"] += 1
continue
signed = json.loads(resp["result"])
print(f" [{app_num}/{total_apps}] {app_name} ({app_id}) id={signed['id'][:16]}...")
await publish_event(signed, " ")
results["apps"] += 1
# ── Summary ─────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" Stacks: {results['stacks']} published, {results['stacks_failed']} failed")
print(f" Apps: {results['apps']} published, {results['apps_failed']} failed")
print(f" Total: {results['stacks'] + results['apps']} events")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())