Звонки: расширенная диагностика + экран настроек разработчика + обновление TURN-конфига

This commit is contained in:
AidarKC 2026-05-01 16:39:03 +03:00
parent a2ed41514d
commit e3377a48b3
10 changed files with 479 additions and 296 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.24 client.version=1.2.25
server.version=1.2.24 server.version=1.2.25

View File

@ -43,6 +43,7 @@ import * as profileView from './pages/profile-view.js';
import * as profileEditView from './pages/profile-edit-view.js'; import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js'; import * as walletView from './pages/wallet-view.js';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js';
import * as deviceView from './pages/device-view.js'; import * as deviceView from './pages/device-view.js';
import * as connectDeviceView from './pages/connect-device-view.js'; import * as connectDeviceView from './pages/connect-device-view.js';
@ -79,6 +80,7 @@ const routes = {
'profile-edit-view': profileEditView, 'profile-edit-view': profileEditView,
'wallet-view': walletView, 'wallet-view': walletView,
'settings-view': settingsView, 'settings-view': settingsView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView, 'server-settings-view': serverSettingsView,
'device-view': deviceView, 'device-view': deviceView,
'connect-device-view': connectDeviceView, 'connect-device-view': connectDeviceView,

View File

@ -18,7 +18,7 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Лог приложения', title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('settings-view') }, leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}), }),
); );

View File

