Files

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>