14-04-2026

Веб пуш работает. Дальше попробую звонки добавить.
This commit is contained in:
AidarKC 2026-04-15 00:50:25 +03:00
parent 21fbc8ffa0
commit 0b7691bdea
7 changed files with 152 additions and 6 deletions

View File

@ -292,7 +292,7 @@ async function ensureSessionRuntimeStarted() {
pingIntervalId = window.setInterval(async () => { pingIntervalId = window.setInterval(async () => {
if (!state.session.isAuthorized) return; if (!state.session.isAuthorized) return;
try { try {
await authService.ws.request('Ping', { timeMs: Date.now() }); await authService.ws.request('Ping', { ts: Date.now() });
} catch { } catch {
// silent keep-alive // silent keep-alive
} }

View File

@ -24,10 +24,12 @@ export function render({ navigate }) {
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.className = 'card row'; controls.className = 'card row';
controls.style.justifyContent = 'space-between'; controls.style.justifyContent = 'flex-start';
controls.style.gap = '8px'; controls.style.gap = '8px';
controls.style.flexWrap = 'wrap';
controls.innerHTML = ` controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button> <button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button> <button class="ghost-btn" type="button" data-action="clear">Очистить</button>
`; `;
@ -38,6 +40,39 @@ export function render({ navigate }) {
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack'; list.className = 'stack';
function entriesToClipboardText(entries) {
return entries
.slice()
.reverse()
.map((entry) => {
const level = String(entry.level || 'info').toUpperCase();
const source = String(entry.source || 'ui');
const header = `[${formatTime(entry.ts)}] ${level} ${source}`;
const message = String(entry.message || '');
const details = String(entry.details || '').trim();
return details ? `${header}\n${message}\n${details}` : `${header}\n${message}`;
})
.join('\n\n');
}
async function copyTextToClipboard(text) {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
function renderEntries() { function renderEntries() {
const entries = getAppLogEntries(); const entries = getAppLogEntries();
list.innerHTML = ''; list.innerHTML = '';
@ -85,6 +120,23 @@ export function render({ navigate }) {
} }
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries); controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
const entries = getAppLogEntries();
if (!entries.length) {
status.className = 'status-line';
status.textContent = 'Лог пуст, копировать нечего.';
return;
}
try {
const text = entriesToClipboardText(entries);
await copyTextToClipboard(text);
status.className = 'status-line is-available';
status.textContent = `Записей: ${entries.length} · скопировано`;
} catch (e) {
status.className = 'status-line';
status.textContent = 'Ошибка копирования лога.';
}
});
controls.querySelector('[data-action="clear"]').addEventListener('click', () => { controls.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearAppLogEntries(); clearAppLogEntries();
renderEntries(); renderEntries();

View File

@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { addChatMessage, getChatMessages, authService, state } from '../state.js'; import { addAppLogEntry, addChatMessage, getChatMessages, authService, state } from '../state.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
@ -66,14 +66,35 @@ export function render({ navigate, route }) {
renderLog(log, chatId); renderLog(log, chatId);
try { try {
await authService.sendDirectMessage({ const result = await authService.sendDirectMessage({
login: state.session.login, login: state.session.login,
toLogin: chatId, toLogin: chatId,
text, text,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
}); });
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
message: `Сообщение отправлено для ${chatId}`,
details: {
toLogin: chatId,
messageId: result?.messageId || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
sessionNotFound: Boolean(result?.sessionNotFound),
},
});
} catch (e) { } catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`); addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
addAppLogEntry({
level: 'warn',
source: 'outgoing-dm',
message: 'Ошибка отправки личного сообщения',
details: {
toLogin: chatId,
error: e?.message || 'unknown',
},
});
renderLog(log, chatId); renderLog(log, chatId);
} }
}); });

View File

@ -49,7 +49,7 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-password-view')); loginButton.addEventListener('click', () => navigate('login-password-view'));
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-actions'; actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton); actions.append(cameraButton, loginButton);
const backButton = document.createElement('button'); const backButton = document.createElement('button');

View File