@ -0,0 +1,324 @@
import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, state } from '../state.js';
import {
canInstallPwa,
isStandalonePwaMode,
onPwaInstallAvailabilityChange,
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
export const pageMeta = { id: 'developer-settings-view', title: 'Настройки разработчика' };
function clearArweaveJwk(walletCtx) {
if (!walletCtx?.jwk || typeof walletCtx.jwk !== 'object') return;
Object.keys(walletCtx.jwk).forEach((key) => {
walletCtx.jwk[key] = '';
});
walletCtx.jwk = null;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {}) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="settings-dev-avatar-modal">
<div class="modal-card stack settings-dev-avatar-modal-card">
<h3 class="modal-title">Загрузить аватар (Arweave)</h3>
<label class="field-label" for="settings-dev-avatar-login">Логин пользователя</label>
<input class="input" id="settings-dev-avatar-login" type="text" maxlength="60" placeholder="Например: aidar" value="" />
<label class="field-label" for="settings-dev-avatar-file">Файл изображения</label>
<input class="input" id="settings-dev-avatar-file" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед загрузкой изображение будет оптимизировано.</p>
<div class="settings-dev-avatar-meta" id="settings-dev-avatar-meta"></div>
<p class="inline-error settings-dev-avatar-error" id="settings-dev-avatar-error"></p>
<div class="form-actions-grid">
<button class="secondary-btn" id="settings-dev-avatar-cancel" type="button">Закрыть</button>
<button class="primary-btn" id="settings-dev-avatar-upload" type="button">Загрузить</button>
</div>
<div class="card stack settings-dev-avatar-result" id="settings-dev-avatar-result" hidden>
<p class="meta-muted">Transaction ID:</p>
<p class="key-value" id="settings-dev-avatar-txid"></p>
<button class="ghost-btn" id="settings-dev-avatar-copy" type="button">Скопировать TX ID</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('#settings-dev-avatar-modal');
const loginInput = root.querySelector('#settings-dev-avatar-login');
const fileInput = root.querySelector('#settings-dev-avatar-file');
const metaEl = root.querySelector('#settings-dev-avatar-meta');
const errorEl = root.querySelector('#settings-dev-avatar-error');
const cancelBtn = root.querySelector('#settings-dev-avatar-cancel');
const uploadBtn = root.querySelector('#settings-dev-avatar-upload');
const resultCard = root.querySelector('#settings-dev-avatar-result');
const txidEl = root.querySelector('#settings-dev-avatar-txid');
const copyBtn = root.querySelector('#settings-dev-avatar-copy');
if (loginInput instanceof HTMLInputElement) {
loginInput.value = String(walletLogin || '').trim();
}
let isClosed = false;
const closeModal = () => {
if (isClosed) return;
isClosed = true;
root.innerHTML = '';
};
const setError = (text) => {
if (errorEl) errorEl.textContent = String(text || '');
};
const setMeta = (text) => {
if (metaEl) metaEl.textContent = String(text || '');
};
const setTxId = (txId) => {
if (!(resultCard instanceof HTMLElement) || !(txidEl instanceof HTMLElement)) return;
const value = String(txId || '').trim();
txidEl.textContent = value;
resultCard.hidden = !value;
};
modal?.addEventListener('click', (event) => {
if (event.target === modal) closeModal();
});
cancelBtn?.addEventListener('click', closeModal);
copyBtn?.addEventListener('click', async () => {
const txId = String(txidEl?.textContent || '').trim();
if (!txId) return;
try {
await navigator.clipboard.writeText(txId);
setError('');
} catch {
setError('Не удалось скопировать TX ID.');
}
});
uploadBtn?.addEventListener('click', async () => {
setError('');
setTxId('');
const targetLogin = String(loginInput?.value || '').trim();
const file = fileInput?.files?.[0] || null;
if (!targetLogin) {
setError('Введите логин пользователя.');
return;
}
if (!file) {
setError('Выберите файл изображения.');
return;
}
if (!String(walletLogin || '').trim() || !String(storagePwd || '').trim()) {
setError('Нет активной сессии. Войдите заново и повторите.');
return;
}
uploadBtn.disabled = true;
let walletCtx = null;
try {
validateAvatarSourceFile(file);
setMeta('Подготовка изображения...');
const optimized = await prepareAvatarImageFile(file);
setMeta(
`Файл подготовлен: ${formatBytes(optimized.originalSizeBytes)}${formatBytes(optimized.optimizedSizeBytes)} `
+ `(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredDeviceKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
});
setMeta('Загрузка в Arweave...');
const uploaded = await uploadArweaveFile({
gateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: targetLogin },
],
});
const txId = String(uploaded?.id || '').trim();
if (!txId) throw new Error('Пустой Transaction ID');
setMeta('Загрузка завершена.');
setTxId(txId);
} catch (error) {
const message = String(error?.message || '');
if (
message === 'Выберите файл изображения.'
|| message === 'Поддерживаются только JPEG, PNG или WebP.'
|| message === 'Файл слишком большой. Максимум 10 MB.'
|| message === 'Не удалось подготовить изображение.'
) {
setError(message);
} else {
setError('Не удалось загрузить аватар в Arweave.');
}
} finally {
clearArweaveJwk(walletCtx);
uploadBtn.disabled = false;
}
});
}
async function forceUiUpdateNow() {
if (!('serviceWorker' in navigator)) {
window.location.reload();
return;
}
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(async (registration) => {
try { await registration.update(); } catch {}
if (registration.waiting) {
try { registration.waiting.postMessage({ type: 'SKIP_WAITING' }); } catch {}
}
}));
} catch {}
window.setTimeout(() => window.location.reload(), 450);
}
function showClientUpdateHelp() {
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '2) Откройте chrome://settings/siteData и удалите данные для shineup.me.\n'
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '4) Откройте https://shineup.me заново и выполните вход.\n'
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Настройки разработчика',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
card.innerHTML = `
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
`;
const appLogBtn = card.querySelector('#settings-app-log');
const diagnosticsBtn = card.querySelector('#settings-pwa-diagnostics');
const pwaInstallBtn = card.querySelector('#settings-pwa-install');
const uploadAvatarBtn = card.querySelector('#settings-upload-avatar');
const forceUpdateBtn = card.querySelector('#settings-force-ui-update');
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
walletLogin: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
});
forceUpdateHelpBtn?.addEventListener('click', showClientUpdateHelp);
forceUpdateBtn?.addEventListener('click', async () => {
forceUpdateBtn.disabled = true;
try {
addAppLogEntry({
level: 'info',
source: 'ui-update',
message: 'Пользователь запросил принудительное обновление UI',
});
await forceUiUpdateNow();
} finally {
forceUpdateBtn.disabled = false;
}
});
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
return;
}
if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
return;
}
pwaInstallBtn.textContent = 'Как установить PWA';
};
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
syncPwaButtonLabel();
});
syncPwaButtonLabel();
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.cleanup = () => {
unsubscribeInstallAvailability();
};
screen.append(card);
return screen;
}

View File

@ -272,7 +272,7 @@ export function render({ navigate }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Диагностика PWA / Push', title: 'Диагностика PWA / Push',
leftAction: { label: '←', onClick: () => navigate('settings-view') }, leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}), }),
); );

View File

@ -1,18 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js'; import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
import {
canInstallPwa,
isStandalonePwaMode,
onPwaInstallAvailabilityChange,
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' }; export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@ -38,168 +25,6 @@ function formatVersionForUi(rawValue) {
return value; return value;
} }
function clearArweaveJwk(walletCtx) {
if (!walletCtx?.jwk || typeof walletCtx.jwk !== 'object') return;
Object.keys(walletCtx.jwk).forEach((key) => {
walletCtx.jwk[key] = '';
});
walletCtx.jwk = null;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {}) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="settings-dev-avatar-modal">
<div class="modal-card stack settings-dev-avatar-modal-card">
<h3 class="modal-title">Загрузить аватар (Arweave)</h3>
<label class="field-label" for="settings-dev-avatar-login">Логин пользователя</label>
<input class="input" id="settings-dev-avatar-login" type="text" maxlength="60" placeholder="Например: aidar" value="" />
<label class="field-label" for="settings-dev-avatar-file">Файл изображения</label>
<input class="input" id="settings-dev-avatar-file" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед загрузкой изображение будет оптимизировано.</p>
<div class="settings-dev-avatar-meta" id="settings-dev-avatar-meta"></div>
<p class="inline-error settings-dev-avatar-error" id="settings-dev-avatar-error"></p>
<div class="form-actions-grid">
<button class="secondary-btn" id="settings-dev-avatar-cancel" type="button">Закрыть</button>
<button class="primary-btn" id="settings-dev-avatar-upload" type="button">Загрузить</button>
</div>
<div class="card stack settings-dev-avatar-result" id="settings-dev-avatar-result" hidden>
<p class="meta-muted">Transaction ID:</p>
<p class="key-value" id="settings-dev-avatar-txid"></p>
<button class="ghost-btn" id="settings-dev-avatar-copy" type="button">Скопировать TX ID</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('#settings-dev-avatar-modal');
const loginInput = root.querySelector('#settings-dev-avatar-login');
const fileInput = root.querySelector('#settings-dev-avatar-file');
const metaEl = root.querySelector('#settings-dev-avatar-meta');
const errorEl = root.querySelector('#settings-dev-avatar-error');
const cancelBtn = root.querySelector('#settings-dev-avatar-cancel');
const uploadBtn = root.querySelector('#settings-dev-avatar-upload');
const resultCard = root.querySelector('#settings-dev-avatar-result');
const txidEl = root.querySelector('#settings-dev-avatar-txid');
const copyBtn = root.querySelector('#settings-dev-avatar-copy');
if (loginInput instanceof HTMLInputElement) {
loginInput.value = String(walletLogin || '').trim();
}
let isClosed = false;
const closeModal = () => {
if (isClosed) return;
isClosed = true;
root.innerHTML = '';
};
const setError = (text) => {
if (errorEl) errorEl.textContent = String(text || '');
};
const setMeta = (text) => {
if (metaEl) metaEl.textContent = String(text || '');
};
const setTxId = (txId) => {
if (!(resultCard instanceof HTMLElement) || !(txidEl instanceof HTMLElement)) return;
const value = String(txId || '').trim();
txidEl.textContent = value;
resultCard.hidden = !value;
};
modal?.addEventListener('click', (event) => {
if (event.target === modal) closeModal();
});
cancelBtn?.addEventListener('click', closeModal);
copyBtn?.addEventListener('click', async () => {
const txId = String(txidEl?.textContent || '').trim();
if (!txId) return;
try {
await navigator.clipboard.writeText(txId);
setError('');
} catch {
setError('Не удалось скопировать TX ID.');
}
});
uploadBtn?.addEventListener('click', async () => {
setError('');
setTxId('');
const targetLogin = String(loginInput?.value || '').trim();
const file = fileInput?.files?.[0] || null;
if (!targetLogin) {
setError('Введите логин пользователя.');
return;
}
if (!file) {
setError('Выберите файл изображения.');
return;
}
if (!String(walletLogin || '').trim() || !String(storagePwd || '').trim()) {
setError('Нет активной сессии. Войдите заново и повторите.');
return;
}
uploadBtn.disabled = true;
let walletCtx = null;
try {
validateAvatarSourceFile(file);
setMeta('Подготовка изображения...');
const optimized = await prepareAvatarImageFile(file);
setMeta(
`Файл подготовлен: ${formatBytes(optimized.originalSizeBytes)}${formatBytes(optimized.optimizedSizeBytes)} ` +
`(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredDeviceKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
});
setMeta('Загрузка в Arweave...');
const uploaded = await uploadArweaveFile({
gateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: targetLogin },
],
});
const txId = String(uploaded?.id || '').trim();
if (!txId) throw new Error('Пустой Transaction ID');
setMeta('Загрузка завершена.');
setTxId(txId);
} catch (error) {
const message = String(error?.message || '');
if (
message === 'Выберите файл изображения.'
|| message === 'Поддерживаются только JPEG, PNG или WebP.'
|| message === 'Файл слишком большой. Максимум 10 MB.'
|| message === 'Не удалось подготовить изображение.'
) {
setError(message);
} else {
setError('Не удалось загрузить аватар в Arweave.');
}
} finally {
clearArweaveJwk(walletCtx);
uploadBtn.disabled = false;
}
});
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -218,79 +43,16 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-device">Устройства</button> <button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button> <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-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button> <button class="text-btn" type="button" id="settings-developer">Настройки разработчика</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button> <button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`; `;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-force-update-help').addEventListener('click', () => { card.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '2) Откройте chrome://settings/siteData и удалите данные для shineup.me.\n'
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '4) Откройте https://shineup.me заново и выполните вход.\n'
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
});
const signOutBtn = card.querySelector('#settings-signout'); const signOutBtn = card.querySelector('#settings-signout');
const developerCard = document.createElement('div');
developerCard.className = 'card stack settings-developer-card';
developerCard.innerHTML = `
<button class="text-btn" type="button" id="settings-dev-toggle">Для разработчиков</button>
<div class="stack settings-developer-panel" id="settings-dev-panel" hidden>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
</div>
`;
const developerToggleBtn = developerCard.querySelector('#settings-dev-toggle');
const developerPanel = developerCard.querySelector('#settings-dev-panel');
const appLogBtn = developerCard.querySelector('#settings-app-log');
const diagnosticsBtn = developerCard.querySelector('#settings-pwa-diagnostics');
const pwaInstallBtn = developerCard.querySelector('#settings-pwa-install');
const uploadAvatarBtn = developerCard.querySelector('#settings-upload-avatar');
developerToggleBtn?.addEventListener('click', () => {
const isHidden = developerPanel?.hidden !== false;
if (developerPanel) developerPanel.hidden = !isHidden;
if (developerToggleBtn) {
developerToggleBtn.textContent = isHidden ? 'Для разработчиков ▲' : 'Для разработчиков ▼';
}
});
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
walletLogin: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
});
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 () => { signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm( const confirmed = window.confirm(
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?' 'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
@ -302,7 +64,7 @@ export function render({ navigate }) {
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'session', source: 'session',
message: `Запрошено завершение текущей сессии: ${state.session.sessionId || 'unknown'}`, message: 'Запрошено завершение текущей сессии',
}); });
await closeCurrentSessionAndSignOut({ await closeCurrentSessionAndSignOut({
infoMessage: 'Сеанс завершён. Выполните вход заново.', infoMessage: 'Сеанс завершён. Выполните вход заново.',
@ -312,45 +74,6 @@ export function render({ navigate }) {
} }
}); });
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();
}
});
const versionCard = document.createElement('div'); const versionCard = document.createElement('div');
versionCard.className = 'card stack'; versionCard.className = 'card stack';
@ -394,12 +117,10 @@ export function render({ navigate }) {
} }
})(); })();
screen.append(card);
screen.cleanup = () => { screen.cleanup = () => {
isDisposed = true; isDisposed = true;
unsubscribeInstallAvailability();
}; };
screen.append(card);
screen.append(versionCard); screen.append(versionCard);
screen.append(developerCard);
return screen; return screen;
} }

