310 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|