diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 10:05:10 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 10:05:10 -0700 |
| commit | a846211262f37238c001e5210938749294f8db6a (patch) | |
| tree | eb2f1c313051a739ef18a32d39bc4988a63e44fd /src | |
| parent | cab447766734cb99821c36988ebc8d27a0bce228 (diff) | |
| download | coinosv3-a846211262f37238c001e5210938749294f8db6a.tar.gz coinosv3-a846211262f37238c001e5210938749294f8db6a.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 'src')
| -rw-r--r-- | src/features/messages.js | 30 | ||||
| -rw-r--r-- | src/features/nwc.js | 40 |
2 files changed, 62 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index b12efa7..5ff1492 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1267,8 +1267,38 @@ export function messagesFeature(ctx) { } catch { inboxAt = 0; } } + // The Android wrapper hands us a UnifiedPush endpoint on its launch URL + // (?up=), because the distributor talks to the app and only the web app + // knows which pubkeys are worth watching. On a phone without Google Play + // Services this is the ONLY way to be reached while closed, so it takes + // precedence over the browser's own push — which there doesn't work at all. + const UP_KEY = 'coinos-unifiedpush'; + const upEndpoint = () => { try { return localStorage.getItem(UP_KEY) || null; } catch { return null; } }; + (() => { + try { + const u = new URLSearchParams(location.search).get('up'); + if (!u) return; + localStorage.setItem(UP_KEY, u); + // tidy the URL the way the payment intent does + history.replaceState(null, '', location.pathname || '/'); + } catch {} + })(); + async function registerPush({ interactive = false } = {}) { try { + // A UnifiedPush endpoint needs no permission and no push service: it's + // a URL the distributor gave the wrapper, and the notifier POSTs to it. + const up = upEndpoint(); + if (up) { + const r = await fetch(`${NOTIFIER}/register`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, notify: pushWatch() }), + }); + if (!r.ok) return false; + const s2 = st(); + if (!s2.push || s2.noPushService) { s2.push = true; delete s2.noPushService; save(s2); } + return true; + } if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false; if (Notification.permission !== 'granted') { if (!interactive) return false; diff --git a/src/features/nwc.js b/src/features/nwc.js index 077b53e..dd0141d 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -595,7 +595,28 @@ export function nwcFeature(ctx) { return /push service|Registration failed|AbortError|NotSupportedError|permission denied/i.test(m); }; + // The Android wrapper's UnifiedPush endpoint, if it handed us one (see + // messages.js, which reads it off the launch URL). It needs no permission + // and no push service, and on a phone without Google Play Services it is + // the only way to be reached at all. + const upEndpoint = () => { try { return localStorage.getItem('coinos-unifiedpush') || null; } catch { return null; } }; + async function enableBackground() { + const up = upEndpoint(); + if (up) { + const pksUp = conns().map((c) => c.servicePk); + if (load().offer) pksUp.push(load().offer.pk); + if (pksUp.length) { + const r = await fetch(`${NOTIFIER}/register`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, servicePubkeys: pksUp }), + }); + if (!r.ok) throw new Error(`notifier refused: ${r.status}`); + } + const stUp = load(); stUp.background = true; delete stUp.pushUnavailable; save(stUp); + writeBg(); + return true; + } if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error(t('nwcNoPushHere')); } @@ -643,11 +664,13 @@ export function nwcFeature(ctx) { async function ensureBackground(interactive) { const st = load(); if (st.background || st.backgroundOff) return; - if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) - || typeof window === 'undefined' || !('PushManager' in window) - || typeof Notification === 'undefined') return; if (!conns().length) return; - if (!interactive && Notification.permission !== 'granted') return; + if (!upEndpoint()) { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) + || typeof window === 'undefined' || !('PushManager' in window) + || typeof Notification === 'undefined') return; + if (!interactive && Notification.permission !== 'granted') return; + } try { await enableBackground(); await reconcileBg(); @@ -691,15 +714,16 @@ export function nwcFeature(ctx) { clearTimeout(regTimer); regTimer = setTimeout(async () => { try { - const reg = await navigator.serviceWorker.ready; - const sub = await reg.pushManager.getSubscription(); - if (!sub) return; const pks = conns().map((c) => c.servicePk); if (load().offer) pks.push(load().offer.pk); if (!pks.length) return; + const up2 = upEndpoint(); + const sub = up2 ? { endpoint: up2, unifiedpush: true } + : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON()); + if (!sub) return; await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: pks }), + body: JSON.stringify({ subscription: sub, servicePubkeys: pks }), }); } catch (e) { console.warn('nwc: notifier re-register failed', e.message); } }, 1500); |
