Files

310 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\CustomPwa\Controller;
use OCA\CustomPwa\AppInfo\Application;
use OCP\Defaults;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\StandaloneTemplateResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
class PageController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IURLGenerator $urlGenerator,
private IUserSession $userSession,
private IConfig $config,
private Defaults $defaults,
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
#[NoCSRFRequired]
public function install(): StandaloneTemplateResponse {
$user = $this->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 <<<JS
const SW_VERSION = {$safeVersion};
const PENDING_NOTIFICATIONS_URL = {$safePendingNotificationsUrl};
self.addEventListener('install', (event) => {
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;
}
}