diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 11:44:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 11:44:43 -0700 |
| commit | d561d4110c7b484bee077678b8469188a3541753 (patch) | |
| tree | 7b4ce64345cdc2a0081dcdb24a82d3a608b507a2 /src | |
| parent | 0319e2fd392fc7e8569fbb6a88a6a1dd65e9d539 (diff) | |
| download | coinosv3-d561d4110c7b484bee077678b8469188a3541753.tar.gz coinosv3-d561d4110c7b484bee077678b8469188a3541753.zip | |
Wake hal for wallet-connect requests while it is closed
Step 3, first half. Android Chrome freezes backgrounded PWAs and closes their
relay sockets, so a phone with hal "open" answers nothing — which is what made
Amethyst spin. Push is the only way to reach a frozen app.
- service worker gains a `push` handler. It deliberately does NOT spend: if a
window is open it nudges it to re-listen, otherwise it raises a notification
that opens hal on tap. Putting a spend path in the worker is the remaining
half and wants its own testing.
- hal-side: request permission, subscribe with the notifier's VAPID key, and
register its service pubkeys with nwcpush.coinos.io. Turning it off
deregisters, unsubscribes, and clears the pouch — no reason to leave
spendable keys where a worker can read them if nothing will.
- a Settings toggle, and the ark feature exposes the ASP context the pouch
record needs.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/ark.js | 6 | ||||
| -rw-r--r-- | src/features/nwc.js | 86 | ||||
| -rw-r--r-- | src/i18n.js | 5 |
3 files changed, 97 insertions, 0 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 18baef6..9621c4b 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1661,6 +1661,12 @@ export function arkFeature(ctx) { return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, + // endpoints + server identity for the NWC pouch record + arkPouchContext() { + const cfg = getArkConfig(); + if (!cfg || !ark || !ark.info) return null; + return { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }; + }, // An npub pasted into Send becomes an ark zap (needs the nostr seam from // the sync feature and a connected-able ark). A bolt11 is handled here // only in builds without the swaps feature (whose matcher runs first and diff --git a/src/features/nwc.js b/src/features/nwc.js index 8ead836..e0af5da 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,6 +30,7 @@ import { import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; +import { buildPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; import { maybeBolt11 } from '../ark/lightning.js'; const REQ_KIND = 23194; @@ -332,6 +333,80 @@ export function nwcFeature(ctx) { } } + // ---- background wake-ups ---------------------------------------------- + // The notifier can't decrypt anything; it only learns which service pubkeys + // to watch. When a request arrives it pushes, the service worker surfaces + // it (or nudges an open window), and hal answers from the relay. + const NOTIFIER = 'https://nwcpush.coinos.io'; + + const b64ToBytes = (b64) => { + const pad = '='.repeat((4 - (b64.length % 4)) % 4); + const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(raw, (c) => c.charCodeAt(0)); + }; + + async function enableBackground() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + throw new Error('this browser cannot receive push'); + } + const perm = await Notification.requestPermission(); + if (perm !== 'granted') throw new Error('notifications were not allowed'); + const reg = await navigator.serviceWorker.ready; + const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json(); + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64ToBytes(publicKey), + }); + const list = conns(); + const r = await fetch(`${NOTIFIER}/register`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: list.map((c) => c.servicePk) }), + }); + if (!r.ok) throw new Error(`notifier refused: ${r.status}`); + const st = load(); st.background = true; save(st); + await writePouch(); + return true; + } + + async function disableBackground() { + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + await fetch(`${NOTIFIER}/register`, { + method: 'DELETE', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }).catch(() => {}); + await sub.unsubscribe().catch(() => {}); + } + } catch {} + const st = load(); st.background = false; save(st); + // no background answering means no reason to keep spendable keys where a + // worker can read them + try { await clearPouch(wallet._cacheKey()); } catch {} + } + + // Mirror just enough for a future worker-side responder: the connections' + // service keys and the ASP endpoints. No pouch coins are moved here — the + // funding UI comes with the spend path. + async function writePouch() { + if (!load().background) return; + const cfg = hook('arkPouchContext'); + if (!cfg) return; + try { + await savePouch(wallet._cacheKey(), buildPouch({ + ...cfg, vtxos: [], account: wallet.account(), connections: conns(), + })); + } catch (e) { console.warn('nwc: could not write pouch', e.message); } + } + + // An open window handles the request itself; the worker just nudges it. + if (typeof navigator !== 'undefined' && navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('message', (ev) => { + if (ev.data?.type === 'nwc-wake') listen(); + }); + } + // ---- UI --------------------------------------------------------------- function nwcCard() { @@ -362,6 +437,17 @@ export function nwcFeature(ctx) { copyBtn(uriFor(c), t('nwcCopy'))) : h('button', { class: 'btn-sm', onClick: () => { ui.nwcShow = c.id; render(); } }, t('nwcShow'))); }), + list.length + ? h('div', { class: 'row between', style: 'border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, + h('span', { class: 'small' }, t('nwcBackground')), + h('button', { class: 'btn-sm', onClick: async () => { + try { + if (load().background) { await disableBackground(); toast(t('nwcBackgroundOff')); } + else { await enableBackground(); toast(t('nwcBackgroundOn')); } + } catch (e) { toast(e.message); } + render(); + } }, load().background ? t('nwcOn') : t('nwcOff'))) + : null, st ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, h('input', { type: 'text', placeholder: t('nwcAppName'), value: st.name, diff --git a/src/i18n.js b/src/i18n.js index b9371e7..1fa7d74 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -439,6 +439,11 @@ const STR = { nwcDesc: 'Let another app pay from this wallet over nostr — it never sees your keys, only spends within the limits you set, and you can cut it off at any time. Payments go out over Ark.', nwcTabWarning: 'Only works while Hal is open in a tab — it is not an always-on service.', nwcAdd: 'Add a connection', + nwcBackground: 'Alert me when closed', + nwcOn: 'On', + nwcOff: 'Off', + nwcBackgroundOn: 'You will be notified of payment requests', + nwcBackgroundOff: 'Background alerts turned off', nwcAppName: 'App name (e.g. Damus)', nwcMaxPayment: 'Max per payment (sats)', nwcDailyBudget: 'Daily budget (sats)', |
