diff --git a/build.gradle b/build.gradle
index 2333809..a7642f8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -188,6 +188,25 @@ tasks.register('deployServer', JavaExec) {
dependsOn testClasses
}
+tasks.register('deployServerNoCleanNoTests', JavaExec) {
+ group = "!!deployment"
+ description = "Build → upload to server → restart service (no data clean, no IT tests)"
+
+ classpath = sourceSets.test.runtimeClasspath
+ mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
+
+ // можно переопределить при запуске:
+ // ./gradlew deployServerNoCleanNoTests -Dit.remoteHost=...
+ dependsOn shadowJar
+ systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
+ systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
+ systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
+ systemProperty "it.service", System.getProperty("it.service", "shine-server")
+ systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
+
+ dependsOn testClasses
+}
+
tasks.register('deployWEB', Exec) {
group = "!!deployment"
description = "Deploy WEB via deploy_shine-PWA.sh"
diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js
index cc6f322..11df433 100644
--- a/shine-UI/firebase-messaging-sw.js
+++ b/shine-UI/firebase-messaging-sw.js
@@ -53,3 +53,25 @@ self.addEventListener('push', (event) => {
}),
]));
});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification?.close();
+
+ event.waitUntil((async () => {
+ const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const existing = allClients.find((client) => {
+ try {
+ return client.url.includes('/index.html') || client.url.endsWith('/');
+ } catch {
+ return false;
+ }
+ });
+
+ if (existing) {
+ await existing.focus();
+ return;
+ }
+
+ await self.clients.openWindow('./index.html');
+ })());
+});
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 076846a..209c696 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -1,6 +1,7 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
+import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { handleIncomingCallInvite, handleIncomingCallSignal } from './services/call-service.js';
import {
@@ -96,6 +97,7 @@ let pingIntervalId = null;
let sessionRuntimeStarted = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
+initPwaInstallPromptHandling();
function showGlobalErrorAlert(title, details = {}) {
const lines = [title];
diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js
index 4d575dc..dc1be71 100644
--- a/shine-UI/js/pages/settings-view.js
+++ b/shine-UI/js/pages/settings-view.js
@@ -1,4 +1,12 @@
import { renderHeader } from '../components/header.js';
+import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js';
+import {
+ canInstallPwa,
+ isStandalonePwaMode,
+ onPwaInstallAvailabilityChange,
+ promptPwaInstall,
+} from '../services/pwa-install-service.js';
+import { initPwaPush } from '../services/pwa-push-service.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -20,6 +28,8 @@ export function render({ navigate }) {
+
+
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
@@ -27,6 +37,89 @@ export function render({ navigate }) {
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
+ const signOutBtn = card.querySelector('#settings-signout');
+ const pwaInstallBtn = card.querySelector('#settings-pwa-install');
+
+ const syncPwaButtonLabel = () => {
+ if (isStandalonePwaMode()) {
+ pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
+ return;
+ }
+ if (canInstallPwa()) {
+ pwaInstallBtn.textContent = 'Зарегистрировать PWA';
+ return;
+ }
+ pwaInstallBtn.textContent = 'Как установить PWA';
+ };
+
+ const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
+ syncPwaButtonLabel();
+ });
+ syncPwaButtonLabel();
+
+ signOutBtn.addEventListener('click', async () => {
+ const confirmed = window.confirm(
+ 'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
+ );
+ if (!confirmed) return;
+
+ signOutBtn.disabled = true;
+ try {
+ addAppLogEntry({
+ level: 'info',
+ source: 'session',
+ message: `Запрошено завершение текущей сессии: ${state.session.sessionId || 'unknown'}`,
+ });
+ await closeCurrentSessionAndSignOut({
+ infoMessage: 'Сеанс завершён. Выполните вход заново.',
+ });
+ } finally {
+ signOutBtn.disabled = false;
+ }
+ });
+
+ pwaInstallBtn.addEventListener('click', async () => {
+ pwaInstallBtn.disabled = true;
+ try {
+ await initPwaPush({
+ authService,
+ onLog: (entry) => addAppLogEntry(entry),
+ });
+
+ if (canInstallPwa()) {
+ const result = await promptPwaInstall();
+ const accepted = result.outcome === 'accepted';
+ addAppLogEntry({
+ level: 'info',
+ source: 'pwa-install',
+ message: accepted ? 'Пользователь принял установку PWA' : 'Пользователь отклонил установку PWA',
+ details: { outcome: result.outcome || 'unknown' },
+ });
+ if (accepted) {
+ window.alert('Установка PWA подтверждена. Проверьте приложение на главном экране устройства.');
+ }
+ } else if (!isStandalonePwaMode()) {
+ window.alert('Для установки откройте меню браузера и выберите "Установить приложение" или "Добавить на главный экран".');
+ } else {
+ window.alert('PWA уже установлено. WebPush перерегистрирован.');
+ }
+ } catch (error) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'pwa-install',
+ message: 'Не удалось зарегистрировать PWA/WebPush',
+ details: { error: error?.message || 'unknown' },
+ });
+ window.alert(`Ошибка регистрации PWA: ${error?.message || 'unknown'}`);
+ } finally {
+ pwaInstallBtn.disabled = false;
+ syncPwaButtonLabel();
+ }
+ });
+
screen.append(card);
+ screen.cleanup = () => {
+ unsubscribeInstallAvailability();
+ };
return screen;
}
diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js
index 70e42ff..dadc025 100644
--- a/shine-UI/js/services/message-store.js
+++ b/shine-UI/js/services/message-store.js
@@ -48,3 +48,12 @@ export async function listStoredMessages() {
req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
}));
}
+
+export async function clearStoredMessages() {
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(DB_NAME);
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
+ request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
+ });
+}
diff --git a/shine-UI/js/services/pwa-install-service.js b/shine-UI/js/services/pwa-install-service.js
new file mode 100644
index 0000000..878d486
--- /dev/null
+++ b/shine-UI/js/services/pwa-install-service.js
@@ -0,0 +1,66 @@
+let initialized = false;
+let deferredInstallPrompt = null;
+const listeners = new Set();
+
+function notifyAvailability() {
+ const canInstall = Boolean(deferredInstallPrompt);
+ listeners.forEach((listener) => {
+ try {
+ listener(canInstall);
+ } catch {
+ // ignore listener errors
+ }
+ });
+}
+
+export function initPwaInstallPromptHandling() {
+ if (initialized) return;
+ initialized = true;
+
+ window.addEventListener('beforeinstallprompt', (event) => {
+ event.preventDefault();
+ deferredInstallPrompt = event;
+ notifyAvailability();
+ });
+
+ window.addEventListener('appinstalled', () => {
+ deferredInstallPrompt = null;
+ notifyAvailability();
+ });
+}
+
+export function canInstallPwa() {
+ return Boolean(deferredInstallPrompt);
+}
+
+export function isStandalonePwaMode() {
+ return (
+ window.matchMedia?.('(display-mode: standalone)')?.matches ||
+ window.navigator?.standalone === true
+ );
+}
+
+export async function promptPwaInstall() {
+ if (!deferredInstallPrompt) {
+ return { supported: false, outcome: '' };
+ }
+
+ const promptEvent = deferredInstallPrompt;
+ deferredInstallPrompt = null;
+ notifyAvailability();
+
+ await promptEvent.prompt();
+ const choice = await promptEvent.userChoice;
+ return {
+ supported: true,
+ outcome: String(choice?.outcome || ''),
+ };
+}
+
+export function onPwaInstallAvailabilityChange(handler) {
+ if (typeof handler !== 'function') return () => {};
+ listeners.add(handler);
+ return () => {
+ listeners.delete(handler);
+ };
+}
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index f7301b8..f596dfc 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -1,11 +1,15 @@
-import { chatMessages, wallet } from './mock-data.js';
+import { wallet } from './mock-data.js';
import { AuthService } from './services/auth-service.js';
import { clearClientAuthData } from './services/key-vault.js';
-import { listStoredMessages, putStoredMessage } from './services/message-store.js';
+import { clearStoredMessages, listStoredMessages, putStoredMessage } from './services/message-store.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
+const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
+const CHANNEL_NOTIFY_KEY = 'shine-channels-notify-v1';
+const CHANNELS_DEMO_KEY = 'shine-channels-demo';
+const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MAX_APP_LOG_ENTRIES = 500;
const INVALID_SESSION_CODES = new Set([
'NOT_AUTHENTICATED',
@@ -121,13 +125,36 @@ function clearStoredSession() {
}
}
+function clearBrowserClientData() {
+ const localKeys = [
+ SESSION_STORAGE_KEY,
+ REACTIONS_STORAGE_KEY,
+ WEB_PUSH_SUBSCRIPTION_KEY,
+ CHANNEL_NOTIFY_KEY,
+ CHANNELS_DEMO_KEY,
+ ];
+ localKeys.forEach((key) => {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+ });
+
+ try {
+ sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY);
+ } catch {
+ // ignore
+ }
+}
+
function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null;
const storedReactions = loadStoredReactions();
const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER;
return {
- chats: clone(chatMessages),
+ chats: {},
contacts: [],
appLog: [],
incomingDedup: {},
@@ -504,9 +531,16 @@ export async function refreshSessions() {
function resetStateForSignedOut() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
+ state.contacts = next.contacts;
+ state.appLog = next.appLog;
+ state.incomingDedup = next.incomingDedup;
+ state.knownMessageKeys = next.knownMessageKeys;
+ state.outgoingTempSeq = next.outgoingTempSeq;
state.notificationsTab = next.notificationsTab;
+ state.pageLabelCollapsed = next.pageLabelCollapsed;
state.session = next.session;
state.startHint = next.startHint;
+ state.entrySettings = next.entrySettings;
state.registrationDraft = next.registrationDraft;
state.loginDraft = next.loginDraft;
state.registrationPayment = next.registrationPayment;
@@ -522,21 +556,48 @@ function resetStateForSignedOut() {
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
clearStoredSession();
+ clearBrowserClientData();
resetStateForSignedOut();
authService.close();
try {
- await clearClientAuthData();
+ await Promise.all([
+ clearClientAuthData(),
+ clearStoredMessages(),
+ ]);
} catch {
// ignore cleanup errors in prototype mode
}
if (infoMessage) {
state.startHint = infoMessage;
}
+ try {
+ await authService.reconnect(state.entrySettings.shineServer);
+ } catch {
+ // ignore reconnect errors on sign out
+ }
if (onSessionReset) {
onSessionReset();
}
}
+export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
+ const currentSessionId = String(state.session.sessionId || '').trim();
+ try {
+ if (state.session.isAuthorized && currentSessionId) {
+ await authService.closeSession(currentSessionId);
+ }
+ } catch (error) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'session',
+ message: 'Не удалось завершить текущую сессию на сервере',
+ details: { sessionId: currentSessionId, error: error?.message || 'unknown' },
+ });
+ }
+
+ await terminateCurrentSession({ infoMessage });
+}
+
export function refreshRegistrationBalance() {
const next = (0.005 + Math.random() * 0.03).toFixed(4);
state.registrationPayment.balanceSOL = next;
diff --git a/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java b/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java
new file mode 100644
index 0000000..ca0adc9
--- /dev/null
+++ b/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java
@@ -0,0 +1,128 @@
+package test.it;
+
+import java.io.File;
+import java.util.Enumeration;
+import java.util.Objects;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+public class IT_DeployRestartNoCleanNoTestsMain {
+
+ // ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
+ private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
+ private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
+
+ private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
+ private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
+
+ private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
+ private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
+
+ public static void main(String[] args) {
+ // 1) stop service на сервере
+ sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
+
+ // 2) upload jar -> .new
+ validateLocalFatJarOrThrow(LOCAL_JAR);
+ sshStrict("mkdir -p " + q(REMOTE_DIR));
+ scpStrict(LOCAL_JAR, REMOTE_JAR + ".new");
+ verifyRemoteNewJarOrThrow(REMOTE_JAR + ".new");
+
+ // 3) заменить jar атомарно
+ sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR));
+
+ // 4) start service
+ sshStrict("sudo systemctl start " + SERVICE_NAME);
+
+ // 5) дождаться поднятия
+ waitRemotePort7070();
+
+ System.out.println("deploy_no_clean_no_tests_done");
+ }
+
+ private static void waitRemotePort7070() {
+ for (int i = 0; i < 50; i++) {
+ int code = ssh("ss -ltnp | grep -q ':7070'");
+ if (code == 0) return;
+ sleepMs(200);
+ }
+ throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST);
+ }
+
+ private static void sshStrict(String remoteCmd) {
+ int code = ssh(remoteCmd);
+ if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd);
+ }
+
+ private static int ssh(String remoteCmd) {
+ String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd));
+ return sh(cmd);
+ }
+
+ private static void scpStrict(String local, String remote) {
+ Objects.requireNonNull(local);
+ Objects.requireNonNull(remote);
+ int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote));
+ if (code != 0) throw new RuntimeException("SCP failed (" + code + ")");
+ }
+
+ private static int sh(String cmd) {
+ try {
+ Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start();
+ return p.waitFor();
+ } catch (Exception e) {
+ throw new RuntimeException("Command error: " + cmd, e);
+ }
+ }
+
+ private static String q(String s) {
+ return "'" + s.replace("'", "'\"'\"'") + "'";
+ }
+
+ private static void sleepMs(long ms) {
+ 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();
+ 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) {
+ 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);
+ }
+ }
+}