Merge branch 'feature/push'

This commit is contained in:
2025-09-21 16:03:43 +03:00
Unverified
18 changed files with 881 additions and 17 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from routes import account, messaging, profile from routes import account, messaging, profile, push
# Инициализация FastAPI # Инициализация FastAPI
app = FastAPI(title="PixelChat") app = FastAPI(title="PixelChat")
@@ -19,3 +19,4 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
+73
View File
@@ -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()
+17
View File
@@ -68,6 +68,18 @@ class DMEnvelope(Base):
timestamp = Column(DateTime, default=datetime.now) 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 модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel):
bio: str bio: str
class PushSubscriptionRequest(BaseModel):
endpoint: str
keys: dict
class UserProfileResponse(BaseModel): class UserProfileResponse(BaseModel):
id: int id: int
username: str username: str
+149
View File
@@ -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()
+2
View File
@@ -6,3 +6,5 @@ bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
pywebpush>=1.14.0
cryptography>=41.0.0
+20
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
from push_service import push_service
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -58,6 +59,12 @@ async def send_message(
db.commit() db.commit()
db.refresh(new_message) 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)} 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.add(env)
db.commit() db.commit()
db.refresh(env) 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} 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 self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload); await self.send_to_user(env.sender_id, payload);
+46
View File
@@ -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))
+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);
@@ -0,0 +1,89 @@
/// <reference lib="webworker" />
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
});
@@ -1,15 +1,66 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { PRODUCT_NAME } 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 { 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) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); 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) => { const handlePanelChange = (panelId: string) => {
setActivePanel(panelId); 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 ( return (
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog"> <MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
<div className="fullscreen-wrapper"> <div className="fullscreen-wrapper">
@@ -87,6 +138,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div className="screen"> <div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}> <div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3> <h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
Push уведомления
</mdui-switch>
)}
<mdui-switch checked>Новые сообщения</mdui-switch> <mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch> <mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch> <mdui-switch>Уведомления о статусе</mdui-switch>
+25 -1
View File
@@ -8,6 +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 { 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[]>([]);
@@ -66,7 +68,29 @@ export default function LoginScreen() {
} }
setCurrentPage("chat"); 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 { } else {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль"); showAlert("danger", data.message || "Неверное имя пользователя или пароль");
+19
View File
@@ -7,6 +7,8 @@ import { DMPanel, type DMPanelData } from "./panels/DMPanel";
import { getAuthHeaders } from "../auth/api"; import { getAuthHeaders } from "../auth/api";
import { restoreKeys } from "../auth/crypto"; import { restoreKeys } from "../auth/crypto";
import { API_BASE_URL } from "../core/config"; 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" type Page = "login" | "register" | "chat"
export type ChatTabs = "chats" | "channels" | "contacts" | "dms" export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
@@ -215,6 +217,23 @@ export const useAppState = create<AppState>((set, get) => ({
}, },
currentPage: "chat" 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 { } else {
throw new Error("Unable to authenticate"); throw new Error("Unable to authenticate");
} }
+1
View File
@@ -19,6 +19,7 @@ import 'mdui/components/text-field';
import 'mdui/components/button-icon'; import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title'; import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';
+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;
}
}
+45 -2
View File
@@ -3,6 +3,9 @@ import { createHtmlPlugin } from "vite-plugin-html";
import autoprefixer from "autoprefixer"; import autoprefixer from "autoprefixer";
import electron from "vite-plugin-electron/simple"; import electron from "vite-plugin-electron/simple";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { resolve } from "path";
const serviceWorkerPath = resolve(__dirname, "src/service-worker/service-worker.ts");
const plugins: PluginOption[] = [ const plugins: PluginOption[] = [
react(), react(),
@@ -17,7 +20,31 @@ const plugins: PluginOption[] = [
minifyCSS: true, minifyCSS: true,
minifyJS: 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) { if (process.env.VITE_ELECTRON) {
@@ -79,6 +106,22 @@ export default defineConfig({
}, },
cssMinify: true, cssMinify: true,
assetsInlineLimit: 0, 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';
}
}
}
} }
}); });
+1 -1
View File
@@ -16,7 +16,7 @@
"frontend:typecheck": "tsc --project frontend", "frontend:typecheck": "tsc --project frontend",
"frontend:build": "npm run frontend:typecheck && vite build frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend",
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev", "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:preview": "vite preview frontend",
"frontend:dependencies": "npm install --ignore-scripts", "frontend:dependencies": "npm install --ignore-scripts",
"frontend:clean": "rm -rf frontend/dist", "frontend:clean": "rm -rf frontend/dist",