userSession->getUser(); $displayName = $user?->getDisplayName() ?? $user?->getUID() ?? 'User'; $instanceName = trim((string)$this->defaults->getName()); if ($instanceName === '') { $instanceName = 'Nextcloud'; } $cacheBuster = trim($this->config->getAppValue('theming', 'cachebuster', '')); $manifestUrl = $this->urlGenerator->linkToRoute('custom_pwa.page.manifest'); $iconUrl = $this->buildPwaIconUrl(false, $cacheBuster); if ($cacheBuster !== '') { $manifestUrl .= '?v=' . rawurlencode($cacheBuster); } $response = new StandaloneTemplateResponse( Application::APP_ID, 'install', [ 'instanceName' => $instanceName, 'userDisplayName' => $displayName, 'manifestUrl' => $manifestUrl, 'iconUrl' => $iconUrl, 'serviceWorkerUrl' => $this->buildServiceWorkerUrl($cacheBuster), 'deviceStatusUrl' => $this->urlGenerator->linkToRoute('custom_pwa.api.status'), 'deviceRegisterUrl' => $this->urlGenerator->linkToRoute('custom_pwa.api.registerDevice'), 'devicePushReceivedUrl' => $this->urlGenerator->linkToRoute('custom_pwa.api.markPushReceived'), 'testNotificationUrl' => $this->urlGenerator->linkToRoute('custom_pwa.api.testNotification'), 'vapidPublicKey' => trim($this->config->getAppValue(Application::APP_ID, 'vapid_public_key', '')), 'vapidConfigured' => trim($this->config->getAppValue(Application::APP_ID, 'vapid_public_key', '')) !== '' && trim($this->config->getAppValue(Application::APP_ID, 'vapid_private_key', '')) !== '', 'homeUrl' => $this->urlGenerator->linkToDefaultPageUrl(), 'filesUrl' => $this->urlGenerator->linkToRoute('files.view.index'), ], TemplateResponse::RENDER_AS_BLANK, ); return $response; } #[NoAdminRequired] #[NoCSRFRequired] public function manifest(): JSONResponse { $instanceName = trim((string)$this->defaults->getName()); if ($instanceName === '') { $instanceName = 'Nextcloud'; } $shortName = substr($instanceName, 0, 24); $themeColor = trim((string)$this->defaults->getColorPrimary()); if ($themeColor === '') { $themeColor = '#0082c9'; } $cacheBuster = trim($this->config->getAppValue('theming', 'cachebuster', '')); $startUrl = $this->absoluteUrl($this->urlGenerator->linkToDefaultPageUrl()); $installUrl = $this->absoluteUrl($this->urlGenerator->linkToRoute('custom_pwa.page.install')); $iconUrl = $this->buildManifestIconUrl($cacheBuster); $filesUrl = $this->absoluteUrl($this->urlGenerator->linkToRoute('files.view.index')); $talkUrl = $this->absoluteUrl('/apps/spreed/'); $settingsUrl = $this->absoluteUrl('/settings/user/qortal_integration'); $payload = [ 'id' => $installUrl, 'name' => $instanceName, 'short_name' => $shortName !== '' ? $shortName : $instanceName, 'description' => 'Managed cloud workspace with install-ready mobile experience.', 'start_url' => $startUrl, 'scope' => '/', 'display' => 'standalone', 'display_override' => ['standalone', 'minimal-ui', 'browser'], 'background_color' => '#041224', 'theme_color' => $themeColor, 'icons' => [ [ 'src' => $iconUrl, 'type' => 'image/png', 'sizes' => '180x180', 'purpose' => 'any maskable', ], [ 'src' => $iconUrl, 'type' => 'image/png', 'sizes' => '192x192', 'purpose' => 'any maskable', ], [ 'src' => $iconUrl, 'type' => 'image/png', 'sizes' => '512x512', 'purpose' => 'any maskable', ], ], 'shortcuts' => [ [ 'name' => 'Open Talk', 'short_name' => 'Talk', 'url' => $talkUrl, ], [ 'name' => 'Open Files', 'short_name' => 'Files', 'url' => $filesUrl, ], [ 'name' => 'Open Settings', 'short_name' => 'Settings', 'url' => $settingsUrl, ], ], ]; return new JSONResponse($payload); } #[NoAdminRequired] #[NoCSRFRequired] public function serviceWorker(): DataDisplayResponse { $cacheBuster = trim($this->config->getAppValue('theming', 'cachebuster', '')); $version = 'custom-pwa-' . ($cacheBuster !== '' ? $cacheBuster : 'v1'); $pendingNotificationsUrl = $this->urlGenerator->linkToRoute('custom_pwa.api.pendingNotifications'); $script = $this->buildServiceWorkerScript($version, $pendingNotificationsUrl); return new DataDisplayResponse($script, 200, [ 'Content-Type' => 'application/javascript; charset=UTF-8', 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', ]); } private function buildPwaIconUrl(bool $absolute, string $cacheBuster = ''): string { // Prefer uploaded favicon (instance branding) when available. $faviconMime = trim($this->config->getAppValue('theming', 'faviconMime', '')); if ($faviconMime !== '') { $url = $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => 'favicon', 'useSvg' => false, ]); } else { $logoMime = trim($this->config->getAppValue('theming', 'logoMime', '')); if ($logoMime !== '') { // Secondary fallback: uploaded logo image if favicon is not set. $url = $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => 'logo', 'useSvg' => false, ]); } else { // Last fallback to themed core icon. $url = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => 'core']); } } if ($cacheBuster !== '') { $separator = str_contains($url, '?') ? '&' : '?'; $url .= $separator . 'v=' . rawurlencode($cacheBuster); } return $absolute ? $this->urlGenerator->getAbsoluteURL($url) : $url; } private function buildManifestIconUrl(string $cacheBuster = ''): string { $url = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => 'core']); if ($cacheBuster !== '') { $separator = str_contains($url, '?') ? '&' : '?'; $url .= $separator . 'v=' . rawurlencode($cacheBuster); } return $this->urlGenerator->getAbsoluteURL($url); } private function absoluteUrl(string $url): string { $trimmed = trim($url); if ($trimmed === '') { return ''; } if (preg_match('#^https?://#i', $trimmed) === 1) { return $trimmed; } return $this->urlGenerator->getAbsoluteURL($trimmed); } private function buildServiceWorkerUrl(string $cacheBuster = ''): string { $url = $this->urlGenerator->linkToRoute('custom_pwa.page.serviceWorker'); if ($cacheBuster !== '') { $separator = str_contains($url, '?') ? '&' : '?'; $url .= $separator . 'v=' . rawurlencode($cacheBuster); } return $url; } private function buildServiceWorkerScript(string $version, string $pendingNotificationsUrl): string { $safeVersion = json_encode($version, JSON_THROW_ON_ERROR); $safePendingNotificationsUrl = json_encode($pendingNotificationsUrl, JSON_THROW_ON_ERROR); return << { event.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener('push', (event) => { event.waitUntil((async () => { const payloads = await resolvePushPayloads(event); for (const payload of payloads) { const title = payload.title || 'Cloud notification'; const body = payload.body || 'A new event is available.'; const url = payload.url || '/'; const tag = payload.tag || 'custom-pwa-default'; await self.registration.showNotification(title, { body, tag, data: { url, ts: Date.now(), version: SW_VERSION }, requireInteraction: Boolean(payload.requireInteraction), renotify: true, }); } })()); }); async function resolvePushPayloads(event) { let payload = {}; try { payload = event.data ? event.data.json() : {}; } catch (_error) { payload = {}; } if (payload && (payload.title || payload.body)) { return [payload]; } try { const response = await fetch(PENDING_NOTIFICATIONS_URL, { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, }); if (response.ok) { const data = await response.json(); const notifications = data && data.data && Array.isArray(data.data.notifications) ? data.data.notifications : []; if (notifications.length > 0) { return notifications; } } } catch (_error) { } return [payload || {}]; } self.addEventListener('notificationclick', (event) => { event.notification.close(); const targetUrl = (event.notification.data && event.notification.data.url) || '/'; event.waitUntil((async () => { const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const client of clientList) { if (client.url === targetUrl && 'focus' in client) { client.postMessage({ type: 'CUSTOM_PWA_PUSH_RECEIVED', ts: Date.now(), version: SW_VERSION }); await client.focus(); return; } } const opened = await clients.openWindow(targetUrl); if (opened) { opened.postMessage({ type: 'CUSTOM_PWA_PUSH_RECEIVED', ts: Date.now(), version: SW_VERSION }); } })()); }); self.addEventListener('message', (event) => { const message = event.data || {}; if (message.type === 'CUSTOM_PWA_PING' && event.source && 'postMessage' in event.source) { event.source.postMessage({ type: 'CUSTOM_PWA_PONG', ts: Date.now(), version: SW_VERSION }); } }); JS; } }