View File

@ -100,6 +100,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'profile-edit-view' || pageId === 'profile-edit-view' ||
pageId === 'wallet-view' || pageId === 'wallet-view' ||
pageId === 'settings-view' || pageId === 'settings-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' || pageId === 'server-settings-view' ||
pageId === 'device-view' || pageId === 'device-view' ||
pageId === 'connect-device-view' || pageId === 'connect-device-view' ||

View File

@ -284,20 +284,106 @@ function buildCallFactsJson(call, extra = {}) {
} }
} }
function buildCallFactsLine(call, extra = {}) {
const pc = call?.pc || null;
const facts = {
callId: call?.callId || '',
peerLogin: call?.peerLogin || '',
remoteSessionId: call?.remoteSessionId || '',
direction: call?.direction || '',
phase: call?.phase || '',
statusText: call?.statusText || '',
startedAtMs: Number(call?.startedAtMs || 0),
startedAtIso: toIsoTs(call?.startedAtMs),
connectedAtMs: Number(call?.connectedAtMs || 0),
connectedAtIso: toIsoTs(call?.connectedAtMs),
routeLabel: call?.connectionRouteLabel || '',
routeDetails: call?.connectionRouteDetails || '',
pcConnectionState: pc?.connectionState || '',
pcIceConnectionState: pc?.iceConnectionState || '',
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
...extra,
};
return Object.entries(facts)
.map(([k, v]) => `${k}=${String(v ?? '').replace(/,/g, ';')}`)
.join(', ');
}
function getCallDiagnosticsContext(call) {
const pc = call?.pc || null;
const nav = typeof navigator !== 'undefined' ? navigator : null;
const conn = nav?.connection || nav?.mozConnection || nav?.webkitConnection || null;
const permissionsApiAvailable = typeof nav?.permissions?.query === 'function';
const mediaDevicesAvailable = Boolean(nav?.mediaDevices?.getUserMedia);
const online = typeof nav?.onLine === 'boolean' ? nav.onLine : null;
const visibilityState = typeof document !== 'undefined' ? String(document.visibilityState || '') : '';
const pageFocused = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
? Boolean(document.hasFocus())
: false;
const localTracks = call?.localStream?.getTracks?.() || [];
const localAudioTracks = call?.localStream?.getAudioTracks?.() || [];
const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length;
const transceiversCount = pc?.getTransceivers?.()?.length || 0;
const sendersCount = pc?.getSenders?.()?.length || 0;
const receiversCount = pc?.getReceivers?.()?.length || 0;
const iceGatheringState = pc?.iceGatheringState || '';
const currentLocalDescType = pc?.localDescription?.type || '';
const currentRemoteDescType = pc?.remoteDescription?.type || '';
return {
remoteSessionIdPresent: Boolean(call?.remoteSessionId),
callExistsInStore: calls.has(String(call?.callId || '')),
browserOnline: online === null ? '' : String(online),
documentVisibilityState: visibilityState,
pageFocused,
userAgent: typeof nav?.userAgent === 'string' ? nav.userAgent : '',
platform: typeof nav?.platform === 'string' ? nav.platform : '',
language: typeof nav?.language === 'string' ? nav.language : '',
permissionsApiAvailable,
mediaDevicesApiAvailable: mediaDevicesAvailable,
connectionType: String(conn?.type || ''),
effectiveConnectionType: String(conn?.effectiveType || ''),
networkRttMs: Number(conn?.rtt || 0),
networkDownlinkMbps: Number(conn?.downlink || 0),
saveData: conn?.saveData === true,
localTrackCount: localTracks.length,
localAudioTracksCount: localAudioTracks.length,
localAudioTracksEnabledCount: enabledLocalAudioTracks,
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
hasPeerConnection: Boolean(pc),
pcConnectionState: pc?.connectionState || '',
pcIceConnectionState: pc?.iceConnectionState || '',
pcIceGatheringState: iceGatheringState,
pcSignalingState: pc?.signalingState || '',
pcCanTrickleIceCandidates: pc?.canTrickleIceCandidates === null || pc?.canTrickleIceCandidates === undefined
? ''
: String(pc?.canTrickleIceCandidates),
localDescriptionType: currentLocalDescType,
remoteDescriptionType: currentRemoteDescType,
pcTransceiversCount: transceiversCount,
pcSendersCount: sendersCount,
pcReceiversCount: receiversCount,
};
}
async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) { async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) {
if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return; if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return;
try { try {
const valueJson = buildCallFactsJson(call, { const diagnostics = getCallDiagnosticsContext(call);
const valueLine = buildCallFactsLine(call, {
eventType: String(eventType || '').trim(), eventType: String(eventType || '').trim(),
eventCode: String(eventCode || '').trim(), eventCode: String(eventCode || '').trim(),
reason: String(reason || '').trim(), reason: String(reason || '').trim(),
reportedAtMs: nowMs(), reportedAtMs: nowMs(),
reportedAtIso: toIsoTs(nowMs()), reportedAtIso: toIsoTs(nowMs()),
...diagnostics,
...extraFacts, ...extraFacts,
}); });
await authService.sendCallDeliveryReport({ await authService.sendCallDeliveryReport({
type: String(eventType || '').trim(), type: String(eventType || '').trim(),
value: valueJson, value: valueLine,
}); });
} catch {} } catch {}
} }
@ -569,13 +655,23 @@ async function finalizeCall(call, {
const reasonText = debugReason || localReasonCode; const reasonText = debugReason || localReasonCode;
if (String(localReasonCode || '') !== 'completed') { if (String(localReasonCode || '') !== 'completed') {
const failureStage = call.phase || '';
const failureContext = {
failureStage,
connectedBeforeFailure: Boolean(call.connectedAtMs),
};
if (call.direction === 'out') { if (call.direction === 'out') {
await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText); await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText, failureContext);
} else if (call.direction === 'in') { } else if (call.direction === 'in') {
await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText); await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText, failureContext);
}
if (String(localReasonCode || '') === 'busy') {
await sendCallDeliveryReport(call, 'call_busy', 'call_busy', reasonText, failureContext);
} else if (String(localReasonCode || '') === 'declined') {
await sendCallDeliveryReport(call, 'call_declined', 'call_declined', reasonText, failureContext);
} }
if (String(localReasonCode || '') === 'error') { if (String(localReasonCode || '') === 'error') {
await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText); await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText, failureContext);
} }
} }
@ -1081,7 +1177,12 @@ export async function handleIncomingCallSignal(evt) {
} }
if (type === TYPES.DECLINE_BUSY) { if (type === TYPES.DECLINE_BUSY) {
await finalizeCall(call, { localReasonCode: 'busy', debugReason: 'decline_or_busy' }); const normalized = data.trim().toLowerCase();
const isDeclined = normalized === 'decline' || normalized === 'declined';
await finalizeCall(call, {
localReasonCode: isDeclined ? 'declined' : 'busy',
debugReason: isDeclined ? 'declined_by_remote' : 'busy_by_remote',
});
return; return;
} }

