Electron updater now uses Electron release metadata only Removed legacy Tauri package and migration workflow Replaced Tauri shim usage with the desktop bridge
228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
|
|
|
const SW_READY_TIMEOUT_MS = 1500;
|
|
const NOTIFICATION_DEDUPE_TTL_MS = 5000;
|
|
const NOTIFICATION_DEDUPE_STORAGE_PREFIX = 'openchamber-notification-claim:';
|
|
|
|
const notificationClaims = new Map<string, number>();
|
|
|
|
const isClientFocused = (): boolean => {
|
|
if (typeof document === 'undefined') return true;
|
|
return document.visibilityState === 'visible' && document.hasFocus();
|
|
};
|
|
|
|
const getNotificationClaimKey = (payload?: NotificationPayload): string => {
|
|
const tag = typeof payload?.tag === 'string' ? payload.tag.trim() : '';
|
|
if (tag) return tag;
|
|
|
|
return [payload?.sessionId, payload?.kind, payload?.title, payload?.body]
|
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
.map((value) => value.trim())
|
|
.join('|');
|
|
};
|
|
|
|
const pruneNotificationClaims = (now: number): void => {
|
|
for (const [key, claimedAt] of notificationClaims) {
|
|
if (now - claimedAt > NOTIFICATION_DEDUPE_TTL_MS) {
|
|
notificationClaims.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
const claimNotificationPayload = (payload?: NotificationPayload): boolean => {
|
|
const key = getNotificationClaimKey(payload);
|
|
if (!key) return true;
|
|
|
|
const now = Date.now();
|
|
pruneNotificationClaims(now);
|
|
|
|
const claimedAt = notificationClaims.get(key) ?? 0;
|
|
if (now - claimedAt < NOTIFICATION_DEDUPE_TTL_MS) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (typeof window !== 'undefined' && window.localStorage) {
|
|
const storageKey = `${NOTIFICATION_DEDUPE_STORAGE_PREFIX}${key}`;
|
|
const stored = Number(window.localStorage.getItem(storageKey) ?? '0');
|
|
if (Number.isFinite(stored) && now - stored < NOTIFICATION_DEDUPE_TTL_MS) {
|
|
notificationClaims.set(key, stored);
|
|
return false;
|
|
}
|
|
if (Number.isFinite(stored) && stored > 0) {
|
|
window.localStorage.removeItem(storageKey);
|
|
}
|
|
window.localStorage.setItem(storageKey, String(now));
|
|
}
|
|
} catch {
|
|
// Storage is best-effort; in-memory dedupe still covers duplicate streams in this tab.
|
|
}
|
|
|
|
notificationClaims.set(key, now);
|
|
return true;
|
|
};
|
|
|
|
const getNotificationRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
return null;
|
|
}
|
|
|
|
let existing: ServiceWorkerRegistration | null = null;
|
|
try {
|
|
existing = (await navigator.serviceWorker.getRegistration()) ?? null;
|
|
} catch {
|
|
existing = null;
|
|
}
|
|
|
|
if (existing?.active) {
|
|
return existing;
|
|
}
|
|
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const ready = await Promise.race<ServiceWorkerRegistration | null>([
|
|
navigator.serviceWorker.ready,
|
|
new Promise<null>((resolve) => {
|
|
setTimeout(() => resolve(null), SW_READY_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
|
|
return ready ?? existing;
|
|
} catch {
|
|
return existing;
|
|
}
|
|
};
|
|
|
|
const notifyWithServiceWorker = async (payload?: NotificationPayload): Promise<boolean> => {
|
|
const registration = await getNotificationRegistration();
|
|
if (!registration || typeof registration.showNotification !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await registration.showNotification(payload?.title ?? 'OpenChamber', {
|
|
body: payload?.body,
|
|
tag: payload?.tag,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to send notification via service worker', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const hasActivePushSubscription = async (): Promise<boolean> => {
|
|
const registration = await getNotificationRegistration();
|
|
if (!registration || !('pushManager' in registration) || !registration.pushManager) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return Boolean(await registration.pushManager.getSubscription());
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean> => {
|
|
if (payload?.requireHidden && typeof document !== 'undefined' && document.hasFocus()) {
|
|
return true;
|
|
}
|
|
|
|
if (typeof Notification === 'undefined') {
|
|
console.info('Notifications not supported in this environment', payload);
|
|
return false;
|
|
}
|
|
|
|
if (Notification.permission === 'default') {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
console.warn('Notification permission not granted');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (Notification.permission !== 'granted') {
|
|
console.warn('Notification permission not granted');
|
|
return false;
|
|
}
|
|
|
|
// Background push is the delivery channel when the web/PWA client is not
|
|
// focused. Keep the main notification toggle and templates enabled, but avoid
|
|
// also showing the same foreground notification from a hidden page.
|
|
if (!isClientFocused() && await hasActivePushSubscription()) {
|
|
return true;
|
|
}
|
|
|
|
if (!claimNotificationPayload(payload)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
// Some installed PWAs expose Notification.permission but only allow
|
|
// notifications through an active service worker registration.
|
|
if (await notifyWithServiceWorker(payload)) {
|
|
return true;
|
|
}
|
|
|
|
new Notification(payload?.title ?? 'OpenChamber', {
|
|
body: payload?.body,
|
|
tag: payload?.tag,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to send notification', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const notifyWithDesktop = async (payload?: NotificationPayload): Promise<boolean> => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
|
if (!desktop?.invoke) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await desktop.invoke('desktop_notify', {
|
|
payload: {
|
|
title: payload?.title,
|
|
body: payload?.body,
|
|
tag: payload?.tag,
|
|
kind: payload?.kind,
|
|
sessionId: payload?.sessionId,
|
|
directory: payload?.directory,
|
|
requireHidden: payload?.requireHidden,
|
|
},
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to send native notification (desktop)', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const createWebNotificationsAPI = (): NotificationsAPI => ({
|
|
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
|
return (await notifyWithDesktop(payload)) || (await notifyWithWebAPI(payload));
|
|
},
|
|
canNotify: () => {
|
|
if (typeof window !== 'undefined') {
|
|
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
|
if (desktop?.invoke) {
|
|
return true;
|
|
}
|
|
}
|
|
return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false;
|
|
},
|
|
});
|
|
type DesktopBridgeGlobal = {
|
|
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
};
|