@ -289,6 +289,10 @@
width: min(100%, 320px); width: min(100%, 320px);
} }
.login-actions-wide {
width: 100%;
}
.auth-footer-actions { .auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View File

@ -10,6 +10,8 @@ import utils.config.AppConfig;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.Security;
public final class WebPushSender { public final class WebPushSender {
private static final Logger log = LoggerFactory.getLogger(WebPushSender.class); private static final Logger log = LoggerFactory.getLogger(WebPushSender.class);
@ -17,10 +19,27 @@ public final class WebPushSender {
private WebPushSender() {} private WebPushSender() {}
private static void ensureBouncyCastleProvider() {
if (Security.getProvider("BC") != null) return;
try {
Class<?> providerClass = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider");
Object providerInstance = providerClass.getDeclaredConstructor().newInstance();
if (providerInstance instanceof Provider provider) {
Security.addProvider(provider);
log.info("WebPush: registered crypto provider {}", provider.getName());
return;
}
log.warn("WebPush: class org.bouncycastle.jce.provider.BouncyCastleProvider is not a java.security.Provider");
} catch (Exception e) {
log.warn("WebPush: failed to register BC provider: {}", e.getMessage());
}
}
private static PushService service() throws GeneralSecurityException, JoseException { private static PushService service() throws GeneralSecurityException, JoseException {
if (service != null) return service; if (service != null) return service;
synchronized (WebPushSender.class) { synchronized (WebPushSender.class) {
if (service != null) return service; if (service != null) return service;
ensureBouncyCastleProvider();
AppConfig cfg = AppConfig.getInstance(); AppConfig cfg = AppConfig.getInstance();
String pub = cfg.getStringOrEmpty("webpush.vapid.public"); String pub = cfg.getStringOrEmpty("webpush.vapid.public");
String priv = cfg.getStringOrEmpty("webpush.vapid.private"); String priv = cfg.getStringOrEmpty("webpush.vapid.private");
@ -35,6 +54,9 @@ public final class WebPushSender {
public static boolean sendBase64Payload(String endpoint, String p256dhKey, String authKey, String payloadB64) { public static boolean sendBase64Payload(String endpoint, String p256dhKey, String authKey, String payloadB64) {
try { try {
// Some web-push library code may touch crypto provider while building Notification.
// Register BC before creating any push objects.
ensureBouncyCastleProvider();
Subscription subscription = new Subscription( Subscription subscription = new Subscription(
endpoint, endpoint,
new Subscription.Keys(p256dhKey, authKey) new Subscription.Keys(p256dhKey, authKey)
@ -47,7 +69,7 @@ public final class WebPushSender {
log.warn("WebPush crypto unsupported", e); log.warn("WebPush crypto unsupported", e);
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.warn("WebPush send failed: {}", e.getMessage()); log.warn("WebPush send failed: {}", e.getMessage(), e);
return false; return false;
} }
} }

View File

@ -2,6 +2,10 @@ package test.it;
import test.it.runner.IT_RunAllMain; import test.it.runner.IT_RunAllMain;
import java.io.File;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.Objects; import java.util.Objects;
public class IT_DeployRestartAndRunRemoteMain { public class IT_DeployRestartAndRunRemoteMain {
@ -30,7 +34,9 @@ public class IT_DeployRestartAndRunRemoteMain {
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true"); sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
// 2) upload jar -> .new // 2) upload jar -> .new
validateLocalFatJarOrThrow(LOCAL_JAR);
scpStrict(LOCAL_JAR, REMOTE_JAR + ".new"); scpStrict(LOCAL_JAR, REMOTE_JAR + ".new");
verifyRemoteNewJarOrThrow(REMOTE_JAR + ".new");
// 3) заменить jar атомарно // 3) заменить jar атомарно
sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR)); sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR));
@ -103,4 +109,45 @@ public class IT_DeployRestartAndRunRemoteMain {
try { Thread.sleep(ms); } try { Thread.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
} }
private static void validateLocalFatJarOrThrow(String localJarPath) {
File jar = new File(localJarPath);
if (!jar.isFile()) {
throw new RuntimeException("Local jar not found: " + localJarPath);
}
long size = jar.length();
// В нашем проекте fat-jar обычно ~30+ MB. Маленький (<10 MB) почти точно не fat-jar.
if (size < 10L * 1024L * 1024L) {
throw new RuntimeException("Local jar is too small for fat-jar: " + size + " bytes (" + localJarPath + ")");
}
try (JarFile jf = new JarFile(jar)) {
boolean hasJetty = false;
boolean hasBc = false;
Enumeration<JarEntry> entries = jf.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (!hasJetty && "org/eclipse/jetty/server/Handler.class".equals(name)) hasJetty = true;
if (!hasBc && "org/bouncycastle/jce/provider/BouncyCastleProvider.class".equals(name)) hasBc = true;
if (hasJetty && hasBc) break;
}
if (!hasJetty || !hasBc) {
throw new RuntimeException(
"Local jar doesn't look like fat-jar (missing deps). hasJetty=" + hasJetty + ", hasBC=" + hasBc
);
}
} catch (Exception e) {
throw new RuntimeException("Failed to inspect local jar: " + localJarPath, e);
}
}
private static void verifyRemoteNewJarOrThrow(String remoteJarNewPath) {
// Проверка на сервере до mv: файл существует и не подозрительно маленький.
String cmd = "test -f " + q(remoteJarNewPath) + " && " +
"sz=$(stat -c %s " + q(remoteJarNewPath) + ") && " +
"echo remote_new_size=$sz && test \"$sz\" -ge 10485760";
int code = ssh(cmd);
if (code != 0) {
throw new RuntimeException("Remote uploaded jar is missing or too small: " + remoteJarNewPath);
}
}
} }