Web notifications now listen to the notification stream directly Native notifications no longer depend on PWA push being enabled Window focus is respected when deciding whether to send background push notifications
318 lines
8.8 KiB
JavaScript
318 lines
8.8 KiB
JavaScript
const parsePushSubscribeBody = (body) => {
|
|
if (!body || typeof body !== 'object') return null;
|
|
const endpoint = body.endpoint;
|
|
const keys = body.keys;
|
|
const p256dh = keys?.p256dh;
|
|
const auth = keys?.auth;
|
|
|
|
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
|
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
|
|
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
|
|
|
|
return {
|
|
endpoint: endpoint.trim(),
|
|
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
|
|
};
|
|
};
|
|
|
|
const parsePushUnsubscribeBody = (body) => {
|
|
if (!body || typeof body !== 'object') return null;
|
|
const endpoint = body.endpoint;
|
|
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
|
return { endpoint: endpoint.trim() };
|
|
};
|
|
|
|
export const registerNotificationRoutes = (app, dependencies) => {
|
|
const {
|
|
uiAuthController,
|
|
ensurePushInitialized,
|
|
ensureGlobalWatcherStarted,
|
|
getOrCreateVapidKeys,
|
|
getUiSessionTokenFromRequest,
|
|
readSettingsFromDiskMigrated,
|
|
writeSettingsToDisk,
|
|
addOrUpdatePushSubscription,
|
|
removePushSubscription,
|
|
updateUiVisibility,
|
|
isUiVisible,
|
|
getUiNotificationClients,
|
|
writeSseEvent,
|
|
getSessionActivitySnapshot,
|
|
getSessionStateSnapshot,
|
|
getSessionAttentionSnapshot,
|
|
getSessionState,
|
|
getSessionAttentionState,
|
|
markSessionViewed,
|
|
markSessionUnviewed,
|
|
markUserMessageSent,
|
|
setPushInitialized,
|
|
setAutoAcceptSession,
|
|
} = dependencies;
|
|
|
|
const ensureSessionWatcher = async () => {
|
|
if (typeof ensureGlobalWatcherStarted !== 'function') {
|
|
return;
|
|
}
|
|
try {
|
|
await ensureGlobalWatcherStarted();
|
|
} catch (error) {
|
|
console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error);
|
|
}
|
|
};
|
|
|
|
app.get('/api/push/vapid-public-key', async (_req, res) => {
|
|
try {
|
|
await ensurePushInitialized();
|
|
const keys = await getOrCreateVapidKeys();
|
|
res.json({ publicKey: keys.publicKey });
|
|
} catch (error) {
|
|
console.warn('[Push] Failed to load VAPID key:', error);
|
|
res.status(500).json({ error: 'Failed to load push key' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/push/subscribe', async (req, res) => {
|
|
await ensurePushInitialized();
|
|
await ensureSessionWatcher();
|
|
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|
: getUiSessionTokenFromRequest(req);
|
|
if (!uiToken) {
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|
}
|
|
|
|
const parsed = parsePushSubscribeBody(req.body);
|
|
if (!parsed) {
|
|
return res.status(400).json({ error: 'Invalid body' });
|
|
}
|
|
|
|
const { endpoint, keys } = parsed;
|
|
|
|
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
|
|
if (origin.startsWith('http://') || origin.startsWith('https://')) {
|
|
try {
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
|
|
await writeSettingsToDisk({
|
|
...settings,
|
|
publicOrigin: origin,
|
|
});
|
|
setPushInitialized(false);
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
await addOrUpdatePushSubscription(
|
|
uiToken,
|
|
{
|
|
endpoint,
|
|
p256dh: keys.p256dh,
|
|
auth: keys.auth,
|
|
},
|
|
req.headers['user-agent']
|
|
);
|
|
|
|
return res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/push/subscribe', async (req, res) => {
|
|
await ensurePushInitialized();
|
|
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|
: getUiSessionTokenFromRequest(req);
|
|
if (!uiToken) {
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|
}
|
|
|
|
const parsed = parsePushUnsubscribeBody(req.body);
|
|
if (!parsed) {
|
|
return res.status(400).json({ error: 'Invalid body' });
|
|
}
|
|
|
|
await removePushSubscription(uiToken, parsed.endpoint);
|
|
return res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/push/visibility', async (req, res) => {
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|
: getUiSessionTokenFromRequest(req);
|
|
if (!uiToken) {
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|
}
|
|
|
|
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
|
|
updateUiVisibility(uiToken, visible === true);
|
|
return res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/push/visibility', (req, res) => {
|
|
const uiToken = getUiSessionTokenFromRequest(req);
|
|
if (!uiToken) {
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|
}
|
|
|
|
return res.json({
|
|
ok: true,
|
|
visible: isUiVisible(uiToken),
|
|
});
|
|
});
|
|
|
|
app.get('/api/notifications/stream', async (req, res) => {
|
|
await ensureSessionWatcher();
|
|
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|
: getUiSessionTokenFromRequest(req);
|
|
if (!uiToken) {
|
|
return;
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
res.flushHeaders?.();
|
|
|
|
const clients = getUiNotificationClients();
|
|
clients.add(res);
|
|
|
|
try {
|
|
writeSseEvent(res, {
|
|
type: 'openchamber:notification-stream-ready',
|
|
properties: { uiToken },
|
|
});
|
|
} catch {
|
|
}
|
|
|
|
req.on('close', () => {
|
|
clients.delete(res);
|
|
});
|
|
});
|
|
|
|
app.get('/api/session-activity', (_req, res) => {
|
|
void ensureSessionWatcher();
|
|
res.json(getSessionActivitySnapshot());
|
|
});
|
|
|
|
app.get('/api/sessions/snapshot', async (_req, res) => {
|
|
await ensureSessionWatcher();
|
|
res.json({
|
|
statusSessions: getSessionStateSnapshot(),
|
|
attentionSessions: getSessionAttentionSnapshot(),
|
|
serverTime: Date.now(),
|
|
});
|
|
});
|
|
|
|
app.get('/api/sessions/status', async (_req, res) => {
|
|
await ensureSessionWatcher();
|
|
const snapshot = getSessionStateSnapshot();
|
|
res.json({
|
|
sessions: snapshot,
|
|
serverTime: Date.now(),
|
|
});
|
|
});
|
|
|
|
app.get('/api/sessions/:id/status', async (req, res) => {
|
|
await ensureSessionWatcher();
|
|
const sessionId = req.params.id;
|
|
const state = getSessionState(sessionId);
|
|
|
|
if (!state) {
|
|
return res.status(404).json({
|
|
error: 'Session not found or no state available',
|
|
sessionId,
|
|
});
|
|
}
|
|
|
|
return res.json({
|
|
sessionId,
|
|
...state,
|
|
});
|
|
});
|
|
|
|
app.get('/api/sessions/attention', async (_req, res) => {
|
|
await ensureSessionWatcher();
|
|
const snapshot = getSessionAttentionSnapshot();
|
|
res.json({
|
|
sessions: snapshot,
|
|
serverTime: Date.now(),
|
|
});
|
|
});
|
|
|
|
app.get('/api/sessions/:id/attention', async (req, res) => {
|
|
await ensureSessionWatcher();
|
|
const sessionId = req.params.id;
|
|
const state = getSessionAttentionState(sessionId);
|
|
|
|
if (!state) {
|
|
return res.status(404).json({
|
|
error: 'Session not found or no attention state available',
|
|
sessionId,
|
|
});
|
|
}
|
|
|
|
return res.json({
|
|
sessionId,
|
|
...state,
|
|
});
|
|
});
|
|
|
|
app.post('/api/sessions/:id/view', (req, res) => {
|
|
const sessionId = req.params.id;
|
|
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
|
|
|
|
markSessionViewed(sessionId, clientId);
|
|
|
|
return res.json({
|
|
success: true,
|
|
sessionId,
|
|
viewed: true,
|
|
});
|
|
});
|
|
|
|
app.post('/api/sessions/:id/unview', (req, res) => {
|
|
const sessionId = req.params.id;
|
|
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
|
|
|
|
markSessionUnviewed(sessionId, clientId);
|
|
|
|
return res.json({
|
|
success: true,
|
|
sessionId,
|
|
viewed: false,
|
|
});
|
|
});
|
|
|
|
app.post('/api/sessions/:id/message-sent', (req, res) => {
|
|
const sessionId = req.params.id;
|
|
|
|
markUserMessageSent(sessionId);
|
|
|
|
return res.json({
|
|
success: true,
|
|
sessionId,
|
|
messageSent: true,
|
|
});
|
|
});
|
|
|
|
// Mirror client-side Permission Auto-Accept state to the server so it can
|
|
// suppress permission notifications at the source (the 500ms debounce race
|
|
// otherwise leaks notifications for auto-accepted permissions).
|
|
app.post('/api/notifications/auto-accept', (req, res) => {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
const enabled = body.enabled === true;
|
|
if (!sessionId) {
|
|
return res.status(400).json({ error: 'sessionId required' });
|
|
}
|
|
if (typeof setAutoAcceptSession === 'function') {
|
|
setAutoAcceptSession(sessionId, enabled);
|
|
}
|
|
return res.json({ success: true, sessionId, enabled });
|
|
});
|
|
};
|