aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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
commita846211262f37238c001e5210938749294f8db6a (patch)
treeeb2f1c313051a739ef18a32d39bc4988a63e44fd /src/features
parentcab447766734cb99821c36988ebc8d27a0bce228 (diff)
downloadcoinosv3-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/features')
-rw-r--r--src/features/messages.js30
-rw-r--r--src/features/nwc.js40
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);