From bef9d694036389add33566a2fb5ce4e91b79431e Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Thu, 24 Sep 2026 10:27:24 +0000
Subject: [PATCH 1/2] Give TronBrowser its own Web Push service, and make it a
setting
ungoogled-chromium ships without a push service and a page cannot choose
one, so pushManager.subscribe() failed on every site with 'Registration
failed - push service error' (reproduced in the bundled 152 engine).
- services/api: an RFC 8030 push service at /api/1/push. Site servers send
standard aes128gcm + VAPID pushes (the sender's key must match the one the
site subscribed with); ciphertext is queued until the browser acks it and
delivered live over a WebSocket. 404/410/413/415/429 as senders expect.
- extension: push-page.js (MAIN world) replaces PushManager.subscribe /
getSubscription / permissionState; push-client.js holds the subscription
keys, registers, decrypts (RFC 8291, WebCrypto) and shows the notification.
- setting: Settings -> Push notifications. Default tronbrowser.dev, any
compatible https URL (checked against GET / first), or off (engine's own).
- /push docs page, privacy entry, migration 0007.
Verified end to end in the real engine: subscribe returns a PushSubscription,
a real sendPush gets 201, the notification is created with its click URL and
the message is acked over the socket within 3s.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
Caddyfile | 4 +-
.../extensions/ai-sidebar/background.js | 4 +
.../extensions/ai-sidebar/manifest.json | 27 +-
.../extensions/ai-sidebar/options.html | 19 ++
apps/desktop/extensions/ai-sidebar/options.js | 37 +++
.../extensions/ai-sidebar/push-bridge.js | 26 ++
.../extensions/ai-sidebar/push-client.js | 227 +++++++++++++
.../extensions/ai-sidebar/push-crypto.js | 106 ++++++
.../extensions/ai-sidebar/push-page.js | 105 ++++++
apps/web/public/privacy.html | 1 +
apps/web/public/push.html | 72 ++++
packages/storage/migrations/0007_push.sql | 42 +++
pnpm-lock.yaml | 29 ++
services/api/package.json | 2 +
services/api/src/index.ts | 14 +-
services/api/src/push/client.test.js | 98 ++++++
services/api/src/push/push.test.ts | 144 ++++++++
services/api/src/push/routes.ts | 311 ++++++++++++++++++
services/api/src/push/vapid.ts | 81 +++++
19 files changed, 1346 insertions(+), 3 deletions(-)
create mode 100644 apps/desktop/extensions/ai-sidebar/push-bridge.js
create mode 100644 apps/desktop/extensions/ai-sidebar/push-client.js
create mode 100644 apps/desktop/extensions/ai-sidebar/push-crypto.js
create mode 100644 apps/desktop/extensions/ai-sidebar/push-page.js
create mode 100644 apps/web/public/push.html
create mode 100644 packages/storage/migrations/0007_push.sql
create mode 100644 services/api/src/push/client.test.js
create mode 100644 services/api/src/push/push.test.ts
create mode 100644 services/api/src/push/routes.ts
create mode 100644 services/api/src/push/vapid.ts
diff --git a/Caddyfile b/Caddyfile
index bea51ee0..25b26266 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -52,7 +52,7 @@
# (no content hashing yet — don't let stale JS strand logged-in users).
@images path *.svg *.png *.ico
header @images Cache-Control "public, max-age=86400"
- @code path *.js *.css *.html / /privacy /login /settings /dns
+ @code path *.js *.css *.html / /privacy /login /settings /dns /push
header @code Cache-Control "public, max-age=60, must-revalidate"
encode gzip zstd
@@ -66,6 +66,8 @@
rewrite @settings /settings.html
@dns path /dns
rewrite @dns /dns.html
+ @push path /push
+ rewrite @push /push.html
file_server
diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js
index c4b01bee..8e82730b 100644
--- a/apps/desktop/extensions/ai-sidebar/background.js
+++ b/apps/desktop/extensions/ai-sidebar/background.js
@@ -1,5 +1,9 @@
import { decideInstallTarget, lookupInstalled } from './install-state.js';
import { PIT_SOCKS_PORT, pitProxyConfig } from './pit-proxy.js';
+import { installPush } from './push-client.js';
+
+// Web Push through TronBrowser's configured push service (the engine has none).
+installPush();
// Open the AI side panel when the toolbar action is clicked.
chrome.sidePanel
diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json
index 1fc57671..7557721e 100644
--- a/apps/desktop/extensions/ai-sidebar/manifest.json
+++ b/apps/desktop/extensions/ai-sidebar/manifest.json
@@ -18,7 +18,9 @@
"scripting",
"proxy",
"privacy",
- "notifications"
+ "notifications",
+ "alarms",
+ "contentSettings"
],
"host_permissions": [
"https://api.openai.com/*",
@@ -40,6 +42,29 @@
"type": "module"
},
"content_scripts": [
+ {
+ "matches": [
+ "https://*/*",
+ "http://localhost/*",
+ "http://127.0.0.1/*"
+ ],
+ "js": [
+ "push-page.js"
+ ],
+ "run_at": "document_start",
+ "world": "MAIN"
+ },
+ {
+ "matches": [
+ "https://*/*",
+ "http://localhost/*",
+ "http://127.0.0.1/*"
+ ],
+ "js": [
+ "push-bridge.js"
+ ],
+ "run_at": "document_start"
+ },
{
"matches": [
"https://chromewebstore.google.com/detail/*",
diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html
index 8d103083..18de06bc 100644
--- a/apps/desktop/extensions/ai-sidebar/options.html
+++ b/apps/desktop/extensions/ai-sidebar/options.html
@@ -89,6 +89,25 @@
Name resolution
name still needs the certificate that moshcode dns enable installs.
+
Push notifications
+
+ Sites send browser notifications through a push service the browser chooses.
+ The engine TronBrowser runs on ships without one, so TronBrowser brings its
+ own. Messages reach it encrypted to a key only this browser holds.
+
+
+
+
+
+
+
+
+
Changing it signs sites out of push; they subscribe again the next time they ask.
+
AI providers (bring your own keys)
Add keys for as many providers as you like, then pick a default for
the sidebar. Keys are stored on your account (encrypted end-to-end when a vault
diff --git a/apps/desktop/extensions/ai-sidebar/options.js b/apps/desktop/extensions/ai-sidebar/options.js
index 3ddb85ca..398a83ff 100644
--- a/apps/desktop/extensions/ai-sidebar/options.js
+++ b/apps/desktop/extensions/ai-sidebar/options.js
@@ -9,6 +9,7 @@ import {
} from "./coinpay-auth.js";
import { pushSettings, pullSettings } from "./settings-store.js";
import { encryptVault, decryptVault } from "./vault.js";
+import { DEFAULT_PUSH_SERVICE, pushServiceFrom } from "./push-client.js";
import {
connect as btrConnect,
disconnect as btrDisconnect,
@@ -391,6 +392,42 @@ function flash(id, msg) {
el(id).textContent = msg;
setTimeout(() => (el(id).textContent = ""), 1600);
}
+
+/* ---------- Push service (push-client.js reads `pushService`) ---------- */
+async function mountPush() {
+ const { pushService } = await chrome.storage.local.get("pushService");
+ const mode = pushService === "off" ? "off" : pushService && pushService !== DEFAULT_PUSH_SERVICE ? "custom" : "default";
+ el("pushMode").value = mode;
+ el("pushUrl").value = mode === "custom" ? pushService : "";
+ el("pushCustomRow").hidden = mode !== "custom";
+ el("pushMode").addEventListener("change", () => {
+ el("pushCustomRow").hidden = el("pushMode").value !== "custom";
+ });
+ el("savePush").addEventListener("click", async () => {
+ const choice = el("pushMode").value;
+ if (choice === "off") {
+ await chrome.storage.local.set({ pushService: "off" });
+ return flash("savedPush", "push off ✓");
+ }
+ if (choice === "default") {
+ await chrome.storage.local.remove("pushService");
+ return flash("savedPush", "saved ✓");
+ }
+ const url = pushServiceFrom(el("pushUrl").value);
+ if (!url || url === DEFAULT_PUSH_SERVICE) return flash("savedPush", "enter an https:// URL");
+ // Check it speaks our protocol before switching every site over to it.
+ try {
+ const info = await (await fetch(url)).json();
+ if (info.service !== "tronbrowser-push") throw new Error("not a compatible push service");
+ } catch (e) {
+ return flash("savedPush", `can't use that URL: ${e.message}`);
+ }
+ await chrome.storage.local.set({ pushService: url });
+ flash("savedPush", "saved ✓");
+ });
+}
+mountPush();
+
function escape(s) {
const d = document.createElement("div");
d.textContent = s || "";
diff --git a/apps/desktop/extensions/ai-sidebar/push-bridge.js b/apps/desktop/extensions/ai-sidebar/push-bridge.js
new file mode 100644
index 00000000..0bd357fe
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/push-bridge.js
@@ -0,0 +1,26 @@
+// Isolated-world half of the pushManager replacement: forwards push-page.js's
+// requests to the background and hands the answers back. The background reads
+// the origin from Chrome's sender info, so nothing the page sends here can act
+// for another site.
+(() => {
+ if (window.top !== window) return;
+ const TYPES = new Set(['push:subscribe', 'push:get', 'push:unsubscribe']);
+ window.addEventListener('message', (event) => {
+ const data = event.data;
+ if (event.source !== window || data?.__tronPush !== 'request' || !TYPES.has(data.type)) return;
+ const reply = (result) =>
+ window.postMessage({ __tronPush: 'response', id: data.id, result }, location.origin);
+ try {
+ chrome.runtime.sendMessage(
+ { type: data.type, scope: typeof data.scope === 'string' ? data.scope : undefined, applicationServerKey: data.applicationServerKey ?? null },
+ (result) => {
+ if (chrome.runtime.lastError) reply({ error: 'AbortError', message: 'Registration failed - push service error' });
+ else reply(result);
+ },
+ );
+ } catch {
+ // The extension was reloaded under this page; behave as if push is off.
+ reply({ off: true });
+ }
+ });
+})();
diff --git a/apps/desktop/extensions/ai-sidebar/push-client.js b/apps/desktop/extensions/ai-sidebar/push-client.js
new file mode 100644
index 00000000..1fb57cfb
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/push-client.js
@@ -0,0 +1,227 @@
+// Web Push for TronBrowser, background side.
+//
+// ungoogled-chromium ships without a push service, so every site's
+// pushManager.subscribe() fails with "Registration failed - push service
+// error". push-page.js replaces pushManager in pages; this module does the
+// work: holds each subscription's keys, registers with the configured push
+// service, keeps a socket to it, decrypts what arrives and shows it.
+//
+// The push service is a setting (`pushService` in chrome.storage.local):
+// unset / DEFAULT_PUSH_SERVICE TronBrowser's own, https://tronbrowser.dev/api/1/push
+// any https URL a compatible service (self-hosted, say)
+// 'off' leave pushManager alone (the engine's own behaviour)
+import { createSubscriptionKeys, decryptPush, fromB64u, notificationFromPayload, toB64u } from './push-crypto.js';
+
+export const DEFAULT_PUSH_SERVICE = 'https://tronbrowser.dev/api/1/push';
+const SUBS_KEY = 'pushSubscriptions'; // { [origin|scope]: { token, endpoint, p256dh, auth, privateJwk, appKey, service } }
+const PING_MS = 20_000; // keeps the socket, and so this worker, alive (Chrome 116+)
+
+/** The configured push service URL, or null when push is switched off. */
+export function pushServiceFrom(value) {
+ if (value === 'off') return null;
+ if (typeof value === 'string' && /^https:\/\/[^\s]+$/.test(value.trim())) return value.trim().replace(/\/$/, '');
+ if (typeof value === 'string' && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(value.trim())) return value.trim().replace(/\/$/, '');
+ return DEFAULT_PUSH_SERVICE;
+}
+
+async function service() {
+ const { pushService } = await chrome.storage.local.get('pushService');
+ return pushServiceFrom(pushService);
+}
+
+async function deviceSecret() {
+ const { pushDeviceSecret } = await chrome.storage.local.get('pushDeviceSecret');
+ if (pushDeviceSecret) return pushDeviceSecret;
+ const secret = toB64u(crypto.getRandomValues(new Uint8Array(32)));
+ await chrome.storage.local.set({ pushDeviceSecret: secret });
+ return secret;
+}
+
+async function subscriptions() {
+ return (await chrome.storage.local.get(SUBS_KEY))[SUBS_KEY] || {};
+}
+
+const subKey = (origin, scope) => `${origin}|${scope || origin + '/'}`;
+
+/** What the page sees: the same shape PushSubscription.toJSON() gives. */
+function publicShape(sub) {
+ return { endpoint: sub.endpoint, expirationTime: null, keys: { p256dh: sub.p256dh, auth: sub.auth }, applicationServerKey: sub.appKey };
+}
+
+async function api(base, path, init = {}) {
+ const res = await fetch(`${base}${path}`, {
+ ...init,
+ headers: { authorization: `Bearer ${await deviceSecret()}`, 'content-type': 'application/json', ...(init.headers || {}) },
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(body.error || `push service answered ${res.status}`);
+ return body;
+}
+
+export async function subscribe(origin, scope, appKey) {
+ const base = await service();
+ if (!base) return { off: true };
+ const all = await subscriptions();
+ const key = subKey(origin, scope);
+ const existing = all[key];
+ if (existing && existing.service === base) {
+ if ((existing.appKey || null) === (appKey || null)) return { subscription: publicShape(existing) };
+ // Native behaviour: a different key on an existing subscription is an error.
+ return { error: 'InvalidStateError', message: 'A subscription with a different applicationServerKey already exists.' };
+ }
+ const keys = await createSubscriptionKeys();
+ const created = await api(base, '/subscriptions', {
+ method: 'POST',
+ body: JSON.stringify({ origin, applicationServerKey: appKey || null }),
+ });
+ all[key] = { ...keys, token: created.token, endpoint: created.endpoint, appKey: appKey || null, service: base, origin };
+ await chrome.storage.local.set({ [SUBS_KEY]: all });
+ connect();
+ return { subscription: publicShape(all[key]) };
+}
+
+export async function getSubscription(origin, scope) {
+ const base = await service();
+ if (!base) return { off: true };
+ const sub = (await subscriptions())[subKey(origin, scope)];
+ return { subscription: sub && sub.service === base ? publicShape(sub) : null };
+}
+
+export async function unsubscribe(origin, scope) {
+ const all = await subscriptions();
+ const key = subKey(origin, scope);
+ const sub = all[key];
+ if (!sub) return { ok: false };
+ delete all[key];
+ await chrome.storage.local.set({ [SUBS_KEY]: all });
+ await api(sub.service, `/subscriptions/${sub.token}`, { method: 'DELETE' }).catch(() => undefined);
+ return { ok: true };
+}
+
+/* ---------- receiving ---------- */
+
+async function show(message) {
+ const sub = Object.values(await subscriptions()).find((s) => s.token === message.token);
+ if (!sub) return; // unsubscribed since; the ack still clears it
+ // Respect the site's notification permission if the user has since blocked it.
+ if (chrome.contentSettings?.notifications) {
+ const { setting } = await chrome.contentSettings.notifications.get({ primaryUrl: `${sub.origin}/` }).catch(() => ({}));
+ if (setting === 'block') return;
+ }
+ let text = '';
+ if (message.body) text = new TextDecoder().decode(await decryptPush(fromB64u(message.body), sub));
+ const n = notificationFromPayload(text, sub.origin);
+ const id = `push:${message.id}`;
+ const options = {
+ type: 'basic',
+ iconUrl: n.icon || chrome.runtime.getURL('icons/icon-128.png'),
+ title: n.title,
+ message: n.body || n.host,
+ contextMessage: n.host,
+ };
+ try {
+ await chrome.notifications.create(id, options);
+ } catch {
+ await chrome.notifications.create(id, { ...options, iconUrl: chrome.runtime.getURL('icons/icon-128.png') });
+ }
+ await chrome.storage.session.set({ [id]: n.url });
+}
+
+async function handle(messages, ackVia) {
+ const ids = [];
+ for (const message of messages || []) {
+ try { await show(message); } catch (error) { console.warn('[push] could not show a message', error); }
+ ids.push(message.id); // an unreadable push will not become readable later
+ }
+ if (ids.length) await ackVia(ids);
+}
+
+let socket = null;
+let pinger = null;
+
+export async function connect() {
+ const base = await service();
+ if (!base || socket) return;
+ if (!Object.values(await subscriptions()).some((s) => s.service === base)) return;
+ const secret = await deviceSecret();
+ let ws;
+ try { ws = new WebSocket(`${base.replace(/^http/, 'ws')}/connect`); } catch { return; }
+ socket = ws;
+ ws.onopen = () => {
+ ws.send(JSON.stringify({ type: 'hello', secret }));
+ pinger = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping' })); } catch { /* closing */ } }, PING_MS);
+ };
+ ws.onmessage = (event) => {
+ let frame;
+ try { frame = JSON.parse(event.data); } catch { return; }
+ if (frame.type === 'messages') {
+ handle(frame.messages, async (ids) => ws.send(JSON.stringify({ type: 'ack', ids })));
+ }
+ };
+ ws.onclose = () => {
+ clearInterval(pinger);
+ if (socket === ws) socket = null;
+ };
+}
+
+/** The fallback when the socket was down: fetch and show whatever is waiting. */
+export async function poll() {
+ const base = await service();
+ if (!base) return;
+ if (!Object.values(await subscriptions()).some((s) => s.service === base)) return;
+ const { messages } = await api(base, '/messages');
+ await handle(messages, (ids) => api(base, '/ack', { method: 'POST', body: JSON.stringify({ ids }) }));
+}
+
+export function installPush() {
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ if (typeof msg?.type !== 'string' || !msg.type.startsWith('push:')) return false;
+ // The origin comes from Chrome, never from the page: a site can only
+ // manage its own subscriptions.
+ const origin = sender.origin || (sender.url ? new URL(sender.url).origin : null);
+ if (!origin || !/^https:|^http:\/\/(localhost|127\.0\.0\.1)/.test(origin)) {
+ sendResponse({ error: 'NotSupportedError', message: 'Push needs a secure origin.' });
+ return false;
+ }
+ const run = {
+ 'push:subscribe': () => subscribe(origin, msg.scope, msg.applicationServerKey),
+ 'push:get': () => getSubscription(origin, msg.scope),
+ 'push:unsubscribe': () => unsubscribe(origin, msg.scope),
+ }[msg.type];
+ if (!run) return false;
+ run().then(sendResponse, (error) => sendResponse({ error: 'AbortError', message: `Registration failed - ${error.message}` }));
+ return true;
+ });
+
+ chrome.notifications?.onClicked?.addListener(async (id) => {
+ if (!id.startsWith('push:')) return;
+ const url = (await chrome.storage.session.get(id))[id];
+ if (url) chrome.tabs.create({ url });
+ chrome.notifications.clear(id);
+ chrome.storage.session.remove(id);
+ });
+ chrome.notifications?.onClosed?.addListener((id) => {
+ if (id.startsWith('push:')) chrome.storage.session.remove(id);
+ });
+
+ // A changed service strands the old subscriptions; forget them so sites
+ // see no subscription and subscribe again against the new one.
+ chrome.storage?.onChanged?.addListener(async (changes, area) => {
+ if (area !== 'local' || !changes.pushService) return;
+ socket?.close();
+ const base = await service();
+ const all = await subscriptions();
+ for (const [key, sub] of Object.entries(all)) if (sub.service !== base) delete all[key];
+ await chrome.storage.local.set({ [SUBS_KEY]: all });
+ connect();
+ });
+
+ chrome.alarms?.create('push-poll', { periodInMinutes: 1 });
+ chrome.alarms?.onAlarm?.addListener((alarm) => {
+ if (alarm.name !== 'push-poll') return;
+ if (!socket) connect();
+ poll().catch(() => undefined);
+ });
+ chrome.runtime.onStartup?.addListener(() => { connect(); poll().catch(() => undefined); });
+ connect().catch(() => undefined);
+}
diff --git a/apps/desktop/extensions/ai-sidebar/push-crypto.js b/apps/desktop/extensions/ai-sidebar/push-crypto.js
new file mode 100644
index 00000000..7e827c7b
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/push-crypto.js
@@ -0,0 +1,106 @@
+// The browser end of Web Push encryption (RFC 8291 over RFC 8188 aes128gcm),
+// in WebCrypto only, so it runs in the extension's service worker and in node
+// tests alike. Browsers normally do this inside their push stack; TronBrowser's
+// engine has none, so the extension holds the subscription keys itself.
+
+const subtle = () => globalThis.crypto.subtle;
+const enc = new TextEncoder();
+
+export function toB64u(bytes) {
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+ let s = '';
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+export function fromB64u(text) {
+ const normal = String(text).replace(/-/g, '+').replace(/_/g, '/');
+ const bin = atob(normal + '==='.slice((normal.length + 3) % 4));
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+const concat = (...parts) => {
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let at = 0;
+ for (const p of parts) { out.set(p, at); at += p.length; }
+ return out;
+};
+
+async function hkdf(salt, ikm, info, length) {
+ const key = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
+ const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8);
+ return new Uint8Array(bits);
+}
+
+/**
+ * A fresh subscription keypair: `p256dh` (public, raw 65 bytes) and `auth`
+ * (16 random bytes) go to the site; `privateJwk` never leaves the extension.
+ */
+export async function createSubscriptionKeys() {
+ const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
+ const raw = new Uint8Array(await subtle().exportKey('raw', pair.publicKey));
+ const privateJwk = await subtle().exportKey('jwk', pair.privateKey);
+ const auth = globalThis.crypto.getRandomValues(new Uint8Array(16));
+ return { p256dh: toB64u(raw), auth: toB64u(auth), privateJwk };
+}
+
+/**
+ * Decrypt one aes128gcm Web Push body with the subscription's keys.
+ * Returns the plaintext bytes. Throws on anything malformed or forged.
+ */
+export async function decryptPush(body, keys) {
+ const data = body instanceof Uint8Array ? body : new Uint8Array(body);
+ if (data.length < 21) throw new Error('push body too short');
+ const salt = data.subarray(0, 16);
+ const rs = new DataView(data.buffer, data.byteOffset + 16, 4).getUint32(0);
+ const idlen = data[20];
+ const senderPublic = data.subarray(21, 21 + idlen);
+ const ciphertext = data.subarray(21 + idlen);
+ if (idlen !== 65 || senderPublic[0] !== 4) throw new Error('push body has no sender key');
+ if (ciphertext.length > rs) throw new Error('multi-record push bodies are not used by Web Push');
+
+ const receiverPublic = fromB64u(keys.p256dh);
+ const privateKey = await subtle().importKey('jwk', keys.privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
+ const senderKey = await subtle().importKey('raw', senderPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
+ const shared = new Uint8Array(await subtle().deriveBits({ name: 'ECDH', public: senderKey }, privateKey, 256));
+
+ // RFC 8291 §3.4: mix the auth secret and both public keys into the IKM.
+ const ikm = await hkdf(fromB64u(keys.auth), shared,
+ concat(enc.encode('WebPush: info\0'), receiverPublic, senderPublic), 32);
+ const cek = await hkdf(salt, ikm, enc.encode('Content-Encoding: aes128gcm\0'), 16);
+ const nonce = await hkdf(salt, ikm, enc.encode('Content-Encoding: nonce\0'), 12);
+
+ const aes = await subtle().importKey('raw', cek, 'AES-GCM', false, ['decrypt']);
+ const padded = new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce }, aes, ciphertext));
+ // Single (last) record: content, then 0x02, then zero padding.
+ let end = padded.length - 1;
+ while (end >= 0 && padded[end] === 0) end--;
+ if (end < 0 || padded[end] !== 2) throw new Error('bad push padding');
+ return padded.subarray(0, end);
+}
+
+/**
+ * What to show for a decrypted payload. Understands the common shapes: our
+ * house `{ title, body, url, icon }`, FCM-style `{ notification: {...} }`, and
+ * plain text. Anything else becomes the body.
+ */
+export function notificationFromPayload(text, origin) {
+ let data;
+ try { data = JSON.parse(text); } catch { data = { body: text }; }
+ if (typeof data !== 'object' || data === null) data = { body: String(data) };
+ const n = typeof data.notification === 'object' && data.notification ? { ...data, ...data.notification } : data;
+ const host = (() => { try { return new URL(origin).host; } catch { return origin; } })();
+ const title = String(n.title || host).slice(0, 200);
+ const body = String(n.body ?? n.message ?? '').slice(0, 1000);
+ const rawUrl = n.url ?? n.click_action ?? n.data?.url ?? '/';
+ let url = origin;
+ try {
+ const resolved = new URL(rawUrl, origin);
+ if (resolved.protocol === 'https:' || resolved.protocol === 'http:') url = resolved.href;
+ } catch { /* keep the origin */ }
+ let icon = null;
+ try { if (n.icon) icon = new URL(n.icon, origin).href; } catch { /* no icon */ }
+ return { title, body, url, icon, host };
+}
diff --git a/apps/desktop/extensions/ai-sidebar/push-page.js b/apps/desktop/extensions/ai-sidebar/push-page.js
new file mode 100644
index 00000000..a32946c7
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/push-page.js
@@ -0,0 +1,105 @@
+// Runs in every page's own world (MAIN), before its scripts. Replaces
+// PushManager's methods so subscribe() goes to TronBrowser's configured push
+// service instead of the engine's, which does not exist in ungoogled-chromium.
+// Talks to push-bridge.js (isolated world) by window messages; the background
+// does the rest (push-client.js). With the setting 'off', every call falls
+// through to the engine's own implementation.
+(() => {
+ if (typeof PushManager === 'undefined' || typeof ServiceWorkerRegistration === 'undefined') return;
+ if (window.top !== window) return; // top-level documents only, like the bridge
+ const native = {
+ subscribe: PushManager.prototype.subscribe,
+ getSubscription: PushManager.prototype.getSubscription,
+ permissionState: PushManager.prototype.permissionState,
+ };
+ const scopes = new WeakMap(); // PushManager -> its registration's scope
+ const pmGetter = Object.getOwnPropertyDescriptor(ServiceWorkerRegistration.prototype, 'pushManager')?.get;
+ if (pmGetter) {
+ Object.defineProperty(ServiceWorkerRegistration.prototype, 'pushManager', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ const pm = pmGetter.call(this);
+ if (pm && !scopes.has(pm)) scopes.set(pm, this.scope);
+ return pm;
+ },
+ });
+ }
+
+ let seq = 0;
+ const waiting = new Map();
+ window.addEventListener('message', (event) => {
+ if (event.source !== window || event.data?.__tronPush !== 'response') return;
+ const done = waiting.get(event.data.id);
+ if (done) { waiting.delete(event.data.id); done(event.data.result || {}); }
+ });
+ const ask = (type, payload) =>
+ new Promise((resolve) => {
+ const id = `${Date.now()}-${++seq}`;
+ waiting.set(id, resolve);
+ window.postMessage({ __tronPush: 'request', id, type, ...payload }, location.origin);
+ });
+
+ const toB64u = (input) => {
+ if (typeof input === 'string') return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ const u8 = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
+ let s = '';
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ };
+ const fromB64u = (text) => {
+ const normal = text.replace(/-/g, '+').replace(/_/g, '/');
+ const bin = atob(normal + '==='.slice((normal.length + 3) % 4));
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out.buffer;
+ };
+ const fail = (result) => { throw new DOMException(result.message || 'Registration failed', result.error || 'AbortError'); };
+
+ // A real PushSubscription as far as the page can tell: instanceof holds and
+ // the fields, getKey, toJSON and unsubscribe behave as specified.
+ function subscriptionFrom(data, scope) {
+ const sub = Object.create(PushSubscription.prototype);
+ const appKey = data.applicationServerKey ? fromB64u(data.applicationServerKey) : null;
+ const define = (name, value) => Object.defineProperty(sub, name, { value, enumerable: true });
+ define('endpoint', data.endpoint);
+ define('expirationTime', null);
+ define('options', Object.freeze({ userVisibleOnly: true, applicationServerKey: appKey }));
+ define('getKey', (name) => (data.keys[name] ? fromB64u(data.keys[name]) : null));
+ define('toJSON', () => ({ endpoint: data.endpoint, expirationTime: null, keys: { ...data.keys } }));
+ define('unsubscribe', async () => (await ask('push:unsubscribe', { scope })).ok === true);
+ return sub;
+ }
+
+ PushManager.prototype.subscribe = async function (options = {}) {
+ const scope = scopes.get(this);
+ if (options.userVisibleOnly === false) {
+ throw new DOMException('Push subscriptions must be userVisibleOnly.', 'NotAllowedError');
+ }
+ const probe = await ask('push:get', { scope });
+ if (probe.off) return native.subscribe.call(this, options);
+ if (Notification.permission === 'default') await Notification.requestPermission();
+ if (Notification.permission !== 'granted') {
+ throw new DOMException('Registration failed - permission denied', 'NotAllowedError');
+ }
+ const appKey = options.applicationServerKey == null ? null : toB64u(options.applicationServerKey);
+ const result = await ask('push:subscribe', { scope, applicationServerKey: appKey });
+ if (result.off) return native.subscribe.call(this, options);
+ if (result.error) fail(result);
+ return subscriptionFrom(result.subscription, scope);
+ };
+
+ PushManager.prototype.getSubscription = async function () {
+ const scope = scopes.get(this);
+ const result = await ask('push:get', { scope });
+ if (result.off) return native.getSubscription.call(this);
+ if (result.error) fail(result);
+ return result.subscription ? subscriptionFrom(result.subscription, scope) : null;
+ };
+
+ PushManager.prototype.permissionState = async function (options) {
+ const result = await ask('push:get', { scope: scopes.get(this) });
+ if (result.off) return native.permissionState.call(this, options);
+ return Notification.permission === 'default' ? 'prompt' : Notification.permission;
+ };
+})();
diff --git a/apps/web/public/privacy.html b/apps/web/public/privacy.html
index c3821895..b02190fb 100644
--- a/apps/web/public/privacy.html
+++ b/apps/web/public/privacy.html
@@ -37,6 +37,7 @@
The browser
No telemetry by default. TronBrowser does not phone home. It is built on Ungoogled Chromium, which removes Google background connections.
Your data is yours. Bookmarks, history, and profiles stay on your device, or in a database you control (your own SQLite, or our optional managed cloud).
Bring your own AI keys. When you use the AI sidebar, requests go directly from your browser to the AI provider you configured; your keys are stored locally on your device.
+
Push notifications. The engine has no push service, so TronBrowser uses its own (tronbrowser.dev/api/1/push by default, or one you choose, or off). It connects only after a site you allowed notifications for subscribes. Messages are encrypted to a key only your browser holds; the service keeps the subscribing site's origin and each message until it is delivered or expires. How it works.
No ads, no sponsored tabs, no affiliate link injection.
Browser notifications travel through a push service that the browser picks:
+ Chrome uses Google's, Firefox uses Mozilla's, Safari uses Apple's. The ungoogled-chromium
+ engine TronBrowser runs on has none, so on a stock build every site's
+ pushManager.subscribe() fails with
+ Registration failed - push service error.
+
+
TronBrowser brings its own. The built-in extension supplies pushManager to
+ every page and registers with https://tronbrowser.dev/api/1/push by default.
+ Change it under Settings → Push notifications: another compatible
+ service, or off.
+
+
For site owners: nothing to change
+
Your site subscribes the usual way and gets an endpoint on our service. Your server sends
+ exactly what it sends to FCM or Mozilla: an aes128gcm body (RFC 8291) with a
+ VAPID header (RFC 8292), via web-push, @profullstack/notifications
+ or anything else that speaks the standard. We answer 201, and 404 /
+ 410 when a subscription is gone, so your cleanup keeps working.
+
One difference: the notification is shown by TronBrowser from the payload's
+ title, body, icon and url (also
+ notification.* and click_action). Your service worker's
+ push handler does not run, so a silent push that only syncs data shows as a
+ plain notification.
+
+
Privacy
+
+
No account. A browser is identified by the hash of a random secret it keeps.
+
Payloads arrive encrypted to a key only your browser holds; we store and relay the
+ ciphertext and cannot read it.
+
We keep each subscription's site origin and the sender's VAPID key, and each message
+ until your browser acknowledges it or its TTL runs out (28 days at most).
+
+
+
The protocol, for a self-hosted service
+
+
Request
Who
What
+
GET /
settings page
{"service":"tronbrowser-push","version":1}, which is how the browser checks a custom URL
+
POST /{token}
site servers
RFC 8030 push. TTL required, body ≤ 4096 bytes, Topic replaces a pending message