Implement notifications for Electron

This commit is contained in:
2025-09-21 15:23:27 +03:00
Unverified
parent fcb567a198
commit 137d02567b
7 changed files with 368 additions and 183 deletions
+12 -1
View File
@@ -1,8 +1,19 @@
export type Platform = "win32" | "darwin" | "linux" export type Platform = "win32" | "darwin" | "linux"
export interface ElectronNotifications {
requestPermission: () => Promise<NotificationPermission>;
show: (options: {
title: string;
body: string;
icon?: string;
tag?: string;
}) => Promise<boolean>;
}
export interface ElectronInterface { export interface ElectronInterface {
desktop: true, desktop: true,
platform: Platform platform: Platform,
notifications: ElectronNotifications
} }
declare global { declare global {
+43 -4
View File
@@ -1,8 +1,10 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path"; import path from "node:path";
let mainWindow: BrowserWindow | null = null;
app.whenReady().then(() => { app.whenReady().then(() => {
const win = new BrowserWindow({ mainWindow = new BrowserWindow({
title: 'Main window', title: 'Main window',
minWidth: 800, minWidth: 800,
minHeight: 420, minHeight: 420,
@@ -19,8 +21,45 @@ app.whenReady().then(() => {
}); });
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL); mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
} else { } else {
win.loadFile('frontend/build/electron/dist/index.html'); mainWindow.loadFile('frontend/build/electron/dist/index.html');
} }
// Handle notification permission requests
ipcMain.handle('request-notification-permission', async () => {
if (Notification.isSupported()) {
return 'granted';
}
return 'denied';
});
// Handle showing notifications
ipcMain.handle('show-notification', async (event, options) => {
if (Notification.isSupported()) {
try {
const notification = new Notification({
title: options.title,
body: options.body,
icon: options.icon,
silent: false,
urgency: 'normal'
});
notification.on('click', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
notification.show();
return true;
} catch (error) {
console.error('Error creating notification:', error);
return false;
}
}
return false;
});
}); });
+6 -2
View File
@@ -1,9 +1,13 @@
import { contextBridge } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { ElectronInterface, Platform } from "../electron"; import type { ElectronInterface, Platform } from "../electron";
const electronInterface: ElectronInterface = { const electronInterface: ElectronInterface = {
desktop: true, desktop: true,
platform: process.platform as Platform platform: process.platform as Platform,
notifications: {
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
show: (options: any) => ipcRenderer.invoke('show-notification', options)
}
} }
contextBridge.exposeInMainWorld("electronInterface", electronInterface); contextBridge.exposeInMainWorld("electronInterface", electronInterface);
@@ -2,7 +2,8 @@ import { useState, useEffect } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config"; import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
import type { DialogProps } from "../../../core/types"; import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "../core/Dialog";
import { pushNotificationManager } from "../../../utils/pushNotifications"; import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/notifications";
import { isElectron } from "../../../electron/electron";
import { useAppState } from "../../state"; import { useAppState } from "../../state";
import type { Switch } from "mdui/components/switch"; import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api"; import { getAuthHeaders } from "../../../auth/api";
@@ -14,8 +15,10 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const user = useAppState(state => state.user); const user = useAppState(state => state.user);
useEffect(() => { useEffect(() => {
setPushSupported(pushNotificationManager.isSupported()); setPushSupported(isSupported());
setPushNotificationsEnabled(!!pushNotificationManager.getSubscription()); // For Electron, we assume notifications are enabled if supported
// For web browsers, we check if there's a subscription
setPushNotificationsEnabled(isSupported());
}, []); }, []);
const handlePanelChange = (panelId: string) => { const handlePanelChange = (panelId: string) => {
@@ -27,19 +30,26 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
try { try {
if (enabled) { if (enabled) {
await pushNotificationManager.initialize(); const initialized = await initialize();
const permission = await pushNotificationManager.requestPermission(); if (initialized) {
await subscribe(user.authToken);
if (permission === "granted") {
const subscription = await pushNotificationManager.subscribe(); // For Electron, start the notification receiver
if (subscription) { if (isElectron) {
await pushNotificationManager.sendSubscriptionToServer(user.authToken); await startElectronReceiver();
setPushNotificationsEnabled(true);
} }
setPushNotificationsEnabled(true);
} }
} else { } else {
await pushNotificationManager.unsubscribe(); await unsubscribe();
// Call API to unsubscribe on server
// For Electron, stop the notification receiver
if (isElectron) {
stopElectronReceiver();
}
// Call API to unsubscribe on server (for web browsers)
await fetch(`${API_BASE_URL}/push/unsubscribe`, { await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE", method: "DELETE",
headers: getAuthHeaders(user.authToken) headers: getAuthHeaders(user.authToken)
@@ -47,7 +57,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
setPushNotificationsEnabled(false); setPushNotificationsEnabled(false);
} }
} catch (error) { } catch (error) {
console.error("Failed to toggle push notifications:", error); console.error("Failed to toggle notifications:", error);
} }
}; };
+16 -14
View File
@@ -8,7 +8,8 @@ import { useRef } from "react";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state"; import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField"; import { MaterialTextField } from "../components/core/TextField";
import { pushNotificationManager } from "../../utils/pushNotifications"; import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/notifications";
import { isElectron } from "../../electron/electron";
export default function LoginScreen() { export default function LoginScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]); const [alerts, updateAlerts] = useImmer<Alert[]>([]);
@@ -68,26 +69,27 @@ export default function LoginScreen() {
setCurrentPage("chat"); setCurrentPage("chat");
// Initialize push notifications // Initialize notifications
try { try {
if (pushNotificationManager.isSupported()) { if (isSupported()) {
await pushNotificationManager.initialize(); const initialized = await initialize();
const permission = await pushNotificationManager.requestPermission(); if (initialized) {
await subscribe(data.token);
if (permission === "granted") {
const subscription = await pushNotificationManager.subscribe(); // For Electron, start the notification receiver
if (subscription) { if (isElectron) {
await pushNotificationManager.sendSubscriptionToServer(data.token); await startElectronReceiver();
console.log("Push notifications enabled");
} }
console.log("Notifications enabled");
} else { } else {
console.log("Push notification permission denied"); console.log("Notification permission denied");
} }
} else { } else {
console.log("Not supported"); console.log("Notifications not supported");
} }
} catch (e) { } catch (e) {
console.error("Push notification setup failed:", e); console.error("Notification setup failed:", e);
} }
} else { } else {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
+267
View File
@@ -0,0 +1,267 @@
import { API_BASE_URL } from "../core/config";
import { isElectron } from "../electron/electron";
import { websocket } from "../core/websocket";
import type { WebSocketMessage } from "../core/types";
export interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface NotificationPayload {
title: string;
body: string;
icon?: string;
image?: string;
tag?: string;
data?: any;
}
// Global state
let isInitialized = false;
let registration: ServiceWorkerRegistration | null = null;
let subscription: PushSubscription | null = null;
let isElectronReceiverRunning = false;
let messageListener: ((event: MessageEvent) => void) | null = null;
// Helper functions
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
async function subscribeToWebPush(): Promise<PushSubscription | null> {
if (!registration) {
throw new Error("Service Worker not initialized");
}
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
).slice().buffer
});
console.log("Push subscription successful");
return subscription;
} catch (error) {
console.error("Push subscription failed:", error);
return null;
}
}
async function sendSubscriptionToServer(token: string): Promise<boolean> {
if (!subscription) {
throw new Error("No push subscription available");
}
const subscriptionData: PushSubscriptionData = {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
auth: arrayBufferToBase64(subscription.getKey("auth")!)
}
};
try {
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(subscriptionData)
});
return response.ok;
} catch (error) {
console.error("Failed to send subscription to server:", error);
return false;
}
}
async function showMessageNotification(message: any): Promise<void> {
try {
await showNotification({
title: `New message from ${message.username}`,
body: message.content.length > 100
? message.content.substring(0, 100) + "..."
: message.content,
icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`,
data: {
type: "public_message",
message_id: message.id,
sender_id: message.user_id,
sender_username: message.username
}
});
} catch (error) {
console.error("Failed to show message notification:", error);
}
}
async function handleWebSocketMessage(response: WebSocketMessage): Promise<void> {
// Handle notifications for new messages
if (response.type === "newMessage" && response.data) {
await showMessageNotification(response.data);
}
}
// Public API functions
export async function initialize(): Promise<boolean> {
if (isInitialized) {
return true;
}
try {
if (isElectron) {
// For Electron, we just need to request permission
const permission = await window.electronInterface.notifications.requestPermission();
isInitialized = permission === "granted";
return isInitialized;
} else {
// For web browsers, initialize service worker and push manager
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push messaging is not supported");
return false;
}
try {
registration = await navigator.serviceWorker.register("/assets/serviceWorker.js");
console.log("Service Worker registered successfully");
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToWebPush();
isInitialized = true;
}
return isInitialized;
} catch (error) {
console.error("Service Worker registration failed:", error);
return false;
}
}
} catch (error) {
console.error("Failed to initialize notification service:", error);
return false;
}
}
export async function subscribe(token: string): Promise<boolean> {
if (!isInitialized) {
return false;
}
if (isElectron) {
// In Electron, we don't need server-side subscription
return true;
}
return await sendSubscriptionToServer(token);
}
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
if (isElectron) {
try {
return await window.electronInterface.notifications.show({
title: payload.title,
body: payload.body,
icon: payload.icon,
tag: payload.tag
});
} catch (error) {
console.error("Failed to show Electron notification:", error);
return false;
}
}
// For web browsers, notifications are handled by the service worker
// when push messages are received from the server
return false;
}
export async function unsubscribe(): Promise<boolean> {
if (isElectron) {
// In Electron, we don't need to unsubscribe from server
return true;
}
if (!subscription) {
return true;
}
try {
const result = await subscription.unsubscribe();
subscription = null;
return result;
} catch (error) {
console.error("Failed to unsubscribe:", error);
return false;
}
}
export function isSupported(): boolean {
if (isElectron) {
return true; // Electron always supports notifications
}
return "serviceWorker" in navigator && "PushManager" in window;
}
// Electron-specific functions
export async function startElectronReceiver(): Promise<void> {
if (!isElectron || isElectronReceiverRunning) {
return;
}
isElectronReceiverRunning = true;
// Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => {
try {
const response: WebSocketMessage = JSON.parse(event.data);
handleWebSocketMessage(response);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
websocket.addEventListener('message', messageListener);
}
export function stopElectronReceiver(): void {
if (!isElectron) {
return;
}
isElectronReceiverRunning = false;
// Remove our message listener
if (messageListener) {
websocket.removeEventListener('message', messageListener);
messageListener = null;
}
}
-148
View File
@@ -1,148 +0,0 @@
import { API_BASE_URL } from "../core/config";
export interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface NotificationPayload {
title: string;
body: string;
icon?: string;
image?: string;
tag?: string;
data?: any;
}
class PushNotificationManager {
private registration: ServiceWorkerRegistration | null = null;
private subscription: PushSubscription | null = null;
async initialize(): Promise<boolean> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push messaging is not supported");
return false;
}
try {
this.registration = await navigator.serviceWorker.register("/assets/serviceWorker.js");
console.log("Service Worker registered successfully");
return true;
} catch (error) {
console.error("Service Worker registration failed:", error);
return false;
}
}
async requestPermission(): Promise<NotificationPermission> {
if (!this.registration) {
throw new Error("Service Worker not initialized");
}
const permission = await Notification.requestPermission();
return permission;
}
async subscribe(): Promise<PushSubscription | null> {
if (!this.registration) {
throw new Error("Service Worker not initialized");
}
try {
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
).slice().buffer
});
console.log("Push subscription successful");
return this.subscription;
} catch (error) {
console.error("Push subscription failed:", error);
return null;
}
}
async sendSubscriptionToServer(token: string): Promise<boolean> {
if (!this.subscription) {
throw new Error("No push subscription available");
}
const subscriptionData: PushSubscriptionData = {
endpoint: this.subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(this.subscription.getKey("p256dh")!),
auth: this.arrayBufferToBase64(this.subscription.getKey("auth")!)
}
};
try {
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(subscriptionData)
});
return response.ok;
} catch (error) {
console.error("Failed to send subscription to server:", error);
return false;
}
}
async unsubscribe(): Promise<boolean> {
if (!this.subscription) {
return true;
}
try {
const result = await this.subscription.unsubscribe();
this.subscription = null;
return result;
} catch (error) {
console.error("Failed to unsubscribe:", error);
return false;
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
getSubscription(): PushSubscription | null {
return this.subscription;
}
isSupported(): boolean {
return "serviceWorker" in navigator && "PushManager" in window;
}
}
export const pushNotificationManager = new PushNotificationManager();