2026-03-31 18:47:00 +03:00
|
|
|
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
2026-06-02 00:43:05 +03:00
|
|
|
const UI_VISIBILITY_TTL_MS = 30_000;
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-04-21 23:08:04 +03:00
|
|
|
const isLoopbackHttpOrigin = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value.startsWith('http://localhost')
|
|
|
|
|
|| value.startsWith('http://127.0.0.1')
|
|
|
|
|
|| value.startsWith('http://[::1]');
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export const createPushRuntime = (deps) => {
|
|
|
|
|
const {
|
|
|
|
|
fsPromises,
|
|
|
|
|
path,
|
|
|
|
|
webPush,
|
|
|
|
|
PUSH_SUBSCRIPTIONS_FILE_PATH,
|
|
|
|
|
readSettingsFromDiskMigrated,
|
|
|
|
|
writeSettingsToDisk,
|
|
|
|
|
} = deps;
|
|
|
|
|
|
|
|
|
|
let persistPushSubscriptionsLock = Promise.resolve();
|
|
|
|
|
let pushInitialized = false;
|
|
|
|
|
|
|
|
|
|
const uiVisibilityByToken = new Map();
|
2026-06-02 00:43:05 +03:00
|
|
|
const pruneUiVisibility = (now = Date.now()) => {
|
|
|
|
|
for (const [token, state] of uiVisibilityByToken) {
|
|
|
|
|
if (!state || now - state.updatedAt > UI_VISIBILITY_TTL_MS) {
|
|
|
|
|
uiVisibilityByToken.delete(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const readPushSubscriptionsFromDisk = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8');
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
|
|
|
}
|
|
|
|
|
if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) {
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const subscriptionsBySession =
|
|
|
|
|
parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object'
|
|
|
|
|
? parsed.subscriptionsBySession
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
|
|
|
}
|
|
|
|
|
console.warn('Failed to read push subscriptions file:', error);
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const writePushSubscriptionsToDisk = async (data) => {
|
|
|
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const persistPushSubscriptionUpdate = async (mutate) => {
|
|
|
|
|
persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => {
|
|
|
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|
|
|
|
const current = await readPushSubscriptionsFromDisk();
|
|
|
|
|
const next = mutate({
|
|
|
|
|
version: PUSH_SUBSCRIPTIONS_VERSION,
|
|
|
|
|
subscriptionsBySession: current.subscriptionsBySession || {},
|
|
|
|
|
});
|
|
|
|
|
await writePushSubscriptionsToDisk(next);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return persistPushSubscriptionsLock;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getOrCreateVapidKeys = async () => {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const existing = settings?.vapidKeys;
|
|
|
|
|
if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') {
|
|
|
|
|
return { publicKey: existing.publicKey, privateKey: existing.privateKey };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const generated = webPush.generateVAPIDKeys();
|
|
|
|
|
const next = {
|
|
|
|
|
...settings,
|
|
|
|
|
vapidKeys: {
|
|
|
|
|
publicKey: generated.publicKey,
|
|
|
|
|
privateKey: generated.privateKey,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await writeSettingsToDisk(next);
|
|
|
|
|
return { publicKey: generated.publicKey, privateKey: generated.privateKey };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizePushSubscriptions = (record) => {
|
|
|
|
|
if (!Array.isArray(record)) return [];
|
|
|
|
|
return record
|
|
|
|
|
.map((entry) => {
|
|
|
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
|
|
|
const endpoint = entry.endpoint;
|
|
|
|
|
const p256dh = entry.p256dh;
|
|
|
|
|
const auth = entry.auth;
|
|
|
|
|
if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
endpoint,
|
|
|
|
|
p256dh,
|
|
|
|
|
auth,
|
|
|
|
|
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
2026-07-01 09:55:41 +03:00
|
|
|
platform: typeof entry.platform === 'string' ? entry.platform : undefined,
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!uiSessionToken) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ensurePushInitialized();
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
|
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
|
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|
|
|
|
|
|
|
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint);
|
2026-03-31 18:47:00 +03:00
|
|
|
filtered.unshift({
|
|
|
|
|
endpoint: subscription.endpoint,
|
|
|
|
|
p256dh: subscription.p256dh,
|
|
|
|
|
auth: subscription.auth,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
lastSeenAt: now,
|
|
|
|
|
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
2026-07-01 09:55:41 +03:00
|
|
|
// Platform lets the sender route mobile PWA push through the same presence gate as APNs.
|
|
|
|
|
platform:
|
|
|
|
|
typeof platform === 'string' && platform
|
|
|
|
|
? platform
|
|
|
|
|
: typeof previous?.platform === 'string'
|
|
|
|
|
? previous.platform
|
|
|
|
|
: undefined,
|
2026-03-31 18:47:00 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
subsBySession[uiSessionToken] = filtered.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removePushSubscription = async (uiSessionToken, endpoint) => {
|
|
|
|
|
if (!uiSessionToken || !endpoint) return;
|
|
|
|
|
|
|
|
|
|
await ensurePushInitialized();
|
|
|
|
|
|
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
|
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
|
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|
|
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|
|
|
|
if (filtered.length === 0) {
|
|
|
|
|
delete subsBySession[uiSessionToken];
|
|
|
|
|
} else {
|
|
|
|
|
subsBySession[uiSessionToken] = filtered;
|
|
|
|
|
}
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removePushSubscriptionFromAllSessions = async (endpoint) => {
|
|
|
|
|
if (!endpoint) return;
|
|
|
|
|
|
|
|
|
|
await ensurePushInitialized();
|
|
|
|
|
|
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
|
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
|
|
|
for (const [token, entries] of Object.entries(subsBySession)) {
|
|
|
|
|
if (!Array.isArray(entries)) continue;
|
|
|
|
|
const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|
|
|
|
if (filtered.length === 0) {
|
|
|
|
|
delete subsBySession[token];
|
|
|
|
|
} else {
|
|
|
|
|
subsBySession[token] = filtered;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sendPushToSubscription = async (sub, payload) => {
|
|
|
|
|
await ensurePushInitialized();
|
|
|
|
|
const body = JSON.stringify(payload);
|
|
|
|
|
|
|
|
|
|
const pushSubscription = {
|
|
|
|
|
endpoint: sub.endpoint,
|
|
|
|
|
keys: {
|
|
|
|
|
p256dh: sub.p256dh,
|
|
|
|
|
auth: sub.auth,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await webPush.sendNotification(pushSubscription, body);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
|
|
|
|
|
if (statusCode === 410 || statusCode === 404) {
|
|
|
|
|
await removePushSubscriptionFromAllSessions(sub.endpoint);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.warn('[Push] Failed to send notification:', error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sendPushToAllUiSessions = async (payload, options = {}) => {
|
|
|
|
|
const requireNoSse = options.requireNoSse === true;
|
|
|
|
|
const store = await readPushSubscriptionsFromDisk();
|
|
|
|
|
const sessions = store.subscriptionsBySession || {};
|
|
|
|
|
const subscriptionsByEndpoint = new Map();
|
|
|
|
|
|
|
|
|
|
for (const record of Object.values(sessions)) {
|
|
|
|
|
const subscriptions = normalizePushSubscriptions(record);
|
|
|
|
|
if (subscriptions.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
for (const sub of subscriptions) {
|
|
|
|
|
if (!subscriptionsByEndpoint.has(sub.endpoint)) {
|
|
|
|
|
subscriptionsByEndpoint.set(sub.endpoint, sub);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
if (requireNoSse) {
|
|
|
|
|
// Mobile PWA subscriptions follow the same presence model as native push: suppress only
|
|
|
|
|
// when an interactive (desktop/web) client is visible. The phone PWA's own foreground is
|
|
|
|
|
// handled in the service worker (focused-client check), so it won't double-notify.
|
|
|
|
|
// Non-mobile (desktop/web) subscriptions keep the existing any-visible gate.
|
|
|
|
|
const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible();
|
|
|
|
|
if (suppressed) return;
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
await sendPushToSubscription(sub, payload);
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
// A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop,
|
|
|
|
|
// vscode, or an older client that doesn't report a platform) is treated as interactive — i.e.
|
|
|
|
|
// a surface where the user would actually see the in-app notification.
|
|
|
|
|
const MOBILE_PLATFORMS = new Set(['ios', 'android']);
|
|
|
|
|
const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform);
|
|
|
|
|
|
|
|
|
|
const updateUiVisibility = (token, visible, platform) => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!token) return;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const nextVisible = Boolean(visible);
|
2026-07-01 09:55:41 +03:00
|
|
|
const existing = uiVisibilityByToken.get(token);
|
|
|
|
|
// Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat).
|
|
|
|
|
const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform;
|
|
|
|
|
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform });
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const isAnyUiVisible = () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
pruneUiVisibility(now);
|
|
|
|
|
for (const state of uiVisibilityByToken.values()) {
|
|
|
|
|
if (state.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
// True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to
|
|
|
|
|
// suppress native push to the phone: an active desktop already shows the notification, so the
|
|
|
|
|
// phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the
|
|
|
|
|
// phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it).
|
|
|
|
|
const isAnyInteractiveClientVisible = () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
pruneUiVisibility(now);
|
|
|
|
|
for (const state of uiVisibilityByToken.values()) {
|
|
|
|
|
if (
|
|
|
|
|
state.visible === true &&
|
|
|
|
|
now - state.updatedAt <= UI_VISIBILITY_TTL_MS &&
|
|
|
|
|
!isMobilePlatform(state.platform)
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const isUiVisible = (token) => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
pruneUiVisibility(now);
|
|
|
|
|
const state = uiVisibilityByToken.get(token);
|
|
|
|
|
return state?.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS;
|
|
|
|
|
};
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const resolveVapidSubject = async () => {
|
|
|
|
|
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
|
|
|
|
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
|
|
|
return configured.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
|
|
|
|
|
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
|
|
|
|
|
const trimmed = originEnv.trim();
|
2026-04-21 23:08:04 +03:00
|
|
|
if (isLoopbackHttpOrigin(trimmed)) {
|
2026-03-31 18:47:00 +03:00
|
|
|
return 'mailto:openchamber@localhost';
|
|
|
|
|
}
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const stored = settings?.publicOrigin;
|
|
|
|
|
if (typeof stored === 'string' && stored.trim().length > 0) {
|
|
|
|
|
const trimmed = stored.trim();
|
2026-04-21 23:08:04 +03:00
|
|
|
if (isLoopbackHttpOrigin(trimmed)) {
|
2026-03-31 18:47:00 +03:00
|
|
|
return 'mailto:openchamber@localhost';
|
|
|
|
|
}
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'mailto:openchamber@localhost';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensurePushInitialized = async () => {
|
|
|
|
|
if (pushInitialized) return;
|
|
|
|
|
const keys = await getOrCreateVapidKeys();
|
|
|
|
|
const subject = await resolveVapidSubject();
|
|
|
|
|
|
|
|
|
|
if (subject === 'mailto:openchamber@localhost') {
|
|
|
|
|
console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
|
|
|
|
|
pushInitialized = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setPushInitialized = (value) => {
|
|
|
|
|
pushInitialized = value === true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
getOrCreateVapidKeys,
|
|
|
|
|
addOrUpdatePushSubscription,
|
|
|
|
|
removePushSubscription,
|
|
|
|
|
sendPushToAllUiSessions,
|
|
|
|
|
updateUiVisibility,
|
|
|
|
|
isAnyUiVisible,
|
2026-07-01 09:55:41 +03:00
|
|
|
isAnyInteractiveClientVisible,
|
2026-03-31 18:47:00 +03:00
|
|
|
isUiVisible,
|
|
|
|
|
ensurePushInitialized,
|
|
|
|
|
setPushInitialized,
|
|
|
|
|
};
|
|
|
|
|
};
|