diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 5585e92..a159d36 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -292,7 +292,7 @@ async function ensureSessionRuntimeStarted() {
pingIntervalId = window.setInterval(async () => {
if (!state.session.isAuthorized) return;
try {
- await authService.ws.request('Ping', { timeMs: Date.now() });
+ await authService.ws.request('Ping', { ts: Date.now() });
} catch {
// silent keep-alive
}
diff --git a/shine-UI/js/pages/app-log-view.js b/shine-UI/js/pages/app-log-view.js
index b471131..be3b765 100644
--- a/shine-UI/js/pages/app-log-view.js
+++ b/shine-UI/js/pages/app-log-view.js
@@ -24,10 +24,12 @@ export function render({ navigate }) {
const controls = document.createElement('div');
controls.className = 'card row';
- controls.style.justifyContent = 'space-between';
+ controls.style.justifyContent = 'flex-start';
controls.style.gap = '8px';
+ controls.style.flexWrap = 'wrap';
controls.innerHTML = `
+
`;
@@ -38,6 +40,39 @@ export function render({ navigate }) {
const list = document.createElement('div');
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() {
const entries = getAppLogEntries();
list.innerHTML = '';
@@ -85,6 +120,23 @@ export function render({ navigate }) {
}
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', () => {
clearAppLogEntries();
renderEntries();
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
index b1db188..43ab1fb 100644
--- a/shine-UI/js/pages/chat-view.js
+++ b/shine-UI/js/pages/chat-view.js
@@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.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: 'Чат' };
@@ -66,14 +66,35 @@ export function render({ navigate, route }) {
renderLog(log, chatId);
try {
- await authService.sendDirectMessage({
+ const result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text,
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) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
+ addAppLogEntry({
+ level: 'warn',
+ source: 'outgoing-dm',
+ message: 'Ошибка отправки личного сообщения',
+ details: {
+ toLogin: chatId,
+ error: e?.message || 'unknown',
+ },
+ });
renderLog(log, chatId);
}
});
diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js
index ab5f68f..0a6c30f 100644
--- a/shine-UI/js/pages/login-view.js
+++ b/shine-UI/js/pages/login-view.js
@@ -49,7 +49,7 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-password-view'));
const actions = document.createElement('div');
- actions.className = 'auth-actions';
+ actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton);
const backButton = document.createElement('button');
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 35006c0..59b5ce3 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -289,6 +289,10 @@
width: min(100%, 320px);
}
+.login-actions-wide {
+ width: 100%;
+}
+
.auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java
index 2de3afe..676e34c 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java
@@ -10,6 +10,8 @@ import utils.config.AppConfig;
import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+import java.security.Security;
public final class WebPushSender {
private static final Logger log = LoggerFactory.getLogger(WebPushSender.class);
@@ -17,10 +19,27 @@ public final class 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 {
if (service != null) return service;
synchronized (WebPushSender.class) {
if (service != null) return service;
+ ensureBouncyCastleProvider();
AppConfig cfg = AppConfig.getInstance();
String pub = cfg.getStringOrEmpty("webpush.vapid.public");
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) {
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(
endpoint,
new Subscription.Keys(p256dhKey, authKey)
@@ -47,7 +69,7 @@ public final class WebPushSender {
log.warn("WebPush crypto unsupported", e);
return false;
} catch (Exception e) {
- log.warn("WebPush send failed: {}", e.getMessage());
+ log.warn("WebPush send failed: {}", e.getMessage(), e);
return false;
}
}
diff --git a/src/test/java/test/it/IT_DeployRestartAndRunRemoteMain.java b/src/test/java/test/it/IT_DeployRestartAndRunRemoteMain.java
index de738b0..2ed0afe 100644
--- a/src/test/java/test/it/IT_DeployRestartAndRunRemoteMain.java
+++ b/src/test/java/test/it/IT_DeployRestartAndRunRemoteMain.java
@@ -2,6 +2,10 @@ package test.it;
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;
public class IT_DeployRestartAndRunRemoteMain {
@@ -30,7 +34,9 @@ public class IT_DeployRestartAndRunRemoteMain {
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
// 2) upload jar -> .new
+ validateLocalFatJarOrThrow(LOCAL_JAR);
scpStrict(LOCAL_JAR, REMOTE_JAR + ".new");
+ verifyRemoteNewJarOrThrow(REMOTE_JAR + ".new");
// 3) заменить jar атомарно
sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR));
@@ -103,4 +109,45 @@ public class IT_DeployRestartAndRunRemoteMain {
try { Thread.sleep(ms); }
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 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);
+ }
+ }
}