aboutsummaryrefslogtreecommitdiffstats
path: root/nwcpush
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 10:05:10 -0700
committerAdam Soltys <[email protected]>2026-09-12 10:05:10 -0700
commit4bf68601fa66d56ff485fde4e096e3fa6dc274bf (patch)
treeb5a9a5b20eaa9bbcef6c9ef1d505fd0a20b7d7c7 /nwcpush
parentf85da3b7b228ae2e20204c673e464236098e0656 (diff)
downloadcoinosv3-server-4bf68601fa66d56ff485fde4e096e3fa6dc274bf.tar.gz
coinosv3-server-4bf68601fa66d56ff485fde4e096e3fa6dc274bf.zip
UnifiedPush: reach a phone that has no Google in it.
Web push on Android is Firebase's, so a phone without Google Play Services — GrapheneOS, CalyxOS, any de-Googled Android — cannot be woken by a web app at all. UnifiedPush is the way that doesn't involve Google: the user runs a distributor (ntfy and friends), apps ask it for an endpoint, and whoever wants to reach the device POSTs to that endpoint. THE WRAPPER speaks the protocol directly — four broadcasts each way, so it pulls in no library; an APK this small has no business growing a Kotlin runtime for eight intents. It asks the distributor on every launch (the call is idempotent, and it's how a distributor installed later gets noticed), raises a notification when a message arrives, and acknowledges it so the distributor stops resending. THE ENDPOINT reaches the web app on the launch URL as ?up=, the same road payment intents already travel — because the distributor talks to the app and only the web app knows which pubkeys are worth watching. It's registered with the notifier in place of a browser subscription, for notifications and for NWC alike, and needs neither permission nor push service. THE NOTIFIER sends to either kind: one deliver() that POSTs the payload to a UnifiedPush endpoint, or hands it to web-push as before. Same payloads, same 404/410 pruning. The endpoint is the only secret there, so it's never logged in full. Verified: a POST to a live ntfy endpoint delivers the payload byte for byte, so the app parses exactly what the notifier sent; the APK builds, carries the receiver, and is signed with the certificate assetlinks already pins (v1.6). tools/unifiedpush-test.js drives a browser with no push service and checks the endpoint is taken off the URL, kept, and registered. Honest about the limit: a UnifiedPush message wakes the ANDROID app, not Chrome's service worker, and the wallet's keys live in the browser's storage where nothing here can reach them. So a zap request arrives as a notification you tap, and the wallet answers when it opens — one tap instead of none. Automatic answering while closed still needs a browser with a push service. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'nwcpush')
-rw-r--r--nwcpush/server.js35
1 files changed, 32 insertions, 3 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index cd7d28f..d441bdd 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -48,6 +48,35 @@ const REQ_KIND = 23194;
const OFFER_KIND = 21001;
webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
+
+// Two ways to reach a device.
+//
+// Web Push is the browser's, and on Android it is Firebase's — a phone
+// without Google Play Services (GrapheneOS and friends) can't subscribe at
+// all. UnifiedPush is the other way: the user runs a distributor, the app
+// gets an endpoint, and anyone wanting to reach the device POSTs the payload
+// to it. No encryption envelope and no VAPID — the endpoint is the secret,
+// which is why we never log one in full.
+//
+// A subscription marked `unifiedpush` is the second kind; everything else is
+// a browser's. One function, so every send path serves both.
+const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string');
+async function deliver(sub, payload, opts = {}) {
+ if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts);
+ const res = await fetch(sub.endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: payload,
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) {
+ // shaped like web-push's errors so the callers' 404/410 pruning works
+ const err = new Error(`unifiedpush ${res.status}`);
+ err.statusCode = res.status;
+ throw err;
+ }
+ return res;
+}
const log = (...a) => console.log(new Date().toISOString(), ...a);
// ---------------------------------------------------------------------------
@@ -158,14 +187,14 @@ async function pushNotify(id, r, reason, extra) {
const payload = JSON.stringify({ type: 'notify', reason, ...extra });
try {
try {
- await webpush.sendNotification(r.sub, payload, { TTL: 3600, urgency: 'normal' });
+ await deliver(r.sub, payload, { TTL: 3600, urgency: 'normal' });
} catch (e) {
// A payload the push service won't take (413, or the library refusing
// the size) must not cost the notification itself — send the bare nudge
// and let the device show its generic message.
if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e;
log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`);
- await webpush.sendNotification(r.sub, JSON.stringify({ type: 'notify', reason }), { TTL: 3600, urgency: 'normal' });
+ await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), { TTL: 3600, urgency: 'normal' });
}
log(`notify(${reason}) -> device ${id}`);
} catch (e) {
@@ -261,7 +290,7 @@ async function wake(servicePk, eventId, ev) {
let ok = 0;
for (const r of targets) {
try {
- await webpush.sendNotification(r.sub, payload, { TTL: 60, urgency: 'high' });
+ await deliver(r.sub, payload, { TTL: 60, urgency: 'high' });
ok++;
} catch (e) {
// 404/410 mean the browser dropped the subscription: forget it.