14-04-2026
Веб пуш работает. Дальше попробую звонки добавить.
This commit is contained in:
parent
21fbc8ffa0
commit
0b7691bdea
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user