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); + } + } +}