14-04-2026

То что дела ай и то во что надо влить изменнеия
This commit is contained in:
AidarKC 2026-04-14 21:51:16 +03:00
parent 62e55dbaec
commit cfc92beec0
12 changed files with 333 additions and 32 deletions

View File

@ -1,20 +1,39 @@
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
async function broadcastToClients(payload) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach((client) => {
client.postMessage({
type: 'SHINE_WEB_PUSH_EVENT',
payload,
});
});
}
self.addEventListener('push', (event) => {
let body = 'Новое сообщение SHiNE';
let rawText = '';
try {
if (event.data) {
const text = event.data.text();
body = text || body;
rawText = text || '';
body = rawText || body;
}
} catch {
// ignore
}
event.waitUntil(self.registration.showNotification('SHiNE: входящее сообщение', {
body,
tag: 'shine-direct-message',
renotify: true,
}));
event.waitUntil(Promise.all([
self.registration.showNotification('SHiNE: входящее сообщение', {
body,
tag: 'shine-direct-message',
renotify: true,
}),
broadcastToClients({
body,
rawText,
receivedAt: Date.now(),
}),
]));
});

View File

@ -29,7 +29,7 @@
<div id="modal-root"></div>
<script>
// Public VAPID key for Web Push (Base64URL)
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = '';
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = 'BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI';
</script>
<script>
(function attachAppWithBuildHash() {

View File

@ -4,6 +4,7 @@ import { captureClientError, setClientErrorTransport } from './services/client-e
import { initPwaPush } from './services/pwa-push-service.js';
import {
authService,
addAppLogEntry,
authorizeSession,
isSessionInvalidError,
refreshSessions,
@ -36,6 +37,7 @@ import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
@ -68,6 +70,7 @@ const routes = {
'show-keys-view': showKeysView,
'device-session-view': deviceSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
@ -100,6 +103,18 @@ function showGlobalErrorAlert(title, details = {}) {
window.addEventListener('error', (event) => {
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'global_error',
message: event.message || 'Global JS error',
details: {
pageId,
sourceUrl: event.filename || '',
line: event.lineno,
column: event.colno,
stack: event.error?.stack || '',
},
});
captureClientError({
kind: 'global_error',
message: event.message || 'Global JS error',
@ -125,6 +140,16 @@ window.addEventListener('error', (event) => {
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
details: {
pageId,
reasonType: reason?.constructor?.name || typeof reason,
stack: reason?.stack || '',
},
});
captureClientError({
kind: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
@ -200,10 +225,30 @@ async function tryAutoLogin() {
}
async function init() {
addAppLogEntry({
level: 'info',
source: 'app',
message: 'Инициализация UI запущена',
});
setSessionResetHandler(() => {
navigate('start-view');
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
});
}
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
@ -226,18 +271,38 @@ async function init() {
}
}
const added = addIncomingMessage(fromLogin, text, messageId);
if (added) {
addAppLogEntry({
level: 'info',
source: 'incoming-dm',
message: `Входящее сообщение от ${fromLogin}`,
details: { messageId, text },
});
}
if (added && Notification.permission === 'granted') {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
if (eventId) {
try { await authService.ackIncomingMessage(eventId, messageId); } catch {}
try {
await authService.ackIncomingMessage(eventId, messageId);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'incoming-dm',
message: 'Не удалось отправить ACK на входящее сообщение',
details: { eventId, messageId, error: error?.message || 'unknown' },
});
}
}
});
await tryAutoLogin();
if (state.session.isAuthorized) {
await initPwaPush({ authService });
await initPwaPush({
authService,
onLog: (entry) => addAppLogEntry(entry),
});
window.setInterval(async () => {
if (!state.session.isAuthorized) return;
try {

View File

@ -0,0 +1,96 @@
import { renderHeader } from '../components/header.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
function formatTime(ts) {
try {
return new Date(ts).toLocaleTimeString('ru-RU');
} catch {
return String(ts || '');
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const controls = document.createElement('div');
controls.className = 'card row';
controls.style.justifyContent = 'space-between';
controls.style.gap = '8px';
controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
`;
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка лога...';
const list = document.createElement('div');
list.className = 'stack';
function renderEntries() {
const entries = getAppLogEntries();
list.innerHTML = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Лог пока пуст.';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Записей: 0';
return;
}
entries.slice().reverse().forEach((entry) => {
const row = document.createElement('div');
row.className = 'card stack';
const level = String(entry.level || 'info').toUpperCase();
const source = String(entry.source || 'ui');
const details = String(entry.details || '').trim();
const head = document.createElement('div');
head.className = 'meta-muted';
head.textContent = `[${formatTime(entry.ts)}] ${level} ${source}`;
const message = document.createElement('div');
message.textContent = entry.message || '';
row.append(head, message);
if (details) {
const detailsNode = document.createElement('pre');
detailsNode.className = 'meta-muted';
detailsNode.style.whiteSpace = 'pre-wrap';
detailsNode.style.margin = '0';
detailsNode.textContent = details;
row.append(detailsNode);
}
list.append(row);
});
status.className = 'status-line is-available';
status.textContent = `Записей: ${entries.length}`;
}
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearAppLogEntries();
renderEntries();
});
screen.append(controls, status, list);
renderEntries();
return screen;
}

View File

@ -45,20 +45,20 @@ export function render({ navigate }) {
async function loadList() {
try {
const relations = await loadCurrentRelations();
const follows = relations.outFollows || [];
const contacts = relations.outContacts || [];
list.innerHTML = '';
if (!follows.length) {
if (!contacts.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ваш список контактов пока пуст';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Нет подписок на пользователей.';
status.textContent = 'Нет контактов.';
return;
}
const rows = follows.map((login) => {
const rows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];

View File

@ -19,11 +19,13 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
screen.append(card);
return screen;

View File

@ -62,7 +62,8 @@ export function resolveToolbarActive(pageId) {
pageId === 'device-camera-view' ||
pageId === 'show-keys-view' ||
pageId === 'device-session-view' ||
pageId === 'language-view'
pageId === 'language-view' ||
pageId === 'app-log-view'
) {
return 'profile-view';
}

View File

@ -11,34 +11,100 @@ function urlBase64ToUint8Array(base64String) {
return outputArray;
}
export async function initPwaPush({ authService }) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
export async function initPwaPush({ authService, onLog = null }) {
const log = (entry) => {
if (typeof onLog === 'function') onLog(entry);
};
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push недоступен: нет serviceWorker или PushManager',
});
return;
}
const vapidPublicKey = window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '';
if (!vapidPublicKey) return;
if (!vapidPublicKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Web Push отключен: не задан публичный VAPID ключ',
});
return;
}
try {
const registration = await navigator.serviceWorker.register('./firebase-messaging-sw.js');
log({
level: 'info',
source: 'web-push',
message: 'Service Worker зарегистрирован',
details: { scope: registration.scope },
});
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
if (permission !== 'granted') {
log({
level: 'warn',
source: 'web-push',
message: `Разрешение на уведомления: ${permission}`,
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Разрешение на уведомления получено',
});
let sub = await registration.pushManager.getSubscription();
let isNewSubscription = false;
if (!sub) {
sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
isNewSubscription = true;
}
log({
level: 'info',
source: 'web-push',
message: isNewSubscription ? 'Создана новая push-подписка' : 'Найдена существующая push-подписка',
});
const serialized = JSON.stringify(sub);
if (localStorage.getItem(LS_KEY) === serialized) return;
const prevSerialized = localStorage.getItem(LS_KEY);
if (prevSerialized === serialized) {
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка не изменилась, отправка на сервер не требуется',
});
return;
}
localStorage.setItem(LS_KEY, serialized);
const json = sub.toJSON();
const endpoint = json.endpoint || '';
const p256dhKey = json.keys?.p256dh || '';
const authKey = json.keys?.auth || '';
if (!endpoint || !p256dhKey || !authKey) return;
if (!endpoint || !p256dhKey || !authKey) {
log({
level: 'warn',
source: 'web-push',
message: 'Подписка неполная: endpoint/p256dh/auth отсутствуют',
});
return;
}
log({
level: 'info',
source: 'web-push',
message: 'Push-токен получен, отправка на сервер',
details: { endpoint },
});
await authService.upsertPushToken({
endpoint,
@ -47,7 +113,17 @@ export async function initPwaPush({ authService }) {
platform: 'web',
userAgent: navigator.userAgent || '',
});
} catch {
// silent for MVP
log({
level: 'info',
source: 'web-push',
message: 'Push-подписка успешно отправлена на сервер',
});
} catch (error) {
log({
level: 'error',
source: 'web-push',
message: 'Ошибка инициализации Web Push',
details: error?.message || 'unknown',
});
}
}

View File

@ -4,6 +4,7 @@ import { clearClientAuthData } from './services/key-vault.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const MAX_APP_LOG_ENTRIES = 500;
const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED',
'SESSION_NOT_FOUND',
@ -60,6 +61,7 @@ function createInitialState({ withStoredSession = true } = {}) {
return {
chats: clone(chatMessages),
contacts: [],
appLog: [],
incomingDedup: {},
notificationsTab: 'replies',
pageLabelCollapsed: false,
@ -155,6 +157,49 @@ export function setContacts(list) {
state.contacts = Array.isArray(list) ? [...list] : [];
}
function toText(value) {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function addAppLogEntry({
level = 'info',
source = 'ui',
message = '',
details = '',
} = {}) {
const cleanMessage = String(message || '').trim();
if (!cleanMessage) return;
const cleanLevel = String(level || 'info').trim().toLowerCase();
const normalizedLevel = (cleanLevel === 'error' || cleanLevel === 'warn') ? cleanLevel : 'info';
state.appLog.push({
id: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
ts: Date.now(),
level: normalizedLevel,
source: String(source || 'ui').trim() || 'ui',
message: cleanMessage,
details: toText(details),
});
if (state.appLog.length > MAX_APP_LOG_ENTRIES) {
state.appLog.splice(0, state.appLog.length - MAX_APP_LOG_ENTRIES);
}
}
export function getAppLogEntries() {
return [...state.appLog];
}
export function clearAppLogEntries() {
state.appLog = [];
}
export function togglePageLabel() {
state.pageLabelCollapsed = !state.pageLabelCollapsed;
}

View File

@ -64,20 +64,20 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
}
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_SIGNATURE", "Подпись не прошла проверку");
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");
}
long now = System.currentTimeMillis();
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
}
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
if (!replayOk) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "REPLAY", "Повторное сообщение заблокировано");
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано");
}
String messageId = NetIdGenerator.eventId("msg");

View File

@ -8,10 +8,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
public final class WebPushSender {
private static final Logger log = LoggerFactory.getLogger(WebPushSender.class);
@ -41,8 +39,7 @@ public final class WebPushSender {
endpoint,
new Subscription.Keys(p256dhKey, authKey)
);
byte[] payloadBytes = Base64.getDecoder().decode(payloadB64);
Notification notification = new Notification(subscription, payloadBytes);
Notification notification = new Notification(subscription, payloadB64);
var response = service().send(notification);
int code = response.getStatusLine().getStatusCode();
return code >= 200 && code < 300;

View File

@ -14,6 +14,6 @@ server.info.origin=
server.info.extraInfo=
# Web Push (VAPID)
webpush.vapid.public=
webpush.vapid.private=
webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE
webpush.vapid.subject=mailto:admin@shine.local