Добавить деплой без clean/тестов и доработки PWA/сессии в UI
This commit is contained in:
parent
bec1d08757
commit
2d48ae7a16
19
build.gradle
19
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"
|
||||
|
||||
@ -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');
|
||||
})());
|
||||
});
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 }) {
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-install">Зарегистрировать PWA</button>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
66
shine-UI/js/services/pwa-install-service.js
Normal file
66
shine-UI/js/services/pwa-install-service.js
Normal file
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
128
src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java
Normal file
128
src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java
Normal file
@ -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<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) {
|
||||
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