702 lines
22 KiB
PHP
702 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* @var array $_
|
|
*/
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="theme-color" content="#0b67d9">
|
|
<link rel="manifest" href="<?php p($_['manifestUrl']); ?>" crossorigin="use-credentials">
|
|
<link rel="icon" href="<?php p($_['iconUrl']); ?>">
|
|
<link rel="apple-touch-icon" href="<?php p($_['iconUrl']); ?>">
|
|
<title><?php p($_['instanceName']); ?> - Mobile Readiness</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg0: #050d19;
|
|
--bg1: #0b1f3b;
|
|
--card: rgba(8, 18, 34, 0.86);
|
|
--line: rgba(88, 169, 255, 0.25);
|
|
--text: #e7f1ff;
|
|
--muted: #9bb8da;
|
|
--accent: #2bb6ff;
|
|
--accent2: #0a7bff;
|
|
--ok: #3fd987;
|
|
--warn: #ffc15c;
|
|
--bad: #ff6b6b;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(1200px 600px at 18% 20%, rgba(37, 138, 255, 0.18), transparent 70%),
|
|
radial-gradient(900px 500px at 85% 15%, rgba(43, 182, 255, 0.12), transparent 70%),
|
|
linear-gradient(180deg, var(--bg1), var(--bg0));
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 18px;
|
|
}
|
|
|
|
.shell {
|
|
width: min(900px, 100%);
|
|
border: 1px solid var(--line);
|
|
background: var(--card);
|
|
border-radius: 18px;
|
|
padding: 22px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.header img {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 1.45rem;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0 0 8px;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
p {
|
|
margin: 8px 0;
|
|
color: var(--muted);
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--line);
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.button.primary {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
border-color: transparent;
|
|
color: #031321;
|
|
}
|
|
|
|
.button[disabled] {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
.status-grid {
|
|
margin-top: 18px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.status-card,
|
|
.note {
|
|
padding: 12px 14px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
.status-title {
|
|
font-size: 0.86rem;
|
|
font-weight: 650;
|
|
margin-bottom: 4px;
|
|
color: #bdd8f6;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.status-value {
|
|
font-size: 0.98rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.ok { color: var(--ok); }
|
|
.warn { color: var(--warn); }
|
|
.bad { color: var(--bad); }
|
|
|
|
#install-status {
|
|
min-height: 1.25em;
|
|
margin-top: 12px;
|
|
font-weight: 600;
|
|
color: #b8e6ff;
|
|
}
|
|
|
|
code {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
color: #c7e6ff;
|
|
word-break: break-all;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<div class="header">
|
|
<img src="<?php p($_['iconUrl']); ?>" alt="">
|
|
<div>
|
|
<h1>Install + Mobile Readiness</h1>
|
|
<p><?php p($_['instanceName']); ?> cloud app setup for <?php p($_['userDisplayName']); ?>.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p>
|
|
This page is the Phase-1 readiness funnel: install app, enable notifications, verify device push health,
|
|
and run a test notification before relying on mobile alerts.
|
|
</p>
|
|
|
|
<div class="actions">
|
|
<button id="install-btn" class="button primary" type="button" hidden>Install Cloud App</button>
|
|
<button id="notifications-enable-btn" class="button" type="button">Enable Notifications</button>
|
|
<button id="notification-test-btn" class="button" type="button">Send Test Notification</button>
|
|
<button id="subscription-refresh-btn" class="button" type="button">Refresh Push Registration</button>
|
|
<a class="button" href="<?php p($_['homeUrl']); ?>">Open Cloud Home</a>
|
|
<a class="button" href="<?php p($_['filesUrl']); ?>">Open Files</a>
|
|
</div>
|
|
|
|
<div id="install-status" aria-live="polite"></div>
|
|
|
|
<section class="status-grid" aria-live="polite">
|
|
<div class="status-card">
|
|
<div class="status-title">Standalone Install</div>
|
|
<div id="status-standalone" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Install Prompt</div>
|
|
<div id="status-install-prompt" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Notification Permission</div>
|
|
<div id="status-notification-permission" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Service Worker</div>
|
|
<div id="status-service-worker" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Push Subscription</div>
|
|
<div id="status-push-subscription" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Backend Device Record</div>
|
|
<div id="status-backend-registration" class="status-value">Checking...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-title">Last Notification Received</div>
|
|
<div id="status-last-received" class="status-value">Never</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="note">
|
|
If install is unavailable on this browser, use browser menu install options on this page
|
|
(or Add to Home Screen on iOS/iPadOS). Permission prompts cannot be forced.
|
|
</div>
|
|
|
|
<p class="note">
|
|
Manifest: <code><?php p($_['manifestUrl']); ?></code><br>
|
|
Service worker: <code><?php p((string)($_['serviceWorkerUrl'] ?? '')); ?></code>
|
|
</p>
|
|
</main>
|
|
|
|
<script nonce="<?php p($_['cspNonce'] ?? ''); ?>">
|
|
(() => {
|
|
const manifestUrl = <?php print_unescaped(json_encode((string)($_['manifestUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const serviceWorkerUrl = <?php print_unescaped(json_encode((string)($_['serviceWorkerUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const deviceStatusUrl = <?php print_unescaped(json_encode((string)($_['deviceStatusUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const deviceRegisterUrl = <?php print_unescaped(json_encode((string)($_['deviceRegisterUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const devicePushReceivedUrl = <?php print_unescaped(json_encode((string)($_['devicePushReceivedUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const testNotificationUrl = <?php print_unescaped(json_encode((string)($_['testNotificationUrl'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const vapidPublicKey = <?php print_unescaped(json_encode((string)($_['vapidPublicKey'] ?? ''), JSON_UNESCAPED_SLASHES)); ?>;
|
|
const vapidConfigured = <?php print_unescaped(!empty($_['vapidConfigured']) ? 'true' : 'false'); ?>;
|
|
const LAST_RECEIVED_KEY = 'custom_pwa_last_notification_received_at';
|
|
const INSTALLATION_ID_KEY = 'custom_pwa_installation_id';
|
|
let deferredPrompt = null;
|
|
let swRegistration = null;
|
|
let currentPushSnapshot = {
|
|
pushEndpoint: '',
|
|
pushKey: '',
|
|
pushAuth: '',
|
|
};
|
|
|
|
const installBtn = document.getElementById('install-btn');
|
|
const notificationsEnableBtn = document.getElementById('notifications-enable-btn');
|
|
const notificationTestBtn = document.getElementById('notification-test-btn');
|
|
const subscriptionRefreshBtn = document.getElementById('subscription-refresh-btn');
|
|
const statusEl = document.getElementById('install-status');
|
|
const standaloneStatusEl = document.getElementById('status-standalone');
|
|
const installPromptStatusEl = document.getElementById('status-install-prompt');
|
|
const permissionStatusEl = document.getElementById('status-notification-permission');
|
|
const serviceWorkerStatusEl = document.getElementById('status-service-worker');
|
|
const pushSubscriptionStatusEl = document.getElementById('status-push-subscription');
|
|
const backendRegistrationStatusEl = document.getElementById('status-backend-registration');
|
|
const lastReceivedStatusEl = document.getElementById('status-last-received');
|
|
|
|
function setStatus(message, isError = false) {
|
|
statusEl.textContent = message || '';
|
|
statusEl.className = isError ? 'bad' : '';
|
|
}
|
|
|
|
function setState(el, label, level) {
|
|
if (!el) return;
|
|
el.textContent = label;
|
|
el.classList.remove('ok', 'warn', 'bad');
|
|
if (level) {
|
|
el.classList.add(level);
|
|
}
|
|
}
|
|
|
|
function isStandalone() {
|
|
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
|
}
|
|
|
|
function getInstallationId() {
|
|
try {
|
|
const existing = window.localStorage.getItem(INSTALLATION_ID_KEY);
|
|
if (existing && /^[a-zA-Z0-9:_\-\.]{8,128}$/.test(existing)) {
|
|
return existing;
|
|
}
|
|
const generated = 'dev-' + Math.random().toString(36).slice(2, 14) + '-' + Date.now().toString(36);
|
|
window.localStorage.setItem(INSTALLATION_ID_KEY, generated);
|
|
return generated;
|
|
} catch (_error) {
|
|
return 'volatile-' + Date.now().toString(36);
|
|
}
|
|
}
|
|
|
|
function encodeForm(body) {
|
|
const params = new URLSearchParams();
|
|
Object.keys(body).forEach((key) => {
|
|
if (body[key] === undefined || body[key] === null) return;
|
|
params.set(key, String(body[key]));
|
|
});
|
|
return params.toString();
|
|
}
|
|
|
|
function urlBase64ToUint8Array(value) {
|
|
const normalized = String(value || '').trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = window.atob(base64);
|
|
const output = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
output[i] = raw.charCodeAt(i);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function requestJson(url, options = {}) {
|
|
const response = await fetch(url, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
});
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch (_error) {
|
|
payload = null;
|
|
}
|
|
}
|
|
if (!response.ok || !payload || payload.ok === false) {
|
|
const errorMessage = payload && payload.error ? payload.error : ('HTTP ' + response.status);
|
|
throw new Error(errorMessage);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function formatTimestamp(value) {
|
|
if (!value) return 'Never';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return 'Never';
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function updateLastReceived(ts) {
|
|
const finalTs = ts || new Date().toISOString();
|
|
try {
|
|
window.localStorage.setItem(LAST_RECEIVED_KEY, finalTs);
|
|
} catch (_error) {
|
|
// ignore localStorage write issues
|
|
}
|
|
setState(lastReceivedStatusEl, formatTimestamp(finalTs), 'ok');
|
|
}
|
|
|
|
function refreshStandaloneState() {
|
|
setState(standaloneStatusEl, isStandalone() ? 'Installed / standalone' : 'Not standalone yet', isStandalone() ? 'ok' : 'warn');
|
|
}
|
|
|
|
function refreshPermissionState() {
|
|
if (!('Notification' in window)) {
|
|
setState(permissionStatusEl, 'Unsupported', 'bad');
|
|
return;
|
|
}
|
|
const permission = Notification.permission;
|
|
if (permission === 'granted') {
|
|
setState(permissionStatusEl, 'Granted', 'ok');
|
|
} else if (permission === 'denied') {
|
|
setState(permissionStatusEl, 'Denied', 'bad');
|
|
} else {
|
|
setState(permissionStatusEl, 'Prompt required', 'warn');
|
|
}
|
|
}
|
|
|
|
async function refreshServiceWorkerState() {
|
|
if (!('serviceWorker' in navigator)) {
|
|
setState(serviceWorkerStatusEl, 'Unsupported', 'bad');
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.serviceWorker.register(serviceWorkerUrl);
|
|
swRegistration = await navigator.serviceWorker.ready;
|
|
setState(serviceWorkerStatusEl, 'Registered', 'ok');
|
|
} catch (error) {
|
|
setState(serviceWorkerStatusEl, 'Registration failed', 'bad');
|
|
setStatus('Service worker registration failed: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
async function refreshPushSubscriptionState() {
|
|
if (!('PushManager' in window)) {
|
|
setState(pushSubscriptionStatusEl, 'Unsupported', 'bad');
|
|
return;
|
|
}
|
|
if (!swRegistration) {
|
|
setState(pushSubscriptionStatusEl, 'Service worker missing', 'warn');
|
|
return;
|
|
}
|
|
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
|
setState(pushSubscriptionStatusEl, 'Permission required', 'warn');
|
|
return;
|
|
}
|
|
if (!vapidConfigured || !vapidPublicKey) {
|
|
setState(pushSubscriptionStatusEl, 'VAPID not configured', 'warn');
|
|
currentPushSnapshot.pushEndpoint = '';
|
|
currentPushSnapshot.pushKey = '';
|
|
currentPushSnapshot.pushAuth = '';
|
|
return;
|
|
}
|
|
try {
|
|
let subscription = await swRegistration.pushManager.getSubscription();
|
|
if (!subscription) {
|
|
setState(pushSubscriptionStatusEl, 'Creating subscription…', 'warn');
|
|
subscription = await swRegistration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
});
|
|
}
|
|
if (subscription) {
|
|
setState(pushSubscriptionStatusEl, 'Active', 'ok');
|
|
const json = subscription.toJSON ? subscription.toJSON() : {};
|
|
currentPushSnapshot.pushEndpoint = subscription.endpoint || '';
|
|
currentPushSnapshot.pushKey = json && json.keys && json.keys.p256dh ? json.keys.p256dh : '';
|
|
currentPushSnapshot.pushAuth = json && json.keys && json.keys.auth ? json.keys.auth : '';
|
|
} else {
|
|
setState(pushSubscriptionStatusEl, 'Missing', 'warn');
|
|
currentPushSnapshot.pushEndpoint = '';
|
|
currentPushSnapshot.pushKey = '';
|
|
currentPushSnapshot.pushAuth = '';
|
|
}
|
|
} catch (error) {
|
|
setState(pushSubscriptionStatusEl, 'Subscription failed', 'bad');
|
|
setStatus('Push subscription failed: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
async function syncDeviceStatus() {
|
|
if (!deviceRegisterUrl) {
|
|
setState(backendRegistrationStatusEl, 'Endpoint missing', 'warn');
|
|
return;
|
|
}
|
|
const installationId = getInstallationId();
|
|
try {
|
|
const payload = await requestJson(deviceRegisterUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
},
|
|
body: encodeForm({
|
|
installationId: installationId,
|
|
notificationPermission: ('Notification' in window ? Notification.permission : 'unsupported'),
|
|
standalone: isStandalone() ? '1' : '0',
|
|
serviceWorkerVersion: serviceWorkerUrl,
|
|
pushEndpoint: currentPushSnapshot.pushEndpoint || '',
|
|
pushKey: currentPushSnapshot.pushKey || '',
|
|
pushAuth: currentPushSnapshot.pushAuth || '',
|
|
userAgent: navigator.userAgent || '',
|
|
platform: navigator.platform || '',
|
|
appVersion: manifestUrl || '',
|
|
}),
|
|
});
|
|
const hasRecord = payload && payload.data && payload.data.device;
|
|
setState(
|
|
backendRegistrationStatusEl,
|
|
hasRecord ? ('Registered (' + (payload.data.deviceCount || 1) + ' device(s))') : 'No record',
|
|
hasRecord ? 'ok' : 'warn'
|
|
);
|
|
if (hasRecord && payload.data.device.lastPushReceivedAt) {
|
|
updateLastReceived(payload.data.device.lastPushReceivedAt);
|
|
}
|
|
} catch (error) {
|
|
setState(backendRegistrationStatusEl, 'Sync failed', 'bad');
|
|
setStatus('Device sync failed: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
async function fetchBackendStatus() {
|
|
if (!deviceStatusUrl) {
|
|
setState(backendRegistrationStatusEl, 'Endpoint missing', 'warn');
|
|
return;
|
|
}
|
|
try {
|
|
const installationId = getInstallationId();
|
|
const payload = await requestJson(deviceStatusUrl + '?installationId=' + encodeURIComponent(installationId));
|
|
const device = payload && payload.data ? payload.data.device : null;
|
|
if (device) {
|
|
setState(
|
|
backendRegistrationStatusEl,
|
|
'Registered (' + (payload.data.deviceCount || 1) + ' device(s))',
|
|
'ok'
|
|
);
|
|
if (device.lastPushReceivedAt) {
|
|
updateLastReceived(device.lastPushReceivedAt);
|
|
}
|
|
} else {
|
|
setState(backendRegistrationStatusEl, 'Not registered yet', 'warn');
|
|
}
|
|
} catch (_error) {
|
|
setState(backendRegistrationStatusEl, 'Status unavailable', 'warn');
|
|
}
|
|
}
|
|
|
|
async function markPushReceivedBackend(tsIso) {
|
|
if (!devicePushReceivedUrl) {
|
|
return;
|
|
}
|
|
try {
|
|
await requestJson(devicePushReceivedUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
},
|
|
body: encodeForm({
|
|
installationId: getInstallationId(),
|
|
receivedAt: tsIso,
|
|
}),
|
|
});
|
|
} catch (_error) {
|
|
// non-blocking
|
|
}
|
|
}
|
|
|
|
async function refreshAllStates() {
|
|
refreshStandaloneState();
|
|
refreshPermissionState();
|
|
await refreshServiceWorkerState();
|
|
await refreshPushSubscriptionState();
|
|
}
|
|
|
|
async function requestNotifications() {
|
|
if (!('Notification' in window)) {
|
|
setStatus('Notifications are not supported by this browser.', true);
|
|
return;
|
|
}
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
refreshPermissionState();
|
|
await refreshPushSubscriptionState();
|
|
await syncDeviceStatus();
|
|
setStatus('Notification permission result: ' + permission + '.');
|
|
} catch (error) {
|
|
setStatus('Notification permission request failed: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
async function sendTestNotification() {
|
|
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
|
setStatus('Notification permission is not granted yet.', true);
|
|
return;
|
|
}
|
|
try {
|
|
const tsIso = new Date().toISOString();
|
|
const payload = {
|
|
title: 'Cloud mobile readiness test',
|
|
body: 'If you saw this, local notification delivery is working.',
|
|
data: { url: window.location.href, ts: tsIso },
|
|
tag: 'custom-pwa-test',
|
|
};
|
|
if (swRegistration && swRegistration.showNotification) {
|
|
await swRegistration.showNotification(payload.title, payload);
|
|
} else {
|
|
new Notification(payload.title, payload);
|
|
}
|
|
updateLastReceived(tsIso);
|
|
await markPushReceivedBackend(tsIso);
|
|
if (testNotificationUrl) {
|
|
try {
|
|
const backend = await requestJson(testNotificationUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
},
|
|
body: encodeForm({
|
|
installationId: getInstallationId(),
|
|
}),
|
|
});
|
|
const reason = backend && backend.data && backend.data.reason ? backend.data.reason : 'queued_local_only';
|
|
const summary = backend && backend.data && backend.data.summary ? backend.data.summary : null;
|
|
if (summary && typeof summary.total === 'number') {
|
|
setStatus(
|
|
'Test notification sent (backend: ' + reason
|
|
+ ', sent=' + String(summary.success || 0)
|
|
+ ', failed=' + String(summary.failed || 0)
|
|
+ ').',
|
|
Boolean(summary.failed && summary.failed > 0)
|
|
);
|
|
} else {
|
|
setStatus('Test notification sent (backend: ' + reason + ').');
|
|
}
|
|
} catch (_error) {
|
|
setStatus('Test notification sent (backend check unavailable).');
|
|
}
|
|
} else {
|
|
setStatus('Test notification sent.');
|
|
}
|
|
} catch (error) {
|
|
setStatus('Failed to send test notification: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
async function refreshRegistration() {
|
|
if (!('serviceWorker' in navigator)) {
|
|
setStatus('Service workers are not supported on this browser.', true);
|
|
return;
|
|
}
|
|
try {
|
|
const registration = await navigator.serviceWorker.getRegistration();
|
|
if (registration) {
|
|
await registration.unregister();
|
|
}
|
|
swRegistration = null;
|
|
await refreshServiceWorkerState();
|
|
await refreshPushSubscriptionState();
|
|
await syncDeviceStatus();
|
|
setStatus('Service worker registration refreshed.');
|
|
} catch (error) {
|
|
setStatus('Failed to refresh service worker: ' + (error && error.message ? error.message : String(error)), true);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('beforeinstallprompt', (event) => {
|
|
event.preventDefault();
|
|
deferredPrompt = event;
|
|
installBtn.hidden = false;
|
|
setState(installPromptStatusEl, 'Available', 'ok');
|
|
});
|
|
|
|
window.addEventListener('appinstalled', () => {
|
|
setStatus('Cloud app installed.');
|
|
installBtn.hidden = true;
|
|
refreshStandaloneState();
|
|
});
|
|
|
|
window.addEventListener('message', (event) => {
|
|
const data = event && event.data ? event.data : null;
|
|
if (!data || typeof data !== 'object') {
|
|
return;
|
|
}
|
|
if (data.type === 'CUSTOM_PWA_PUSH_RECEIVED') {
|
|
const tsIso = typeof data.ts === 'number' ? new Date(data.ts).toISOString() : new Date().toISOString();
|
|
updateLastReceived(tsIso);
|
|
markPushReceivedBackend(tsIso);
|
|
}
|
|
});
|
|
|
|
installBtn.addEventListener('click', async () => {
|
|
if (!deferredPrompt) {
|
|
setStatus('Install prompt is not currently available in this browser.');
|
|
return;
|
|
}
|
|
installBtn.disabled = true;
|
|
setStatus('Opening install prompt...');
|
|
deferredPrompt.prompt();
|
|
try {
|
|
await deferredPrompt.userChoice;
|
|
} catch (_error) {
|
|
// Browser behavior differs.
|
|
}
|
|
deferredPrompt = null;
|
|
installBtn.hidden = true;
|
|
installBtn.disabled = false;
|
|
setState(installPromptStatusEl, 'Not available (use browser menu)', 'warn');
|
|
});
|
|
|
|
notificationsEnableBtn.addEventListener('click', () => {
|
|
requestNotifications();
|
|
});
|
|
|
|
notificationTestBtn.addEventListener('click', () => {
|
|
sendTestNotification();
|
|
});
|
|
|
|
subscriptionRefreshBtn.addEventListener('click', () => {
|
|
refreshRegistration();
|
|
});
|
|
|
|
setState(installPromptStatusEl, 'Waiting for browser signal', 'warn');
|
|
try {
|
|
const stored = window.localStorage.getItem(LAST_RECEIVED_KEY);
|
|
setState(lastReceivedStatusEl, formatTimestamp(stored), stored ? 'ok' : 'warn');
|
|
} catch (_error) {
|
|
setState(lastReceivedStatusEl, 'Storage unavailable', 'warn');
|
|
}
|
|
setStatus('Manifest loaded from ' + manifestUrl);
|
|
refreshAllStates().catch(function () {
|
|
setStatus('One or more readiness checks failed.', true);
|
|
});
|
|
fetchBackendStatus().finally(function () {
|
|
syncDeviceStatus();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|