mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/push'
This commit is contained in:
+2
-1
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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))
|
||||||
Vendored
+12
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || "Неверное имя пользователя или пароль");
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user