View File

@ -36,11 +36,16 @@ public class Net_CallDeliveryReport_Handler implements JsonMessageHandler {
long serverTs = System.currentTimeMillis(); long serverTs = System.currentTimeMillis();
String line = String.format( String line = String.format(
Locale.ROOT, Locale.ROOT,
"%s | type=%s | login=%s | remote=%s | value=%s%n", "%s | type=%s | op=%s | requestId=%s | login=%s | sessionId=%s | authenticated=%s | remote=%s | userAgent=%s | value=%s%n",
Instant.ofEpochMilli(serverTs), Instant.ofEpochMilli(serverTs),
clip(req.getType(), 80), clip(req.getType(), 80),
clip(req.getOp(), 80),
clip(req.getRequestId(), 120),
clip(ctx != null ? ctx.getLogin() : "", 80), clip(ctx != null ? ctx.getLogin() : "", 80),
clip(ctx != null ? ctx.getSessionId() : "", 160),
ctx != null && ctx.isAuthenticatedUser(),
clip(remoteAddress(ctx), 200), clip(remoteAddress(ctx), 200),
clip(userAgent(ctx), 300),
clip(req.getValue(), 8000) clip(req.getValue(), 8000)
); );
@ -88,6 +93,18 @@ public class Net_CallDeliveryReport_Handler implements JsonMessageHandler {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
private static String userAgent(ConnectionContext ctx) {
if (ctx == null) return "";
Session ws = ctx.getWsSession();
if (ws == null) return "";
try {
String value = ws.getUpgradeRequest() != null ? ws.getUpgradeRequest().getHeader("User-Agent") : "";
return safe(value);
} catch (Exception ignored) {
return "";
}
}
private static String clip(String value, int maxLen) { private static String clip(String value, int maxLen) {
String cleaned = safe(value).replace('\n', ' ').replace('\r', ' '); String cleaned = safe(value).replace('\n', ' ').replace('\r', ' ');
if (cleaned.length() <= maxLen) return cleaned; if (cleaned.length() <= maxLen) return cleaned;

View File

@ -12,7 +12,7 @@ server.info.physicalRegion=
server.info.description= server.info.description=
server.info.origin= server.info.origin=
server.info.extraInfo= server.info.extraInfo=
server.ui.indexPath=/home/user/docker/caddyFile/sites/shine-UI/index.html server.ui.indexPath=/home/player/SHiNE/shine-UI/index.html
server.ui.buildHash= server.ui.buildHash=
# Web Push (VAPID) # Web Push (VAPID)
@ -37,6 +37,23 @@ call.ice.turn.sharedSecret=
call.ice.turn.username= call.ice.turn.username=
call.ice.turn.password= call.ice.turn.password=
# ------------------------------------------------------------
# Несколько TURN-серверов (рекомендуемый режим)
# Каждый блок описывает один TURN-узел. Новые узлы добавляются по индексу.
# Приоритет авторизации на узел: sharedSecret -> статические username/password.
# ------------------------------------------------------------
call.ice.turn.servers.1.id=vps-05
call.ice.turn.servers.1.urls=turn:45.136.124.227:3478?transport=udp,turn:45.136.124.227:3478?transport=tcp
call.ice.turn.servers.1.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
call.ice.turn.servers.1.username=
call.ice.turn.servers.1.password=
call.ice.turn.servers.2.id=promo-node-93
call.ice.turn.servers.2.urls=turn:93.170.12.154:3478?transport=udp,turn:93.170.12.154:3478?transport=tcp
call.ice.turn.servers.2.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
call.ice.turn.servers.2.username=
call.ice.turn.servers.2.password=
# ------------------------------------------------------------ # ------------------------------------------------------------
# Временные debug HTTP API для тестирования соединений # Временные debug HTTP API для тестирования соединений
# true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token) # true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token)