diff --git a/backend/app.py b/backend/app.py index 9b7a6ae..dd2ee15 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging, profile +from routes import account, messaging, profile, push # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -18,4 +18,5 @@ app.add_middleware( # Routes app.include_router(account.router) app.include_router(messaging.router) -app.include_router(profile.router) \ No newline at end of file +app.include_router(profile.router) +app.include_router(push.router, prefix="/push") \ No newline at end of file diff --git a/backend/generate_vapid_keys.py b/backend/generate_vapid_keys.py new file mode 100644 index 0000000..8e3a074 --- /dev/null +++ b/backend/generate_vapid_keys.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Generate VAPID keys for push notifications +Run this script to generate new VAPID keys for your application +""" + +from pywebpush import WebPushException +import base64 +import json + +def generate_vapid_keys(): + """Generate VAPID keys for push notifications""" + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.backends import default_backend + + # Generate private key + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + + # Get public key + public_key = private_key.public_key() + + # Serialize keys + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + # Convert to base64 for web push + private_key_b64 = base64.urlsafe_b64encode( + private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + ).decode('utf-8').rstrip('=') + + # Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y) + public_numbers = public_key.public_numbers() + x_bytes = public_numbers.x.to_bytes(32, 'big') + y_bytes = public_numbers.y.to_bytes(32, 'big') + public_key_raw = b'\x04' + x_bytes + y_bytes + + public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=') + + print("VAPID Keys Generated:") + print("=" * 50) + print(f"Private Key: {private_key_b64}") + print(f"Public Key: {public_key_b64}") + print("=" * 50) + print("\nAdd these to your environment variables:") + print(f"VAPID_PRIVATE_KEY={private_key_b64}") + print(f"VAPID_PUBLIC_KEY={public_key_b64}") + + return private_key_b64, public_key_b64 + + except ImportError: + print("Error: cryptography library not found.") + print("Install it with: pip install cryptography") + return None, None + except Exception as e: + print(f"Error generating VAPID keys: {e}") + return None, None + +if __name__ == "__main__": + generate_vapid_keys() diff --git a/backend/models.py b/backend/models.py index e3625c5..d9e2757 100644 --- a/backend/models.py +++ b/backend/models.py @@ -68,6 +68,18 @@ class DMEnvelope(Base): timestamp = Column(DateTime, default=datetime.now) +class PushSubscription(Base): + __tablename__ = "push_subscription" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + endpoint = Column(Text, nullable=False) + p256dh_key = Column(Text, nullable=False) + auth_key = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Pydantic модели class LoginRequest(BaseModel): username: str @@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel): bio: str +class PushSubscriptionRequest(BaseModel): + endpoint: str + keys: dict + + class UserProfileResponse(BaseModel): id: int username: str diff --git a/backend/push_service.py b/backend/push_service.py new file mode 100644 index 0000000..d8b9b1c --- /dev/null +++ b/backend/push_service.py @@ -0,0 +1,149 @@ +import json +import logging +import os +from typing import List, Optional +from sqlalchemy.orm import Session +from pywebpush import webpush, WebPushException +from models import PushSubscription, User, Message, DMEnvelope + +logger = logging.getLogger("uvicorn.error") + +class PushNotificationService: + def __init__(self): + # VAPID keys - load from environment variables + self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY", "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghg2CSKiq0KsXXXImE75Z8UAphGBjkpYjUE87zPBmGqKhRANCAATxbNBGMhNl6gLmPL0PAf2YIJCVYX_TZrSqkj7SCqsu5VNMhnDOan6Qc9hEkcTZgvwj286C24SnxfH5CghVMCI6") + self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY", "BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo") + self.vapid_claims = { + "sub": "mailto:admin@fromchat.com", + "aud": "https://fcm.googleapis.com" + } + + async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool: + """Subscribe a user to push notifications""" + try: + # Check if user already has a subscription + existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first() + + if existing_sub: + # Update existing subscription + existing_sub.endpoint = endpoint + existing_sub.p256dh_key = p256dh_key + existing_sub.auth_key = auth_key + else: + # Create new subscription + new_sub = PushSubscription( + user_id=user_id, + endpoint=endpoint, + p256dh_key=p256dh_key, + auth_key=auth_key + ) + db.add(new_sub) + + db.commit() + logger.info(f"Push subscription saved for user {user_id}") + return True + except Exception as e: + logger.error(f"Failed to save push subscription for user {user_id}: {e}") + db.rollback() + return False + + async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None): + """Send push notification for a new public chat message""" + try: + # Get all users except the sender + users = db.query(User).filter(User.id != message.user_id) + if exclude_user_id: + users = users.filter(User.id != exclude_user_id) + + for user in users: + # Check if user has push subscription before trying to send + subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first() + if not subscription: + continue + + await self._send_notification_to_user( + db, user.id, + f"New message from {message.author.username}", + message.content[:100] + ("..." if len(message.content) > 100 else ""), + message.author.profile_picture, + { + "type": "public_message", + "message_id": message.id, + "sender_id": message.user_id, + "sender_username": message.author.username + } + ) + except Exception as e: + logger.error(f"Failed to send public message notifications: {e}") + + async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User): + """Send push notification for a new DM""" + try: + await self._send_notification_to_user( + db, dm_envelope.recipient_id, + f"New message from {sender.username}", + "You have a new direct message", + sender.profile_picture, + { + "type": "dm", + "dm_id": dm_envelope.id, + "sender_id": sender.id, + "sender_username": sender.username + } + ) + except Exception as e: + logger.error(f"Failed to send DM notification: {e}") + + async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict): + """Send a push notification to a specific user""" + try: + subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first() + if not subscription: + return + + payload = { + "title": title, + "body": body, + "icon": icon or "/logo.png", + "tag": f"message_{user_id}", + "data": data + } + + subscription_info = { + "endpoint": subscription.endpoint, + "keys": { + "p256dh": subscription.p256dh_key, + "auth": subscription.auth_key + } + } + + webpush( + subscription_info=subscription_info, + data=json.dumps(payload), + vapid_private_key=self.vapid_private_key, + vapid_claims=self.vapid_claims + ) + + except WebPushException as e: + logger.error(f"WebPush error for user {user_id}: {e}") + # If the subscription is invalid, remove it + if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]: + db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete() + db.commit() + except Exception as e: + logger.error(f"Failed to send push notification to user {user_id}: {e}") + + async def unsubscribe_user(self, db: Session, user_id: int) -> bool: + """Unsubscribe a user from push notifications""" + try: + db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete() + db.commit() + logger.info(f"Push subscription removed for user {user_id}") + return True + except Exception as e: + logger.error(f"Failed to remove push subscription for user {user_id}: {e}") + db.rollback() + return False + +# Global instance +push_service = PushNotificationService() diff --git a/backend/requirements.txt b/backend/requirements.txt index 326b790..966e1b9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,6 @@ sqlalchemy>=2.0.43 bcrypt>=4.3.0 websockets>=15.0.1 Pillow>=10.0.0 -python-multipart>=0.0.6 \ No newline at end of file +python-multipart>=0.0.6 +pywebpush>=1.14.0 +cryptography>=41.0.0 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 927aac3..a5b527b 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope +from push_service import push_service router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -58,6 +59,12 @@ async def send_message( db.commit() db.refresh(new_message) + # Send push notifications for public messages + try: + await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) + except Exception as e: + logger.error(f"Failed to send push notification for message {new_message.id}: {e}") + return {"status": "success", "message": convert_message(new_message)} @@ -93,6 +100,13 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db.add(env) db.commit() db.refresh(env) + + # Send push notification for DM + try: + await push_service.send_dm_notification(db, env, current_user) + except Exception as e: + logger.error(f"Failed to send push notification for DM {env.id}: {e}") + return {"status": "ok", "id": env.id} @@ -298,6 +312,12 @@ class MessaggingSocketManager: } } + # Send push notification for DM + try: + await push_service.send_dm_notification(db, env, current_user) + except Exception as e: + logger.error(f"Failed to send push notification for DM {env.id}: {e}") + await self.send_to_user(env.recipient_id, payload); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await self.send_to_user(env.sender_id, payload); diff --git a/backend/routes/push.py b/backend/routes/push.py new file mode 100644 index 0000000..d9799ed --- /dev/null +++ b/backend/routes/push.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from dependencies import get_current_user, get_db +from models import User, PushSubscriptionRequest +from push_service import push_service + +router = APIRouter() + +@router.post("/subscribe") +async def subscribe_to_push_notifications( + request: PushSubscriptionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Subscribe user to push notifications""" + try: + success = await push_service.subscribe_user( + db=db, + user_id=current_user.id, + endpoint=request.endpoint, + p256dh_key=request.keys["p256dh"], + auth_key=request.keys["auth"] + ) + + if success: + return {"status": "success", "message": "Push notifications enabled"} + else: + raise HTTPException(status_code=500, detail="Failed to enable push notifications") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/unsubscribe") +async def unsubscribe_from_push_notifications( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Unsubscribe user from push notifications""" + try: + success = await push_service.unsubscribe_user(db=db, user_id=current_user.id) + + if success: + return {"status": "success", "message": "Push notifications disabled"} + else: + raise HTTPException(status_code=500, detail="Failed to disable push notifications") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/electron.d.ts b/frontend/electron.d.ts index ae8b74e..a825785 100644 --- a/frontend/electron.d.ts +++ b/frontend/electron.d.ts @@ -1,8 +1,19 @@ export type Platform = "win32" | "darwin" | "linux" +export interface ElectronNotifications { + requestPermission: () => Promise; + show: (options: { + title: string; + body: string; + icon?: string; + tag?: string; + }) => Promise; +} + export interface ElectronInterface { desktop: true, - platform: Platform + platform: Platform, + notifications: ElectronNotifications } declare global { diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 46c4322..cc9b44b 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -1,8 +1,10 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import path from "node:path"; +let mainWindow: BrowserWindow | null = null; + app.whenReady().then(() => { - const win = new BrowserWindow({ + mainWindow = new BrowserWindow({ title: 'Main window', minWidth: 800, minHeight: 420, @@ -19,8 +21,45 @@ app.whenReady().then(() => { }); if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(process.env.VITE_DEV_SERVER_URL); + mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); } 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; + }); }); \ No newline at end of file diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 4d99711..e1ad02c 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -1,9 +1,13 @@ -import { contextBridge } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; import type { ElectronInterface, Platform } from "../electron"; const electronInterface: ElectronInterface = { 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); \ No newline at end of file diff --git a/frontend/src/service-worker/service-worker.ts b/frontend/src/service-worker/service-worker.ts new file mode 100644 index 0000000..6d48b01 --- /dev/null +++ b/frontend/src/service-worker/service-worker.ts @@ -0,0 +1,89 @@ +/// + +declare const self: ServiceWorkerGlobalScope; + +interface NotificationPayload { + title: string; + body: string; + icon?: string; + image?: string; + tag?: string; + data?: any; +} + +interface NotificationAction { + action: string; + title: string; +} + +interface NotificationOptions { + body: string; + icon: string; + badge: string; + image?: string; + tag: string; + data?: any; + actions: NotificationAction[]; + requireInteraction: boolean; + silent: boolean; +} + +// Service Worker for Push Notifications +self.addEventListener("push", function(event: ExtendableEvent) { + const pushEvent = event as PushEvent; + if (pushEvent.data) { + const data: NotificationPayload = pushEvent.data.json(); + + const options: NotificationOptions = { + body: data.body, + icon: data.icon || "/logo.png", + badge: "/logo.png", + image: data.image, + tag: data.tag || "message", + data: data.data, + actions: [ + { + action: "open", + title: "Open Chat" + }, + { + action: "close", + title: "Close" + } + ], + requireInteraction: true, + silent: false + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); + } +}); + +self.addEventListener("notificationclick", function(event: ExtendableEvent) { + const notificationEvent = event as NotificationEvent; + notificationEvent.notification.close(); + + if (notificationEvent.action === "open" || !notificationEvent.action) { + event.waitUntil( + self.clients.matchAll({ type: "window" }).then(function(clientList: readonly WindowClient[]) { + // If there's already a window open, focus it + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url === self.location.origin && "focus" in client) { + return client.focus(); + } + } + // Otherwise, open a new window + if (self.clients.openWindow) { + return self.clients.openWindow(self.location.origin); + } + }) + ); + } +}); + +self.addEventListener("notificationclose", function(_event: ExtendableEvent) { + // Handle notification close if needed +}); diff --git a/frontend/src/ui/components/settings/SettingsDialog.tsx b/frontend/src/ui/components/settings/SettingsDialog.tsx index 786ce55..64471bc 100644 --- a/frontend/src/ui/components/settings/SettingsDialog.tsx +++ b/frontend/src/ui/components/settings/SettingsDialog.tsx @@ -1,15 +1,66 @@ -import { useState } from "react"; -import { PRODUCT_NAME } from "../../../core/config"; +import { useState, useEffect } from "react"; +import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config"; import type { DialogProps } from "../../../core/types"; import { MaterialDialog } from "../core/Dialog"; +import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/notifications"; +import { isElectron } from "../../../electron/electron"; +import { useAppState } from "../../state"; +import type { Switch } from "mdui/components/switch"; +import { getAuthHeaders } from "../../../auth/api"; export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { const [activePanel, setActivePanel] = useState("notifications-settings"); + const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); + const [pushSupported, setPushSupported] = useState(false); + const user = useAppState(state => state.user); + + useEffect(() => { + setPushSupported(isSupported()); + // 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) => { setActivePanel(panelId); }; + const handlePushNotificationToggle = async (enabled: boolean) => { + if (!user.authToken) return; + + try { + if (enabled) { + const initialized = await initialize(); + if (initialized) { + await subscribe(user.authToken); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); + } + + setPushNotificationsEnabled(true); + } + } else { + await unsubscribe(); + + // 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`, { + method: "DELETE", + headers: getAuthHeaders(user.authToken) + }); + setPushNotificationsEnabled(false); + } + } catch (error) { + console.error("Failed to toggle notifications:", error); + } + }; + return (
@@ -87,6 +138,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {

Уведомления

+ {pushSupported && ( + handlePushNotificationToggle((e.target as Switch).checked)} + > + Push уведомления + + )} Новые сообщения Звуковые уведомления Уведомления о статусе diff --git a/frontend/src/ui/screen/LoginScreen.tsx b/frontend/src/ui/screen/LoginScreen.tsx index 24a95f2..40ba22b 100644 --- a/frontend/src/ui/screen/LoginScreen.tsx +++ b/frontend/src/ui/screen/LoginScreen.tsx @@ -8,6 +8,8 @@ import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; import { useAppState } from "../state"; import { MaterialTextField } from "../components/core/TextField"; +import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/notifications"; +import { isElectron } from "../../electron/electron"; export default function LoginScreen() { const [alerts, updateAlerts] = useImmer([]); @@ -64,9 +66,31 @@ export default function LoginScreen() { } catch (e) { console.error("Key setup failed:", e); } - + setCurrentPage("chat"); - // initializeProfile(); // Initialize profile after login + + // Initialize notifications + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(data.token); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); + } + + console.log("Notifications enabled"); + } else { + console.log("Notification permission denied"); + } + } else { + console.log("Notifications not supported"); + } + } catch (e) { + console.error("Notification setup failed:", e); + } } else { const data: ErrorResponse = await response.json(); showAlert("danger", data.message || "Неверное имя пользователя или пароль"); diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 70166e2..21e6594 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -7,6 +7,8 @@ import { DMPanel, type DMPanelData } from "./panels/DMPanel"; import { getAuthHeaders } from "../auth/api"; import { restoreKeys } from "../auth/crypto"; import { API_BASE_URL } from "../core/config"; +import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/notifications"; +import { isElectron } from "../electron/electron"; type Page = "login" | "register" | "chat" export type ChatTabs = "chats" | "channels" | "contacts" | "dms" @@ -215,6 +217,23 @@ export const useAppState = create((set, get) => ({ }, currentPage: "chat" })); + + // Initialize notifications after successful credential restoration + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(token); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); + } + } + } + } catch (e) { + console.error("Notification setup failed (restored):", e); + } } else { throw new Error("Unable to authenticate"); } diff --git a/frontend/src/utils/material.ts b/frontend/src/utils/material.ts index 265d42b..34574a5 100644 --- a/frontend/src/utils/material.ts +++ b/frontend/src/utils/material.ts @@ -19,6 +19,7 @@ import 'mdui/components/text-field'; import 'mdui/components/button-icon'; import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar-title'; +import 'mdui/components/switch'; import { setColorScheme } from 'mdui/functions/setColorScheme.js'; diff --git a/frontend/src/utils/notifications.ts b/frontend/src/utils/notifications.ts new file mode 100644 index 0000000..00ec50b --- /dev/null +++ b/frontend/src/utils/notifications.ts @@ -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 { + 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 { + 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 { + 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 { + // Handle notifications for new messages + if (response.type === "newMessage" && response.data) { + await showMessageNotification(response.data); + } +} + +// Public API functions +export async function initialize(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index cb18e68..593b455 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,9 @@ import { createHtmlPlugin } from "vite-plugin-html"; import autoprefixer from "autoprefixer"; import electron from "vite-plugin-electron/simple"; import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +const serviceWorkerPath = resolve(__dirname, "src/service-worker/service-worker.ts"); const plugins: PluginOption[] = [ react(), @@ -17,7 +20,31 @@ const plugins: PluginOption[] = [ minifyCSS: true, minifyJS: true } - }) + }), + { + name: 'service-worker-redirect', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (req.url === '/assets/serviceWorker.js') { + try { + const traspiled = await server.transformRequest(serviceWorkerPath); + + res.writeHead(200, { + 'Content-Type': "application/javascript" + }); + res.end(traspiled!.code); + } catch (e) { + try { + res.writeHead(500); + res.end(); + } catch (e) {} + } + } else { + next(); + } + }); + } + } ] if (process.env.VITE_ELECTRON) { @@ -79,6 +106,22 @@ export default defineConfig({ }, cssMinify: true, assetsInlineLimit: 0, - outDir: process.env.VITE_ELECTRON ? "build/electron/dist" : "build/normal/dist" + outDir: process.env.VITE_ELECTRON ? "build/electron/dist" : "build/normal/dist", + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + serviceWorker: serviceWorkerPath + }, + output: { + entryFileNames: (chunkInfo) => { + // Проверяем имя чанка + if (chunkInfo.name === 'serviceWorker') { + return 'assets/serviceWorker.js'; // Указываем фиксированное имя для этого скрипта + } + // Для остальных файлов используем стандартное именование с хэшем + return 'assets/[name]-[hash].js'; + } + } + } } }); \ No newline at end of file diff --git a/package.json b/package.json index 13a3c9b..ba01af0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "frontend:typecheck": "tsc --project frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend", "frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev", - "frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force", + "frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64", "frontend:preview": "vite preview frontend", "frontend:dependencies": "npm install --ignore-scripts", "frontend:clean": "rm -rf frontend/dist",