From f403acbe4613fe4964965ca2f063cdee48c75759 Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 08:24:02 -0500 Subject: [PATCH 01/10] ! Initial bits of PWA --- elkManifest.php | 28 + elkServiceWorker.js | 570 ++++++++++++++++++ sources/ElkArte/AdminController/Admin.php | 7 +- .../ElkArte/AdminController/Maintenance.php | 3 + .../AdminController/ManageFeatures.php | 133 +++- sources/ElkArte/Controller/Offline.php | 36 ++ sources/ElkArte/Languages/Admin/English.php | 1 + sources/ElkArte/Languages/Index/English.php | 5 + .../Languages/ManageSettings/English.php | 25 +- sources/ElkArte/ManifestMinimus.php | 195 ++++++ .../Notifications/UserNotification.php | 17 +- sources/ElkArte/Themes/Theme.php | 82 ++- sources/Subs.php | 24 +- themes/default/Offline.template.php | 82 +++ themes/default/Theme.php | 3 + themes/default/index.template.php | 25 +- themes/default/scripts/admin.js | 35 +- themes/default/scripts/elk_pwa.js | 116 ++++ 18 files changed, 1359 insertions(+), 28 deletions(-) create mode 100644 elkManifest.php create mode 100644 elkServiceWorker.js create mode 100644 sources/ElkArte/Controller/Offline.php create mode 100644 sources/ElkArte/ManifestMinimus.php create mode 100644 themes/default/Offline.template.php create mode 100644 themes/default/scripts/elk_pwa.js diff --git a/elkManifest.php b/elkManifest.php new file mode 100644 index 0000000000..c2d60aff8d --- /dev/null +++ b/elkManifest.php @@ -0,0 +1,28 @@ +create(); + +// Always exit as successful +exit(0); \ No newline at end of file diff --git a/elkServiceWorker.js b/elkServiceWorker.js new file mode 100644 index 0000000000..2f828c583b --- /dev/null +++ b/elkServiceWorker.js @@ -0,0 +1,570 @@ +/*! + * @package ElkArte Forum + * @copyright ElkArte Forum contributors + * @license BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file) + * + * @version 2.0 dev + * + * This is the service worker for ElkArte PWA and Push + */ + +const OFFLINE = '/index.php?action=offline'; +const navigationPreload = true; + +let STATIC_CACHE_NAME = 'elk_sw_cache_static', + PAGES_CACHE_NAME = 'elk_sw_cache_pages', + IMAGES_CACHE_NAME = 'elk_sw_cache_images', + CACHE_ID = null; + +// On sw installation cache some defined ASSETS and the OFFLINE page +self.addEventListener('install', event => { + self.skipWaiting(); + + let passedParam = new URL(location); + + // Use a cache id, so we can do pruning/resets from elk_pwa.js messages + CACHE_ID = '::' + passedParam.searchParams.get('cache_id') || 'elk20'; + STATIC_CACHE_NAME += CACHE_ID; + PAGES_CACHE_NAME += CACHE_ID; + IMAGES_CACHE_NAME += CACHE_ID; + + const themeScope = passedParam.searchParams.get('theme_scope') || '/themes/default/', + defaultThemeScope = passedParam.searchParams.get('default_theme_scope') || '/themes/default/', + swScope = passedParam.searchParams.get('sw_scope') || '/', + cache_stale = passedParam.searchParams.get('cache_stale') || '?elk20', + ASSETS = defineAssets(themeScope, cache_stale, defaultThemeScope); + + event.waitUntil( + Promise.all([ + caches.open(STATIC_CACHE_NAME).then(cache => { + let assetPromises = ASSETS.map(asset => cache.add(asset).catch(err => { + if (console && console.error) + { + console.error('[Error] Asset not found. ', asset, ' ', err.message); + } + })); + return Promise.all(assetPromises); + }), + caches.open(PAGES_CACHE_NAME).then(cache => { + return cache.add(`${swScope}${OFFLINE}`).catch(err => { + if (console && console.error) + { + console.error('[Error] Offline Asset not found. ', err.message); + } + }); + }) + ]) + ); +}); + +/** + * After install is complete, enable preloading if available. + * + * If navigation preload is enabled, a HEAD request is sent to the page's origin at the same time + * as the service worker starts up. This way, if the service worker is going to just fetch the page + * from the network anyway, it can get going without having to wait for the installation. + * + * Delete any caches that do not match our current version + */ +self.addEventListener('activate', event => { + event.waitUntil( + (async function() { + if (self.registration.navigationPreload) + { + await self.registration.navigationPreload[navigationPreload ? 'enable' : 'disable'](); + } + })() + .then(() => deleteOldCache()) + .then(() => self.clients.claim()) + ); +}); + +// When the browser makes a request for a resource, determine if its actionable +self.addEventListener('fetch', event => { + let request = event.request, + accept = request.headers.get('Accept') || null; + + // Third Party request, POST, non link or address bar + if (!request.url.startsWith(self.location.origin) || event.request.method !== 'GET') + { + event.respondWith(handleNavigationPreload(event)); + return; + } + + // Admin, tasks, api, install, attachments, other cruft, Network only + if (request.url.match(/scheduled|api=|dlattach|install|google-ad|adsense|action=admin/)) + { + event.respondWith(handleNavigationPreload(event)); + return; + } + + // HTML request, selective Cache first with fallback, all others Network only + if (accept && request.headers.get('Accept').includes('text/html')) + { + // Cache the home page + if (request.url.endsWith('index.php')) + { + return processNetworkFirstRequest(event, PAGES_CACHE_NAME); + } + + event.respondWith(handleNavigationPreload(event)); + return; + } + + // CSS Cache first, with a dynamic refresh to account for theme swapping + if (accept && accept.includes('text/css')) + { + return processStaleWhileRevalidateRequest(event, STATIC_CACHE_NAME); + } + + // JavaScript, Cache first, with a network fallback and cache + if (accept && accept.includes('text/javascript')) + { + return processCacheFirstRequest(event, STATIC_CACHE_NAME); + } + + // Images Cache first then fallback to Network and cache + if (accept && accept.includes('image')) + { + return processCacheFirstRequest(event, IMAGES_CACHE_NAME); + } +}); + +// Message handler, provides a way to interact with the service worker +self.addEventListener('message', function(event) { + let command = event.data.command || '', + opts = event.data.opts || {}; + + if (command === 'pruneCache') + { + pruneCache(25, STATIC_CACHE_NAME) + .then(r => pruneCache(50, IMAGES_CACHE_NAME)) + .then(r => pruneCache(10, PAGES_CACHE_NAME)); + + return; + } + + if (command === 'deleteOldCache') + { + if (opts.cache_id && '::' + opts.cache_id !== CACHE_ID) + { + CACHE_ID = '::' + opts.cache_id; + } + + return deleteOldCache(); + } + + if (command === 'clearAllCache') + { + return clearAllCache(); + } + + self.client = event; +}); + +/** + * Handles navigation preload for the given event. + * + * @param {Event} event - The event object. + * @returns {Promise} - A promise that resolves to the preloaded response or fetch response. + * @throws {Error} - Throws an error if navigation preload is not available or there is no valid preload response. + */ +function handleNavigationPreload (event) +{ + if (!navigationPreload || !event.preloadResponse) + { + throw new Error('Navigation Preload not available'); + } + + return event.preloadResponse.then(preloadedResponse => { + if (!preloadedResponse) + { + throw new Error('No valid preload response'); + } + return preloadedResponse; + }).catch(e => { + return fetch(event.request); + }); +} + +/** + * Defines / resolves the assets for a given theme scope. + * + * @param {string} themeScope - The theme scope. + * @param {string} cache_stale - The cache stale value. + * @param {string} defaultThemeScope - The default theme scope. + * + * @returns {string[]} - An array of asset URLs. + */ +function defineAssets (themeScope, cache_stale, defaultThemeScope) +{ + return [ + `${themeScope}css/index.css${cache_stale}`, + `${defaultThemeScope}css/icons_svg.css${cache_stale}`, + `${defaultThemeScope}scripts/elk_menu.js${cache_stale}`, + `${defaultThemeScope}scripts/script.js${cache_stale}`, + `${defaultThemeScope}scripts/script_elk.js${cache_stale}`, + `${themeScope}scripts/theme.js${cache_stale}`, + `${defaultThemeScope}scripts/theme.js${cache_stale}`, + `${defaultThemeScope}scripts/editor/jquery.sceditor.bbcode.min.js${cache_stale}`, + ]; +} + +/** + * Processes the first request by checking if the response is available in the cache. + * + * If it is, the cached response is returned. + * If not, it checks if there is a preloaded response available. If yes, it adds the preloaded response to the cache + * and returns the preloaded response. + * If neither the cached response nor the preloaded response is available, it makes a network call and returns + * the network response and saves it to the cache. + * If an error occurs during the process, it returns an offline page or a fallback response if there is no + * cached offline response. + * + * @param {FetchEvent} event - The event object representing the request. + * @param {String} cache_name - The name of the cache to be used. + * + * @returns {void} - A promise that resolves to the response object. + */ +async function processCacheFirstRequest (event, cache_name) +{ + event.respondWith( + (async() => { + // Start both promises at the same time + const cachePromise = caches.open(cache_name).then(cache => cache.match(event.request)); + const preloadPromise = event.preloadResponse; + + // If cached Response is available, use it + const cachedResponsePromise = await cachePromise; + if (cachedResponsePromise) + { + return cachedResponsePromise; + } + // If preloadResponse is usable, use it + const preloadResponsePromise = await preloadPromise; + if (preloadResponsePromise) + { + return cacheAndReturnResponse(preloadResponsePromise, event.request, cache_name); + } + // No response found in cache or preload, fetch from network + const networkResponsePromise = await fetch(event.request); + if (networkResponsePromise && networkResponsePromise.ok) + { + return cacheAndReturnResponse(networkResponsePromise, event.request, cache_name); + } + + // Still nothing, return the offline page + const offlineRequest = new Request(OFFLINE); + const cachedResponse = await caches.match(offlineRequest); + return cachedResponse || new Response('Sorry, you are offline. Please check your connection.'); + })() + ); +} + +/** + * Processes a network-first request. + * + * Tries the preloadResponse first + * If the preloadResponse fails, tries a networkResponse + * If the networkResponse fails or returns an error status code, it falls back to the cache. + * If all fail, it returns an offline page. + * Successful preloadResponse or networkResponse are saved to the cache. + * + * @param {FetchEvent} event - The event object for the fetch event. + * @param {string} cache_name - The cache to look in/open. + * @return {void} - A promise that resolves to the fetched response or the offline page. + */ +async function processNetworkFirstRequest (event, cache_name) +{ + event.respondWith( + (async() => { + const networkResponsePromise = fetch(event.request).catch(() => null); + const preloadResponsePromise = event.preloadResponse.catch(() => null); + const [networkResponse, preloadResponse] = await Promise.all([networkResponsePromise, preloadResponsePromise]); + + // If preloadResponse is usable, use it + if (preloadResponse && preloadResponse.ok) + { + return cacheAndReturnResponse(preloadResponse, event.request, cache_name); + } + // If networkResponse is usable, use it + if (networkResponse && networkResponse.ok) + { + return cacheAndReturnResponse(networkResponse, event.request, cache_name); + } + // Both failed, so try the cache + const cachedResponse = await caches.match(event.request); + if (cachedResponse) + { + return cachedResponse; + } + + // Still nothing, return the offline page + const offlineRequest = new Request(OFFLINE); + const cachedOfflineResponse = await caches.match(offlineRequest); + return cachedOfflineResponse || new Response('Sorry, you are offline. Please check your connection.'); + })() + ); +} + +/** + * Processes a stale-while-revalidate request. + * + * When a request is made, this method first checks if there is a cached response for the request. + * If a cached response is found, it returns the cached response immediately. + * Meanwhile, it also sends a network request to fetch the latest response from the server. + * If the network request is successful, the fetched response is stored in the cache for future use. + * If both the cache and network requests fail, it returns the offline page. + * + * @param {FetchEvent} event - The fetch event object containing the request. + * @param {string} cache_name - The cache to look in/open. + * @return {Promise} - A promise that resolves to a Response object. + */ +async function processStaleWhileRevalidateRequest (event, cache_name) +{ + async function fetchAndUpdate (event) + { + let networkResponse = null; + try + { + networkResponse = await fetch(event.request); + const cache = await caches.open(cache_name); + cache.put(event.request, networkResponse.clone()); + } + catch (error) + { + const offlineRequest = new Request(OFFLINE); + networkResponse = await cache.match(offlineRequest); + } + return networkResponse || new Response('Sorry, you are offline. Please check your connection.'); + } + + event.waitUntil(fetchAndUpdate(event)); + + const cache = await caches.open(cache_name); + const cachedResponse = await cache.match(event.request); + // If cachedResponse is available, use it + if (cachedResponse) + { + return cachedResponse; + } + // If preloadResponse is usable, use it + if (event.preloadResponse) + { + const preloadResponse = await event.preloadResponse; + if (preloadResponse) + { + cache.put(event.request, preloadResponse.clone()); + return preloadResponse; + } + } + + // Lastly try networkResponse or failing that show offline + return fetchAndUpdate(event); +} + +/** + * Caches the response and returns it. + * + * @param {Promise} responsePromise - The promise that resolves to the response. + * @param {Request} request - The request object. + * @param {string} cache_name - The name of the cache. + * @returns {Promise} - The response promise that was passed as an argument. + */ +async function cacheAndReturnResponse (responsePromise, request, cache_name) +{ + if (responsePromise && responsePromise.ok) + { + // Add to cache but don't wait for it to complete + let cache = await caches.open(cache_name); + cache.put(request, responsePromise.clone()); + return responsePromise; + } +} + +/** + * Removes the oldest item in the specified cache. + * + * @param {string} cache_name - The name of the cache. + * + * @returns {Promise} - A promise that resolves to true if an item was removed, or false if the cache is empty. + */ +async function removeOldestCacheItem (cache_name) +{ + let cache = await caches.open(cache_name), + keys = await cache.keys(); + + if (keys.length > 0) + { + await cache.delete(keys[0]); + return true; + } + + return false; +} + +/** + * Prunes a cache by removing the oldest items until the maximum item limit is reached. + * + * @param {number} maxItems - The maximum number of items to keep in the cache. + * @param {string} cache_name - The name of the cache to prune. + * @returns {Promise} - A promise that resolves when the cache has been pruned. + */ +async function pruneCache (maxItems, cache_name) +{ + while (true) + { + let cache = await caches.open(cache_name), + keys = await cache.keys(); + + if (keys.length > maxItems) + { + // Exit if nothing was removed from cache + if (!(await removeOldestCacheItem(cache_name))) + { + break; + } + } + // Exit if cache is already at its maximum size + else + { + break; + } + } +} + +/** + * Deletes cache buckets that do not have the current cache_ID + * + * @returns {Promise[]>} A promise that resolves to an array of booleans indicating + * whether each cache entry was successfully deleted. + */ +function deleteOldCache () +{ + return caches.keys() + .then(function(keys) { + return Promise.all(keys + .filter(function(key) { + return key.indexOf(CACHE_ID) === -1; + }) + .map(function(key) { + return caches.delete(key); + }) + ); + }); +} + +/** + * Clears all cache buckets. + * + * @returns {Promise[]>} A promise that resolves to an array of booleans indicating + * successful cache deletions. + */ +function clearAllCache () +{ + return caches.keys() + .then(function(cacheNames) { + return Promise.all( + cacheNames.map(function(cacheName) { + return caches.delete(cacheName); + }) + ); + }); +} + +// +// Below are used by PUSH Notifications API +// +function isFunction (obj) +{ + return obj && {}.toString.call(obj) === '[object Function]'; +} + +function runFunctionString (funcStr) +{ + if (funcStr.trim().length > 0) + { + const func = new Function(funcStr); + if (isFunction(func)) + { + func(); + } + } +} + +self.onnotificationclose = ({notification}) => { + runFunctionString(notification.data.onClose); + + /* Tell Push to execute close callback */ + self.client.postMessage( + JSON.stringify({ + id: notification.data.id, + action: 'close' + }) + ); +}; + +self.onnotificationclick = event => { + let link, origin, href; + + if ( + typeof event.notification.data.link !== 'undefined' && + event.notification.data.link !== null + ) + { + origin = event.notification.data.origin; + link = event.notification.data.link; + href = origin.substring(0, origin.indexOf('/', 8)) + '/'; + + /* Removes prepending slash, as we don't need it */ + if (link[0] === '/') + { + link = link.length > 1 ? link.substring(1, link.length) : ''; + } + + event.notification.close(); + + /* This looks to see if the current is already open and focuses if it is */ + event.waitUntil( + clients + .matchAll({ + type: 'window' + }) + .then(clientList => { + let client, full_url; + + for (let i = 0; i < clientList.length; i++) + { + client = clientList[i]; + full_url = href + link; + + /* Covers case where full_url might be http://example.com/john and the client URL is http://example.com/john/ */ + if ( + full_url[full_url.length - 1] !== '/' && + client.url[client.url.length - 1] === '/' + ) + { + full_url += '/'; + } + + if (client.url === full_url && 'focus' in client) + { + return client.focus(); + } + } + + if (clients.openWindow) + { + return clients.openWindow('/' + link); + } + }) + .catch(({message}) => { + throw new Error( + 'A ServiceWorker error occurred: ' + message + ); + }) + ); + } + + runFunctionString(event.notification.data.onClick); +}; diff --git a/sources/ElkArte/AdminController/Admin.php b/sources/ElkArte/AdminController/Admin.php index 39e805a9e0..760cfa83a1 100644 --- a/sources/ElkArte/AdminController/Admin.php +++ b/sources/ElkArte/AdminController/Admin.php @@ -221,12 +221,13 @@ private function loadMenu() 'subsections' => array( 'basic' => array($txt['mods_cat_features']), 'layout' => array($txt['mods_cat_layout']), - 'pmsettings' => array($txt['personal_messages']), - 'karma' => array($txt['karma'], 'enabled' => featureEnabled('k')), - 'likes' => array($txt['likes'], 'enabled' => featureEnabled('l')), 'mention' => array($txt['mention']), + 'pwa' => array($txt['pwa_label']), + 'pmsettings' => array($txt['personal_messages']), 'sig' => array($txt['signature_settings_short']), 'profile' => array($txt['custom_profile_shorttitle'], 'enabled' => featureEnabled('cp')), + 'karma' => array($txt['karma'], 'enabled' => featureEnabled('k')), + 'likes' => array($txt['likes'], 'enabled' => featureEnabled('l')), ), ), 'serversettings' => array( diff --git a/sources/ElkArte/AdminController/Maintenance.php b/sources/ElkArte/AdminController/Maintenance.php index 188c76760f..fccf354fe4 100644 --- a/sources/ElkArte/AdminController/Maintenance.php +++ b/sources/ElkArte/AdminController/Maintenance.php @@ -373,6 +373,9 @@ public function action_cleancache_display() // Just wipe the whole cache directory! Cache::instance()->clean(); + // Change the PWA stale so it will refresh (if enabled) + setPWACacheStale(true); + $context['maintenance_finished'] = $txt['maintain_cache']; } diff --git a/sources/ElkArte/AdminController/ManageFeatures.php b/sources/ElkArte/AdminController/ManageFeatures.php index 5a49812006..f68aded9d8 100644 --- a/sources/ElkArte/AdminController/ManageFeatures.php +++ b/sources/ElkArte/AdminController/ManageFeatures.php @@ -21,6 +21,7 @@ use ElkArte\AbstractController; use ElkArte\Action; use ElkArte\Exceptions\Exception; +use ElkArte\Helper\DataValidator; use ElkArte\Helper\Util; use ElkArte\Hooks; use ElkArte\Languages\Txt; @@ -60,7 +61,7 @@ public function action_index() // Often Helpful Txt::load('Help+ManageSettings+Mentions'); - // All the actions we know about + // All the actions we know about. These must exist in loadMenu() of the admin controller. $subActions = array( 'basic' => array( 'controller' => $this, @@ -72,6 +73,12 @@ public function action_index() 'function' => 'action_layoutSettings_display', 'permission' => 'admin_forum' ), + 'pwa' => array( + 'controller' => $this, + 'function' => 'action_pwaSettings_display', + 'enabled' => true, + 'permission' => 'admin_forum' + ), 'karma' => array( 'controller' => $this, 'function' => 'action_karmaSettings_display', @@ -115,7 +122,7 @@ public function action_index() // Set up the action control $action = new Action('modify_features'); - // By default do the basic settings, call integrate_sa_modify_features + // By default, do the basic settings, call integrate_sa_modify_features $subAction = $action->initialize($subActions, 'basic'); // Some final pieces for the template @@ -139,6 +146,9 @@ public function action_index() 'profile' => [ 'description' => $txt['custom_profile_desc'], ], + 'pwa' => [ + 'description' => $txt['pwa_settings_desc'], + ], ], ]); @@ -373,6 +383,123 @@ private function _layoutSettings() return $config_vars; } + /** + * Display configuration settings page for progressive web application settings. + * + * - Accessed from ?action=admin;area=featuresettings;sa=pwa; + * + * @event integrate_save_pwa_settings + */ + public function action_pwaSettings_display() + { + global $txt, $context; + + // Initialize the form + $settingsForm = new SettingsForm(SettingsForm::DB_ADAPTER); + + // Initialize it with our settings + $settingsForm->setConfigVars($this->_pwaSettings()); + + // Saving, lots of checks then + if (isset($this->_req->query->save)) + { + checkSession(); + + call_integration_hook('integrate_save_pwa_settings'); + + // Don't allow it to be enabled if we don't have SSL + $canUse = detectServer()->supportsSSL(); + if (!$canUse) + { + $this->_req->post->pwa_enabled = 0; + } + + // And you must enable this if PWA is enabled + if ($this->_req->getPost('pwa_enabled', 'intval') === 1) + { + $this->_req->post->pwa_manifest_enabled = 1; + } + + $validator = new DataValidator(); + $validation_rules = [ + 'pwa_theme_color' => 'valid_color', + 'pwa_background_color' => 'valid_color', + 'pwa_short_name' => 'max_length[12]' + ]; + + // Only check the rest if they entered something. + $valid_urls = ['pwa_small_icon', 'pwa_large_icon', 'favicon_icon', 'apple_touch_icon']; + foreach ($valid_urls as $url) + { + if ($this->_req->getPost($url, 'trim') !== '') + { + $validation_rules[$url] = 'valid_url'; + } + } + $validator->validation_rules($validation_rules); + + if (!$validator->validate($this->_req->post)) + { + // Some input error, lets tell them what is wrong + $context['error_type'] = 'minor'; + $context['settings_message'] = []; + foreach ($validator->validation_errors() as $error) + { + $context['settings_message'][] = $error; + } + } + else + { + $settingsForm->setConfigValues((array) $this->_req->post); + $settingsForm->save(); + redirectexit('action=admin;area=featuresettings;sa=pwa'); + } + } + + $context['post_url'] = getUrl('admin', ['action' => 'admin', 'area' => 'featuresettings', 'sa' => 'pwa', 'save']); + $context['settings_title'] = $txt['pwa_settings']; + theme()->addInlineJavascript(' + pwaPreview("pwa_small_icon"); + pwaPreview("pwa_large_icon"); + pwaPreview("favicon_icon"); + pwaPreview("apple_touch_icon");', ['defer' => true]); + + $settingsForm->prepare(); + } + + /** + * Return PWA settings. + * + * @event integrate_modify_karma_settings Adds to Configuration->Pwa + */ + private function _pwaSettings() + { + global $txt; + + // PWA requires SSL + $canUse = detectServer()->supportsSSL(); + + $config_vars = array( + // PWA - On or off? + array('check', 'pwa_enabled', 'disabled' => !$canUse, 'invalid' => !$canUse, 'postinput' => !$canUse ? $txt['pwa_disabled'] : ''), + '', + array('check', 'pwa_manifest_enabled', 'helptext' => $txt['pwa_manifest_enabled_desc']), + array('text', 'pwa_short_name', 12, 'mask' => 'nohtml', 'helptext' => $txt['pwa_short_name_desc'], 'maxlength' => 12), + array('color', 'pwa_theme_color', 'helptext' => $txt['pwa_theme_color_desc']), + array('color', 'pwa_background_color', 'helptext' => $txt['pwa_background_color_desc']), + '', + array('url', 'pwa_small_icon', 'size' => 40, 'helptext' => $txt['pwa_small_icon_desc'], 'onchange' => "pwaPreview('pwa_small_icon');"), + array('url', 'pwa_large_icon', 'size' => 40, 'helptext' => $txt['pwa_large_icon_desc'], 'onchange' => "pwaPreview('pwa_large_icon');"), + array('title', 'other_icons_title'), + array('url', 'favicon_icon', 'size' => 40, 'helptext' => $txt['favicon_icon_desc'], 'onchange' => "pwaPreview('favicon_icon');"), + array('url', 'apple_touch_icon', 'size' => 40, 'helptext' => $txt['apple_touch_icon_desc'], 'onchange' => "pwaPreview('apple_touch_icon');"), + ); + + call_integration_hook('integrate_modify_pwa_settings', array(&$config_vars)); + + return $config_vars; + } + /** * Display configuration settings page for karma settings. * @@ -402,7 +529,7 @@ public function action_karmaSettings_display() redirectexit('action=admin;area=featuresettings;sa=karma'); } - $context['post_url'] = getUrl('admin', ['action' => 'admin', 'area' => 'featuresettings', 'sa' => 'karm', 'save']); + $context['post_url'] = getUrl('admin', ['action' => 'admin', 'area' => 'featuresettings', 'sa' => 'karma', 'save']); $context['settings_title'] = $txt['karma']; $settingsForm->prepare(); diff --git a/sources/ElkArte/Controller/Offline.php b/sources/ElkArte/Controller/Offline.php new file mode 100644 index 0000000000..3946e7e18e --- /dev/null +++ b/sources/ElkArte/Controller/Offline.php @@ -0,0 +1,36 @@ +action_offline(); + } + + public function action_offline() + { + // Load the template + theme()->getTemplates()->load('Offline'); + theme()->getTemplates()->loadSubTemplate('offline'); + + obExit(false, false); + } +} \ No newline at end of file diff --git a/sources/ElkArte/Languages/Admin/English.php b/sources/ElkArte/Languages/Admin/English.php index 8b38b6679c..1b452cd79f 100644 --- a/sources/ElkArte/Languages/Admin/English.php +++ b/sources/ElkArte/Languages/Admin/English.php @@ -751,6 +751,7 @@ $txt['mods_cat_modifications_misc'] = 'Miscellaneous'; $txt['mods_cat_layout'] = 'Layout'; $txt['karma'] = 'Karma'; +$txt['pwa'] = 'PWA'; $txt['moderation_settings_short'] = 'Moderation'; $txt['signature_settings_short'] = 'Signatures'; $txt['custom_profile_shorttitle'] = 'Profile Fields'; diff --git a/sources/ElkArte/Languages/Index/English.php b/sources/ElkArte/Languages/Index/English.php index f0832a671a..e9d7a5c56c 100644 --- a/sources/ElkArte/Languages/Index/English.php +++ b/sources/ElkArte/Languages/Index/English.php @@ -97,6 +97,7 @@ $txt['package'] = 'Package Manager'; $txt['edit_permissions'] = 'Permissions'; $txt['modSettings_title'] = 'Features and Options'; +$txt['pwa_label'] = 'Web Application'; $txt['moderate'] = 'Moderate'; // Sub menu labels @@ -936,3 +937,7 @@ $txt['otp_show_qr'] = 'Show QR-Code'; $txt['other'] = 'Other'; + +$txt['offline'] = 'OFFLINE'; +$txt['retry'] = 'RETRY'; +$txt['check_connection'] = 'Please check your internet connection'; \ No newline at end of file diff --git a/sources/ElkArte/Languages/ManageSettings/English.php b/sources/ElkArte/Languages/ManageSettings/English.php index e571151613..3b4ae8ef4f 100644 --- a/sources/ElkArte/Languages/ManageSettings/English.php +++ b/sources/ElkArte/Languages/ManageSettings/English.php @@ -66,7 +66,7 @@ $txt['jquery_cdn'] = 'Google CDN'; $txt['jquery_auto'] = 'Auto'; $txt['minify_css_js'] = 'Minify Javascript and CSS files'; -$txt['clean_hives'] = 'Clear minify cache'; +$txt['clean_hives'] = 'Reset Asset Cache'; $txt['clean_hives_sucess'] = 'CSS and JS hives successfully deleted.'; $txt['clean_hives_failed'] = 'A problem occured, hives have not been deleted.'; $txt['enable_contactform'] = 'Enable contact form'; @@ -244,6 +244,29 @@ $txt['signature_max_font_size'] = 'Maximum font size allowed in signatures (pixels)'; $txt['signature_bbc'] = 'Enabled BBC tags'; +$txt['pwa_settings'] = 'PWA Settings'; +$txt['pwa_settings_desc'] = 'PWA (Progressive Web App) when configured allows your users to install your site as a web app on their device. Read More. When this is enabled it will create a manifest.json file for the site using the below settings. Note the description will be your site slogan (if any) and name will be your forum name.'; +$txt['pwa_enabled'] = 'Enable progressive web app support'; +$txt['pwa_disabled'] = 'PWA requires that the server be using SSL (HTTPS)'; +$txt['pwa_manifest_enabled'] = 'Enable webmanifest support.'; +$txt['pwa_manifest_enabled_desc'] = 'This will provide a "webmanifest" file for your website. This file is required when PWA is enabled, otherwise it is optional.'; +$txt['pwa_short_name'] = 'Short Name'; +$txt['pwa_short_name_desc'] = 'The name of the web application displayed to the user when there is not enough space to display the site name (e.g., as a label for an icon on the phone home screen)'; +$txt['pwa_description'] = 'App Description'; +$txt['pwa_theme_color'] = 'Theme Color'; +$txt['pwa_theme_color_desc'] = 'This sets the theme color used for PWA installations, as well as the address bar color on Chrome for Android.'; +$txt['pwa_background_color'] = 'Background Color'; +$txt['pwa_background_color_desc'] = 'Defines the color that appears in the application window before your app\'s stylesheets have loaded, this should be your background-color CSS property.'; +$txt['pwa_small_icon'] = 'Icon URL (192 x 192)'; +$txt['pwa_small_icon_desc'] = 'The url to your small application icon image. This should be a 192x192 image, PNG or SVG recommended. See /themes/default/images/icon_pwa_small.png for an example'; +$txt['pwa_large_icon'] = 'Icon URL (512 x 512)'; +$txt['pwa_large_icon_desc'] = 'The url to your large application icon image. This should be a 512x512 image, PNG or SVG recommended. See /themes/default/images/icon_pwa_large.png for an example'; +$txt['favicon_icon'] = 'Favicon URL'; +$txt['favicon_icon_desc'] = 'The URL location to the favicon.
The preferred location is your sites base directory to ensure all browsers find the file. The file should be called favicon.ico.
If you choose to use another filename & location, such as mobile.png, be sure you still place a favicon.ico in your sites base directory.'; +$txt['apple_touch_icon'] = 'Apple Touch URL'; +$txt['apple_touch_icon_desc'] = 'The url to your apple touch icon image. This should be a 180x180 image, PNG or SVG recommended. See /themes/default/images/apple-touch-icon.png for an example'; +$txt['other_icons_title'] = 'Additional Icons'; + $txt['groups_pm_send'] = 'Member groups allowed to send personal messages'; $txt['pm_posts_verification'] = 'Post count under which users must pass verification when sending personal messages'; $txt['pm_posts_verification_note'] = '(0 for no limit, admins are exempt)'; diff --git a/sources/ElkArte/ManifestMinimus.php b/sources/ElkArte/ManifestMinimus.php new file mode 100644 index 0000000000..bd14cd6309 --- /dev/null +++ b/sources/ElkArte/ManifestMinimus.php @@ -0,0 +1,195 @@ +prepareAndSendHeaders(); + + echo json_encode($this->getManifestParts(), JSON_PRETTY_PRINT); + } + + protected function prepareAndSendHeaders() + { + $headers = Headers::instance(); + + $expires = gmdate('D, d M Y H:i:s', time() + 86400); + $lastModified = gmdate('D, d M Y H:i:s', time()); + + $headers + ->contentType('application/manifest+json') + ->header('Expires', $expires . ' GMT') + ->header('Last-Modified', $lastModified . ' GMT') + ->header('Cache-Control', 'private, max-age=86400') + ->send(); + } + + protected function getManifestParts(): array + { + global $mbname; + + $manifest = []; + + $manifest['name'] = un_htmlspecialchars($mbname); + $manifest['short_name'] = $this->getShortname(); + $manifest['description'] = $this->getDescription(); + $manifest['lang'] = $this->getLanguageCode(); + $manifest['dir'] = $this->getLanguageDirection(); + $manifest['display'] = $this->getDisplay(); + $manifest['orientation'] = $this->getOrientation(); + $manifest['id'] = $this->getId(); + $manifest['start_url'] = $this->getStartUrl(); + $manifest['scope'] = $this->getScope(); + $manifest['background_color'] = $this->getBackgroundColor(); + $manifest['theme_color'] = $this->getThemeColor(); + $manifest['icons'] = $this->getManifestIcons(); + + return array_filter($manifest); + } + + protected function getDescription() + { + global $settings, $mbname; + + $description = un_htmlspecialchars($settings['site_slogan'] ?? $mbname); + $description = str_replace(['
', '
'], ' ', $description); + + return strip_tags($description); + + } + + protected function getShortname() + { + global $modSettings, $mbname; + + return un_htmlspecialchars($modSettings['pwa_short_name'] ?? $mbname); + } + + protected function getLanguageCode() + { + global $txt; + + $lang = $txt['lang_locale'] ?? 'en-US'; + + return str_replace(['.utf8', '_'], ['', '-'], trim($lang)); + } + + protected function getLanguageDirection() + { + global $txt; + + return !empty($txt['lang_rtl']) ? 'rtl' : 'ltr'; + } + + protected function getDisplay(): string + { + return 'standalone'; + } + + protected function getOrientation(): string + { + return 'any'; + } + + protected function getId() + { + return trim($this->getScope(), '/') . '?elk_pwa=1'; + } + + protected function getScope() + { + return $this->getStartUrl(); + } + + protected function getStartUrl() + { + global $boardurl; + + $parts = parse_url($boardurl); + + return empty($parts['path']) ? '/' : '/' . trim($parts['path'], '/') . '/'; + } + + protected function getBackgroundColor() + { + global $modSettings; + + return $modSettings['pwa_background_color'] ?? '#fafafa'; + } + + protected function getThemeColor() + { + global $modSettings; + + return $modSettings['pwa_theme_color'] ?? '#3d6e32'; + } + + protected function getManifestIcons(): array + { + global $modSettings, $settings; + + $icons = []; + + $iconSmallUrl = $modSettings['pwa_small_icon'] ?? $settings['default_images_url'] . '\icon_pwa_small.png'; + $iconUrlLarge = $modSettings['pwa_large_icon'] ?? $settings['default_images_url'] . '\icon_pwa_large.png'; + + if ($iconSmallUrl) + { + $icon = [ + 'src' => $iconSmallUrl, + 'sizes' => '192x192', + 'purpose' => 'any' + ]; + $icons[] = $icon; + } + + if ($iconUrlLarge) + { + $iconLarge = [ + 'src' => $iconUrlLarge, + 'sizes' => '512x512', + 'purpose' => 'any' + ]; + $icons[] = $iconLarge; + } + + return $icons; + } +} diff --git a/sources/ElkArte/Notifications/UserNotification.php b/sources/ElkArte/Notifications/UserNotification.php index 4933b507fa..e80c8013da 100644 --- a/sources/ElkArte/Notifications/UserNotification.php +++ b/sources/ElkArte/Notifications/UserNotification.php @@ -81,7 +81,7 @@ protected function _addFaviconNumbers($number) { call_integration_hook('integrate_adjust_favicon_number', [&$number]); - loadJavascriptFile('ext/favico.js', ['defer' => true]); + loadJavascriptFile(['ext/favico.js', 'favicon-notify.js'], ['defer' => true]); $notif_opt = []; $rules = [ @@ -130,11 +130,22 @@ protected function _addDesktopNotifications() loadJavascriptFile(['ext/push.min.js', 'desktop-notify.js'], ['defer' => true]); theme()->addInlineJavascript(' document.addEventListener("DOMContentLoaded", function() { - Push.config({serviceWorker: "./elkServiceWorker.min.js"}); + Push.config({serviceWorker: "elkServiceWorker.js"}); + var linkElements = document.head.getElementsByTagName("link"), + iconHref = null; + + // Loop through the link elements to find the shortcut icon + for (var i = 0; i < linkElements.length; i++) { + if (linkElements[i].getAttribute("rel") === "icon") { + iconHref = linkElements[i].getAttribute("href"); + break; + } + } + // Grab the site icon to use in the desktop notification widget ElkNotifier.add(new ElkDesktop( - {"icon": $("head").find("link[rel=\'shortcut icon\']").attr("href")} + {"icon": iconHref} )); });', true); } diff --git a/sources/ElkArte/Themes/Theme.php b/sources/ElkArte/Themes/Theme.php index 3895c82d94..670265e0b4 100644 --- a/sources/ElkArte/Themes/Theme.php +++ b/sources/ElkArte/Themes/Theme.php @@ -424,6 +424,9 @@ public function cleanHives($type = 'all') $result = $result && $combiner->removeJsHives(); } + // Force a cache refresh for the PWA + setPWACacheStale(true); + return $result; } @@ -461,6 +464,75 @@ public function autoEmbedVideo() } } + /** + * Progressive Web App initialization + * + * What it does: + * - Sets up the necessary configurations for the Progressive Web App (PWA). + * - Adds JavaScript variables, loads necessary JavaScript files, and adds inline JavaScript code. + * + * @return void + */ + public function progressiveWebApp() + { + global $modSettings, $boardurl, $settings; + +//$modSettings['pwa_enabled'] = 1==1; + + $this->addJavascriptVar([ + 'elk_board_url' => JavaScriptEscape($boardurl), + ]); + loadJavascriptFile('elk_pwa.js', ['defer' => false]); + + // Not enabled, lets be sure to remove it should it exist + if (empty($modSettings['pwa_enabled'])) + { + $this->addInlineJavascript(' + elkPwa().removeServiceWorker(); + '); + + return; + } + + setPWACacheStale(); + $theme_scope = $this->getScopeFromUrl($settings['actual_theme_url']); + $default_theme_scope = $this->getScopeFromUrl($settings['default_theme_url']); + $sw_scope = $this->getScopeFromUrl($boardurl); + $this->addInlineJavascript(' + document.addEventListener("DOMContentLoaded", function() { + let myOptions = { + swUrl: "elkServiceWorker.js", + swOpt: { + cache_stale: ' . JavaScriptEscape(CACHE_STALE) . ', + cache_id: ' . JavaScriptEscape($modSettings['elk_pwa_cache_stale']) . ', + theme_scope: ' . JavaScriptEscape($theme_scope) . ', + default_theme_scope: ' . JavaScriptEscape($default_theme_scope) . ', + sw_scope: ' . JavaScriptEscape($sw_scope) . ', + } + }; + + let elkPwaInstance = elkPwa(myOptions); + elkPwaInstance.init(); + elkPwaInstance.sendMessage("deleteOldCache", {cache_id: ' . JavaScriptEscape($modSettings['elk_pwa_cache_stale']) . '}); + elkPwaInstance.sendMessage("pruneCache"); + });' + ); + } + + /** + * Get the scope from the given URL + * + * @param string $url The URL from which to extract the scope + * + * @return string The scope extracted from the URL, or the root scope if not found + */ + public function getScopeFromUrl($url) + { + $parts = parse_url($url); + + return empty($parts['path']) ? '/' : '/' . trim($parts['path'], '/') . '/'; + } + /** * If the option to pretty output code is on, this loads the JS and CSS */ @@ -624,7 +696,7 @@ public function setContextShowPmPopup() */ public function setContextThemeData() { - global $context, $scripturl, $settings, $boardurl, $modSettings, $txt; + global $context, $scripturl, $settings, $boardurl, $modSettings, $txt, $mbname; if (empty($settings['theme_version'])) { @@ -633,11 +705,13 @@ public function setContextThemeData() $this->addJavascriptVar(['elk_forum_action' => getUrlQuery('action', $modSettings['default_forum_action'])], true); - $context['page_title'] = $context['page_title'] ?? ''; + $context['page_title'] = $context['page_title'] ?? $mbname; $context['page_title_html_safe'] = Util::htmlspecialchars(un_htmlspecialchars($context['page_title'])) . (empty($context['current_page']) ? '' : ' - ' . $txt['page'] . (' ' . ($context['current_page'] + 1))); - $context['favicon'] = $boardurl . '/mobile.png'; - + $context['favicon'] = $boardurl . '/favicon.ico'; + $context['apple_touch'] = $boardurl . '/themes/default/images/apple-touch-icon.png'; $context['html_headers'] = $context['html_headers'] ?? ''; + $context['theme-color'] = $modSettings['pwa_theme-color'] ?? '#3d6e32'; + $context['pwa_manifest_enabled'] = !empty($modSettings['pwa_manifest_enabled']); } /** diff --git a/sources/Subs.php b/sources/Subs.php index df4606b896..6e9aff150f 100644 --- a/sources/Subs.php +++ b/sources/Subs.php @@ -19,6 +19,7 @@ use ElkArte\Helper\Censor; use ElkArte\Helper\ConstructPageIndex; use ElkArte\Helper\GenericList; +use ElkArte\Helper\TokenHash; use ElkArte\Helper\Util; use ElkArte\Hooks; use ElkArte\Http\Headers; @@ -645,7 +646,7 @@ function obExit($header = null, $do_footer = null, $from_index = false, $from_fa // Has the template/header been done yet? if ($do_header) { - handleMaintance(); + handleMaintenance(); // Was the page title set last minute? Also update the HTML safe one. if (!empty($context['page_title']) && empty($context['page_title_html_safe'])) @@ -704,9 +705,9 @@ function obExit($header = null, $do_footer = null, $from_index = false, $from_fa } /** - * Takes care of a few dynamic maintenance items + * Takes care of a few dynamic maintenance items Maintenance */ -function handleMaintance() +function handleMaintenance() { global $context; @@ -759,7 +760,6 @@ function setOldUrl($index = 'old_url') */ function determineTopicClass(&$topic_context) { - $topic_context['class'] = empty($topic_context['is_poll']) ? 'i-normal' : 'i-poll'; // Set topic class depending on locked status and number of replies. @@ -1906,6 +1906,19 @@ function setJsonTemplate() $context['json_data'] = null; } +function setPWACacheStale($refresh = false) +{ + global $modSettings; + + // We need a PWA cache stale to keep things moving, changing this will trigger a PWA cache flush + if (empty($modSettings['elk_pwa_cache_stale']) || $refresh) + { + $tokenizer = new TokenHash(); + $elk_pwa_cache_stale = $tokenizer->generate_hash(8); + updateSettings(['elk_pwa_cache_stale' => $elk_pwa_cache_stale]); + } +} + /** * Send a 1x1 GIF response and terminate the script execution * @@ -2052,8 +2065,7 @@ function featureEnabled($feature) * * What it does: * - * - Removes invalid XML characters to assure the input string being - * parsed properly. + * - Removes invalid XML characters to assure the input string being parsed properly. * * @param string $string The string to clean * diff --git a/themes/default/Offline.template.php b/themes/default/Offline.template.php new file mode 100644 index 0000000000..5ba73eddce --- /dev/null +++ b/themes/default/Offline.template.php @@ -0,0 +1,82 @@ + + + + + + + + Document + + +
+

', $txt['offline'], '

+

', $txt['check_connection'], '

+ + ', $txt['retry'], ' +
+ +'; +} \ No newline at end of file diff --git a/themes/default/Theme.php b/themes/default/Theme.php index e35e2f33f2..bd49f6da5a 100644 --- a/themes/default/Theme.php +++ b/themes/default/Theme.php @@ -340,6 +340,9 @@ public function loadThemeJavascript() 'todayMod' => empty($modSettings['todayMod']) ? 0 : (int) $modSettings['todayMod']] ); + // PWA? + $this->progressiveWebApp(); + // Auto video embedding enabled, then load the needed JS $this->autoEmbedVideo(); diff --git a/themes/default/index.template.php b/themes/default/index.template.php index 994cec41d3..067106358f 100644 --- a/themes/default/index.template.php +++ b/themes/default/index.template.php @@ -77,7 +77,7 @@ function template_html_above() // Show right to left and the character set for ease of translating. echo ' - + ', $context['page_title_html_safe'], ' '; @@ -91,7 +91,8 @@ function template_html_above() echo ' - '; + + '; // Please don't index these Mr Robot. if (!empty($context['robot_no_index'])) @@ -107,9 +108,6 @@ function template_html_above() ' .implode("\n\t", $context['open_graph']); } - // load in any css from addons or themes, so they can overwrite if wanted - theme()->themeCss()->template_css(); - // Present a canonical url for search engines to prevent duplicate content in their indices. if (!empty($context['canonical_url'])) { @@ -117,9 +115,19 @@ function template_html_above() '; } + // Various icons and optionally a PWA manifest + echo ' + + '; + + if (!empty($context['pwa_manifest_enabled'])) + { + echo ' + '; + } + // Show all the relative links, such as help, search, contents, and the like. echo ' - ', ($context['allow_search'] ? ' ' : ''); @@ -162,10 +170,13 @@ function template_html_above() '; } + // load in css from addons or themes, do it first so overrides are possible + theme()->themeCss()->template_css(); + // load in any javascript files and inline from addons and themes theme()->themeJs()->template_javascript(); - // load in any css files from addons and themes + // load in any inline css files from addons and themes theme()->themeCss()->template_inlinecss(); // Output any remaining HTML headers. (from addons, maybe?) diff --git a/themes/default/scripts/admin.js b/themes/default/scripts/admin.js index ca536af99b..ef038f00dd 100644 --- a/themes/default/scripts/admin.js +++ b/themes/default/scripts/admin.js @@ -29,7 +29,7 @@ /** global: ourLanguageVersions, ourVersions, txt_add_another_answer, txt_permissions_commit, Image */ /** - * Admin index class, its the admin landing page with site details + * Admin index class, it's the admin landing page with site details * * @param {object} oOptions */ @@ -2279,4 +2279,37 @@ function setBoardIds () }); }); }); +} + +/** + * Generates a preview image based on the value of the provided element. + * If the element is undefined or empty, no preview image will be generated. + * + * @param {string} elem - The id of the element to generate the preview for. + */ +function pwaPreview(elem) { + if (typeof elem === 'undefined') + { + return; + } + + let oSelection = document.getElementById(elem); + if (oSelection && oSelection.value !== '') + { + let img = new Image(); + img.src = oSelection.value; + img.id = elem + '_preview'; + img.style.height = '45px'; + img.style.margin = '0 20px'; + + img.onload = function() { + let oldImage = document.getElementById(img.id); + if (oldImage) + { + oldImage.remove(); + } + let imgHTML = ''; + oSelection.insertAdjacentHTML('afterend', imgHTML); + }; + } } \ No newline at end of file diff --git a/themes/default/scripts/elk_pwa.js b/themes/default/scripts/elk_pwa.js new file mode 100644 index 0000000000..ae3094a301 --- /dev/null +++ b/themes/default/scripts/elk_pwa.js @@ -0,0 +1,116 @@ +/*! + * @package ElkArte Forum + * @copyright ElkArte Forum contributors + * @license BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file) + * + * @version 2.0 dev + * + * This registers the service worker for PWA and Push and provides interface functions + */ + +const elkPwa = (opt) => { + let defaults = { + isEnabled: null, + swUrl: 'elkServiceWorker.js', + swOpt: {} + }; + + let settings = Object.assign({}, defaults, opt); + + function init () + { + if (!isEnabled()) + { + return; + } + + // Pass swOpt as Query parameters to the service worker + let params = new URLSearchParams(settings.swOpt).toString(); + let urlWithParams = settings.swUrl + '?' + params; + + if ('serviceWorker' in navigator) + { + navigator.serviceWorker.getRegistration(urlWithParams) + .then((registration) => { + if (!registration) + { + navigator.serviceWorker.register(urlWithParams) + .then(registration => { + if ('console' in window && console.info) + { + console.info('[Info] Service Worker Registered'); + } + }) + .catch(error => { + if ('console' in window && console.error) + { + console.error('[Error] Service worker registration failed:', error); + } + }); + } + }) + .catch((error) => { + if ('console' in window && console.error) + { + console.error('[Error] During getRegistration', error); + } + }); + } + } + + function getScope (checkUrl = '') + { + const url = new URL(checkUrl || elk_board_url); + + return url.pathname === '' ? '/' : '/' + url.pathname.replace(/^\/|\/$/g, ''); + } + + function isEnabled () + { + if (settings.isEnabled === null) + { + settings.isEnabled = !!('serviceWorker' in navigator && (settings.swUrl && settings.swUrl !== '')); + } + + return settings.isEnabled; + } + + // Service Workers don’t take control of the page immediately but on subsequent page loads + function sendMessage (command, opts = {}) + { + if (navigator.serviceWorker.controller) + { + navigator.serviceWorker.controller.postMessage({command, opts}); + } + } + + function removeServiceWorker() + { + // Remove service worker if found + if ('serviceWorker' in navigator) + { + navigator.serviceWorker.getRegistrations() + .then(allRegistrations => { + let scope = getScope(); + + Object.values(allRegistrations).forEach(registration => { + if (getScope(registration.scope) === scope) + { + sendMessage('clearAllCache'); + registration.unregister(); + if ('console' in window && console.info) + { + console.info('[Info] Service worker removed: ', registration.scope); + } + } + }); + }); + } + } + + return { + init, + sendMessage, + removeServiceWorker + }; +}; From f13306d86abf13222548f2744e2cc856b709c61b Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 08:51:14 -0500 Subject: [PATCH 02/10] ! simple test cases --- tests/ElkArte/Controller/OfflineTest.php | 28 +++++++++ tests/ElkArte/ManifestMinimusTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/ElkArte/Controller/OfflineTest.php create mode 100644 tests/ElkArte/ManifestMinimusTest.php diff --git a/tests/ElkArte/Controller/OfflineTest.php b/tests/ElkArte/Controller/OfflineTest.php new file mode 100644 index 0000000000..0fa073be1b --- /dev/null +++ b/tests/ElkArte/Controller/OfflineTest.php @@ -0,0 +1,28 @@ +offlineController = new Offline(); + } + + /** + * Testing action_offline method in Offline Class. + * This method loads the 'Offline' template and its 'offline' subtemplate for the display. + */ + public function testActionOffline(): void + { + $this->expectOutputString('RETRY'); + $this->offlineController->action_offline(); + } +} \ No newline at end of file diff --git a/tests/ElkArte/ManifestMinimusTest.php b/tests/ElkArte/ManifestMinimusTest.php new file mode 100644 index 0000000000..c9875efe14 --- /dev/null +++ b/tests/ElkArte/ManifestMinimusTest.php @@ -0,0 +1,79 @@ + 'Test', 'pwa_background_color' => '#fafafa', 'pwa_theme_color' => '#3d6e32', 'pwa_small_icon' => 'icon_small.png', 'pwa_large_icon' => 'icon_large.png']; + $settings = ['site_slogan' => 'The best test forum', 'default_images_url' => 'default_images']; + $txt = ['lang_locale' => 'en-US.utf8', 'lang_rtl' => 0]; + $boardurl = 'http://www.testforum.com/path'; + + $this->manifestMinimus = new ManifestMinimus(); + } + + /** + * Testing create method in ManifestMinimus. + * + * This test verifies the correctness of the 'create' method in + * the ManifestMinimus class. It checks if a correct JSON is + * echoed. + */ + public function testCreate() + { + // Start output buffering. + ob_start(); + + // Call `create` method. + $this->manifestMinimus->create(); + + // Retrieve and clean output buffer. + $output = ob_get_clean(); + + // Decode JSON output to an array. + $outputDecoded = json_decode($output, true); + + // Verify JSON decoded output is an array. + $this->assertTrue(is_array($outputDecoded)); + + // Writing assertions for conditions to be true. + $this->assertArrayHasKey('name', $outputDecoded); + $this->assertArrayHasKey('short_name', $outputDecoded); + $this->assertArrayHasKey('description', $outputDecoded); + $this->assertArrayHasKey('lang', $outputDecoded); + $this->assertArrayHasKey('dir', $outputDecoded); + $this->assertArrayHasKey('display', $outputDecoded); + $this->assertArrayHasKey('orientation', $outputDecoded); + $this->assertArrayHasKey('id', $outputDecoded); + $this->assertArrayHasKey('start_url', $outputDecoded); + $this->assertArrayHasKey('scope', $outputDecoded); + $this->assertArrayHasKey('background_color', $outputDecoded); + $this->assertArrayHasKey('theme_color', $outputDecoded); + $this->assertArrayHasKey('icons', $outputDecoded); + } +} \ No newline at end of file From 583ed29ca27c4f42b630d2db7b72c843efd5db9b Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 08:59:23 -0500 Subject: [PATCH 03/10] ! sized icons --- favicon.ico | Bin 1150 -> 15086 bytes themes/default/images/apple-touch-icon.png | Bin 0 -> 10121 bytes themes/default/images/icon_pwa_large.png | Bin 0 -> 22210 bytes themes/default/images/icon_pwa_small.png | Bin 0 -> 8781 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 themes/default/images/apple-touch-icon.png create mode 100644 themes/default/images/icon_pwa_large.png create mode 100644 themes/default/images/icon_pwa_small.png diff --git a/favicon.ico b/favicon.ico index f89c137b50b7373ebebe23a6d6801846aac6abb9..d7c6dab39e8610a109685c53461e7316472d056a 100644 GIT binary patch literal 15086 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 literal 1150 zcmah}c~Hz@82%c@G5(Na{AI?`D$*)j+qUiR*v{R^QLdRV2%(|FQP^zRE|ZX4GZ-_C znJ^hbta#hASb4Ca~-tSvXW5(E>_xpbP{=Vn=p7&bDTI&4W_g(NZH}=OIILe5;h}*o>lT$U`h{zZ@j5m zpQO0USxydhT?BKR>}i^7TktWvd_8n)WQ699$G++kjLn~nsDc}ST87@=cxo#S)f~mpWG(W_3Q$>h0bVN&NZoHo{nL9mTzd@u=|O^r z^mC3Xqb;P{AhV`WpQ<9da5`!pT|-dLSloVc7xQ;7!LVgM$S*I%x?>x0uC5BRcP?^r zP+StlVpwLLB)&vEcdDGYSqg@#KwUw;uzEsqghFr9qC zP7RqB%4=|yVW}Au2Yl0HB<)QF|FnN^7yIkhH|Xf-5YKNvy+dUF6md>=SVV3ESBJDp zcG5z6^lMba?u-{_|Ftx}Y{s-Lv!Js{0we1C3(vJq+B^fFzP9}fF8p;*8xWB<3C6Xd zlmogqL8h9J9;%Ptu43VyBpkU~j&gK+bV$;5?m; z*TqDAbL!SvY^c~Gbd=PT!gr+%eWGIZOO|?StRpTe)K?PKq%)SsICDpw=!@&kH&H@0 zJ$kJIvAg2o9UpKY&>qTo|BjlgcXN;QYUG;aU$Jl=p3|(-JQP(G<)5RH!3@K*Uzh{u^ z@L!?2ctmMqJtk;tY0iE~cA7=1kzbvLn(?0V9#s}DfSZ8cgcgD_jVJySt_dmja Ob~?w;a!gZi#(n~Vbv$+e diff --git a/themes/default/images/apple-touch-icon.png b/themes/default/images/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0b0314f4b90fa2287b349e20f4249c9b890810be GIT binary patch literal 10121 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 literal 0 HcmV?d00001 diff --git a/themes/default/images/icon_pwa_large.png b/themes/default/images/icon_pwa_large.png new file mode 100644 index 0000000000000000000000000000000000000000..3c5d127c1d97ca9216fc6391de94cee9dfe95749 GIT binary patch literal 22210 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 NtY@ZM{J`ne{{Z|^oBjX* literal 0 HcmV?d00001 diff --git a/themes/default/images/icon_pwa_small.png b/themes/default/images/icon_pwa_small.png new file mode 100644 index 0000000000000000000000000000000000000000..a40d7fd2ffcb7bd41980c4065300bc3942685c9d GIT binary patch literal 8781 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 literal 0 HcmV?d00001 From 4364903583bee9ddfc0cec8da5bb0ec16209a7aa Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 09:15:15 -0500 Subject: [PATCH 04/10] ! Update tests --- elkManifest.php | 2 +- elkServiceWorker.js | 1 + sources/Subs.php | 6 ++++++ tests/ElkArte/Controller/OfflineTest.php | 3 ++- tests/ElkArte/ManifestMinimusTest.php | 1 + 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/elkManifest.php b/elkManifest.php index c2d60aff8d..67e738eef1 100644 --- a/elkManifest.php +++ b/elkManifest.php @@ -1,6 +1,6 @@ 0) { + /*jshint -W054 */ const func = new Function(funcStr); if (isFunction(func)) { diff --git a/sources/Subs.php b/sources/Subs.php index 6e9aff150f..45e049a228 100644 --- a/sources/Subs.php +++ b/sources/Subs.php @@ -697,6 +697,12 @@ function obExit($header = null, $do_footer = null, $from_index = false, $from_fa // Hand off the output to the portal, etc. we're integrated with. call_integration_hook('integrate_exit', [$do_footer]); + // Allow a way for phpunit to run controller methods, pretty? no but allows us to return to the test + if (defined('PHPUNITBOOTSTRAP') && defined('STDIN')) + { + return; + } + // Don't exit if we're coming from index.php; that will pass through normally. if (!$from_index) { diff --git a/tests/ElkArte/Controller/OfflineTest.php b/tests/ElkArte/Controller/OfflineTest.php index 0fa073be1b..4c075cadb3 100644 --- a/tests/ElkArte/Controller/OfflineTest.php +++ b/tests/ElkArte/Controller/OfflineTest.php @@ -2,6 +2,7 @@ namespace ElkArte\Controller; +use ElkArte\EventManager; use tests\ElkArteCommonSetupTest; class OfflineTest extends ElkArteCommonSetupTest @@ -13,7 +14,7 @@ class OfflineTest extends ElkArteCommonSetupTest public function setUp(): void { - $this->offlineController = new Offline(); + $this->offlineController = new Offline(new EventManager()); } /** diff --git a/tests/ElkArte/ManifestMinimusTest.php b/tests/ElkArte/ManifestMinimusTest.php index c9875efe14..3603a40350 100644 --- a/tests/ElkArte/ManifestMinimusTest.php +++ b/tests/ElkArte/ManifestMinimusTest.php @@ -34,6 +34,7 @@ protected function setUp(): void $txt = ['lang_locale' => 'en-US.utf8', 'lang_rtl' => 0]; $boardurl = 'http://www.testforum.com/path'; + require_once(SOURCEDIR . '/Subs.php'); $this->manifestMinimus = new ManifestMinimus(); } From ecb072a004d6cea5ee79a0da92d154fc8dc283aa Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 10:02:16 -0500 Subject: [PATCH 05/10] ! lets make those bypasses part of the test script ! additional test fixes --- .github/setup-elkarte.sh | 7 +++++++ sources/ElkArte/Http/Headers.php | 2 +- sources/Subs.php | 12 ------------ themes/default/Offline.template.php | 17 +++++++---------- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/.github/setup-elkarte.sh b/.github/setup-elkarte.sh index ad697e66d3..b65e35f4e6 100755 --- a/.github/setup-elkarte.sh +++ b/.github/setup-elkarte.sh @@ -33,3 +33,10 @@ then composer remove phpunit/phpunit phpunit/phpunit-selenium --dev composer require phpunit/phpunit:9.3.11 phpunit/phpunit-selenium:9.0.1 --dev --update-with-all-dependencies --ignore-platform-reqs fi + +# Provide a way to return from actions redirectexit & obexit, so we can get results for Unit Test +if [[ "$WEBSERVER" == "none" ]] +then + sudo sed -i '/global $db_show_debug;/a \n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return $setLocation;}' ./sources/Subs.php + sudo sed -i '/call_integration_hook('"'"'integrate_exit'"'"', [$do_footer]);/a \n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return;}' ./sources/Subs.php +fi \ No newline at end of file diff --git a/sources/ElkArte/Http/Headers.php b/sources/ElkArte/Http/Headers.php index b16640a1d6..ba8ba106ad 100644 --- a/sources/ElkArte/Http/Headers.php +++ b/sources/ElkArte/Http/Headers.php @@ -99,7 +99,7 @@ public function redirect($setLocation = '', $httpCode = null) */ public function send() { - handleMaintance(); + handleMaintenance(); $this->sendHeaders(); } diff --git a/sources/Subs.php b/sources/Subs.php index 45e049a228..4c64d2aeb4 100644 --- a/sources/Subs.php +++ b/sources/Subs.php @@ -585,12 +585,6 @@ function redirectexit($setLocation = '') { global $db_show_debug; - // Allow a way for phpunit to run controller methods, pretty? no but allows us to return to the test - if (defined('PHPUNITBOOTSTRAP') && defined('STDIN')) - { - return $setLocation; - } - // Send headers, call integration, do maintance Headers::instance() ->removeHeader('all') @@ -697,12 +691,6 @@ function obExit($header = null, $do_footer = null, $from_index = false, $from_fa // Hand off the output to the portal, etc. we're integrated with. call_integration_hook('integrate_exit', [$do_footer]); - // Allow a way for phpunit to run controller methods, pretty? no but allows us to return to the test - if (defined('PHPUNITBOOTSTRAP') && defined('STDIN')) - { - return; - } - // Don't exit if we're coming from index.php; that will pass through normally. if (!$from_index) { diff --git a/themes/default/Offline.template.php b/themes/default/Offline.template.php index 5ba73eddce..08d6069c62 100644 --- a/themes/default/Offline.template.php +++ b/themes/default/Offline.template.php @@ -1,16 +1,13 @@ Date: Thu, 28 Mar 2024 11:43:25 -0500 Subject: [PATCH 06/10] ! fix sed command --- .github/setup-elkarte.sh | 4 ++-- .github/setup-selenium.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/setup-elkarte.sh b/.github/setup-elkarte.sh index b65e35f4e6..340f7cb0e9 100755 --- a/.github/setup-elkarte.sh +++ b/.github/setup-elkarte.sh @@ -37,6 +37,6 @@ fi # Provide a way to return from actions redirectexit & obexit, so we can get results for Unit Test if [[ "$WEBSERVER" == "none" ]] then - sudo sed -i '/global $db_show_debug;/a \n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return $setLocation;}' ./sources/Subs.php - sudo sed -i '/call_integration_hook('"'"'integrate_exit'"'"', [$do_footer]);/a \n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return;}' ./sources/Subs.php + sudo sed -i '/global $db_show_debug;/a \\n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return $setLocation;}' ./sources/Subs.php + sudo sed -i '/call_integration_hook('"'"'integrate_exit'"'"', \[$do_footer\]);/a \\n\tif (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return;}' ./sources/Subs.php fi \ No newline at end of file diff --git a/.github/setup-selenium.sh b/.github/setup-selenium.sh index ddac29cde8..42ddea26a8 100755 --- a/.github/setup-selenium.sh +++ b/.github/setup-selenium.sh @@ -35,7 +35,7 @@ echo "Installing Browser" # CHROME_VERSION='110.0.5481.100-1' # '91.0.4472.114-1' -wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb -q +sudo wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb -q sudo dpkg -i google-chrome-stable_${CHROME_VERSION}_amd64.deb # Download Chrome Driver From 5c8f3dff2f74330fd41dd2dd1cb1d9457a16a7a8 Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 12:20:33 -0500 Subject: [PATCH 07/10] ! that version of chrome package has gone walkies ??? WTF --- .github/setup-selenium.sh | 4 ++-- tests/ElkArte/Controller/OfflineTest.php | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/setup-selenium.sh b/.github/setup-selenium.sh index 42ddea26a8..484df467ee 100755 --- a/.github/setup-selenium.sh +++ b/.github/setup-selenium.sh @@ -33,9 +33,9 @@ echo "Installing Browser" # Available Chrome Versions # https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable?id=202706 # -CHROME_VERSION='110.0.5481.100-1' # '91.0.4472.114-1' +CHROME_VERSION='112.0.5615.49-1' #'110.0.5481.100-1' # '91.0.4472.114-1' -sudo wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb -q +wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb sudo dpkg -i google-chrome-stable_${CHROME_VERSION}_amd64.deb # Download Chrome Driver diff --git a/tests/ElkArte/Controller/OfflineTest.php b/tests/ElkArte/Controller/OfflineTest.php index 4c075cadb3..2e03489c57 100644 --- a/tests/ElkArte/Controller/OfflineTest.php +++ b/tests/ElkArte/Controller/OfflineTest.php @@ -23,7 +23,11 @@ public function setUp(): void */ public function testActionOffline(): void { - $this->expectOutputString('RETRY'); + //$this->expectOutputString('RETRY'); + obStart(); $this->offlineController->action_offline(); + $output = ob_get_clean(); + + $this->assertStringContainsString('RETRY', $output); } } \ No newline at end of file From e5a5f83cf830e83eefd8cc0058223c8cf3efc436 Mon Sep 17 00:00:00 2001 From: Spuds Date: Thu, 28 Mar 2024 13:12:50 -0500 Subject: [PATCH 08/10] ! try to get some failure output --- .github/setup-selenium.sh | 9 ++++----- sources/Subs.php | 6 ++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/setup-selenium.sh b/.github/setup-selenium.sh index 484df467ee..b7bcee9240 100755 --- a/.github/setup-selenium.sh +++ b/.github/setup-selenium.sh @@ -33,13 +33,12 @@ echo "Installing Browser" # Available Chrome Versions # https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable?id=202706 # -CHROME_VERSION='112.0.5615.49-1' #'110.0.5481.100-1' # '91.0.4472.114-1' - -wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb -sudo dpkg -i google-chrome-stable_${CHROME_VERSION}_amd64.deb +CHROME_VERSION='113.0.5672.63-1' #'110.0.5481.100-1' # '91.0.4472.114-1' +wget -v -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb +sudo dpkg -i /tmp/chrome.deb # Download Chrome Driver -echo "Downloading chromedriver" +echo "Downloading Browser Driver" CHROME_VERSION=$(google-chrome --version | cut -f 3 -d ' ' | cut -d '.' -f 1) \ && CHROMEDRIVER_RELEASE=$(curl --location --fail --retry 3 https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_VERSION}) \ && wget -nv -O "$CHROMEDRIVER_ZIP" "https://chromedriver.storage.googleapis.com/$CHROMEDRIVER_RELEASE/chromedriver_linux64.zip" \ diff --git a/sources/Subs.php b/sources/Subs.php index 4c64d2aeb4..30c621465f 100644 --- a/sources/Subs.php +++ b/sources/Subs.php @@ -585,6 +585,9 @@ function redirectexit($setLocation = '') { global $db_show_debug; + // Note to developers. The testbed will add the following, allowing phpunit test returns + //if (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return $setLocation;} + // Send headers, call integration, do maintance Headers::instance() ->removeHeader('all') @@ -691,6 +694,9 @@ function obExit($header = null, $do_footer = null, $from_index = false, $from_fa // Hand off the output to the portal, etc. we're integrated with. call_integration_hook('integrate_exit', [$do_footer]); + // Note to developers. The testbed will add the following, allowing phpunit test returns + //if (defined("PHPUNITBOOTSTRAP") && defined("STDIN")){return;} + // Don't exit if we're coming from index.php; that will pass through normally. if (!$from_index) { From 0d030c418811c66f4839c45ac4754031b5f950d5 Mon Sep 17 00:00:00 2001 From: Spuds Date: Fri, 29 Mar 2024 08:08:56 -0500 Subject: [PATCH 09/10] ! try actions --- .github/setup-selenium.sh | 59 +++++------------------------------- .github/workflows/tests.yaml | 16 +++++++++- 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/.github/setup-selenium.sh b/.github/setup-selenium.sh index b7bcee9240..1c6eb7b360 100755 --- a/.github/setup-selenium.sh +++ b/.github/setup-selenium.sh @@ -6,58 +6,15 @@ set -e set -x -# Access passed params -DB=$1 -PHP_VERSION=$2 - -# Some vars to make this easy to change -SELENIUM_HUB_URL='http://127.0.0.1:4444' -SELENIUM_JAR=/usr/share/selenium/selenium-server-standalone.jar -SELENIUM_DOWNLOAD_URL=https://selenium-release.storage.googleapis.com/3.141/selenium-server-standalone-3.141.59.jar - -# Location of geckodriver for use as webdriver in xvfb -GECKODRIVER_DOWNLOAD_URL=https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz -GECKODRIVER_TAR=/tmp/geckodriver.tar.gz - -# Location of chromedriver for use as webdriver in xvfb -CHROMEDRIVER_ZIP=/tmp/chromedriver_linux64.zip - -# Download Selenium -echo "Downloading Selenium" -sudo mkdir -p $(dirname "$SELENIUM_JAR") -sudo wget -nv -O "$SELENIUM_JAR" "$SELENIUM_DOWNLOAD_URL" - -# Install Fx or Chrome -echo "Installing Browser" -# sudo apt install firefox -y -qq > /dev/null -# Available Chrome Versions -# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable?id=202706 +# Per actions in the tests.yaml file # -CHROME_VERSION='113.0.5672.63-1' #'110.0.5481.100-1' # '91.0.4472.114-1' -wget -v -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb -sudo dpkg -i /tmp/chrome.deb - -# Download Chrome Driver -echo "Downloading Browser Driver" -CHROME_VERSION=$(google-chrome --version | cut -f 3 -d ' ' | cut -d '.' -f 1) \ - && CHROMEDRIVER_RELEASE=$(curl --location --fail --retry 3 https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_VERSION}) \ - && wget -nv -O "$CHROMEDRIVER_ZIP" "https://chromedriver.storage.googleapis.com/$CHROMEDRIVER_RELEASE/chromedriver_linux64.zip" \ - && unzip "$CHROMEDRIVER_ZIP" \ - && rm -rf "$CHROMEDRIVER_ZIP" \ - && sudo mv chromedriver /usr/local/bin/chromedriver \ - && sudo chmod +x /usr/local/bin/chromedriver \ - && chromedriver --version +# Current Versions for Ref +# Selenium 3.141.59 jar +# Chrome 123.0.6312.58 +# ChromeDriver 123.0.6312.58 -# Download Gecko driver -#echo "Downloading geckodriver" -#wget -nv -O "$GECKODRIVER_TAR" "$GECKODRIVER_DOWNLOAD_URL" \ -# && sudo tar -xvf "$GECKODRIVER_TAR" -C "/usr/local/bin/" \ -# && sudo chmod +x /usr/local/bin/geckodriver \ -# && geckodriver --version - -# Start Selenium using default chosen webdriver -export DISPLAY=:99.0 -xvfb-run --server-args="-screen 0, 2560x1440x24" java -Dwebdriver.chrome.driver=/usr/local/bin/chromedriver -jar "$SELENIUM_JAR" > /tmp/selenium.log & +echo "Ensuring Selenium Started" +SELENIUM_HUB_URL='http://127.0.0.1:4444' wget --retry-connrefused --tries=120 --waitretry=3 --output-file=/dev/null "$SELENIUM_HUB_URL/wd/hub/status" -O /dev/null # Test to see if the selenium server really did start @@ -76,7 +33,7 @@ else # Copy RemoteCoverage.php back to vendor, this version supports phpunit RawCodeCoverageData sudo cp ./tests/RemoteCoverage.php ./vendor/phpunit/phpunit-selenium/PHPUnit/Extensions/SeleniumCommon - # This keeps triggering in tests for the 2 second rule + # This keeps triggering in tests for the 2 second rule, lets try to fix that sudo sed -i -e "s|spamProtection('login');|//spamProtection('login');|g" ./sources/ElkArte/Controller/Auth.php # Run the phpunit selenium tests diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6701474ec6..c19fb36cbb 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -41,7 +41,7 @@ jobs: - name: Checkout ElkArte uses: actions/checkout@v4 with: - repository: Spuds/Elkarte + repository: elkarte/elkarte fetch-depth: 10 ref: ${{ env.ELKARTE_BRANCH }} path: elkarte @@ -78,6 +78,20 @@ jobs: run: .github/setup-elkarte.sh $DB $PHP_VERSION working-directory: ./elkarte + - name: Download Selenium + run: | + sudo mkdir -p /usr/share/selenium + wget -nv -O /usr/share/selenium/selenium-server-standalone.jar https://selenium-release.storage.googleapis.com/3.141/selenium-server-standalone-3.141.59.jar + sudo chmod 777 /usr/share/selenium/selenium-server-standalone.jar + + - name: Setup ChromeDriver + uses: nanasess/setup-chromedriver@v2 + + - name: Start ChromeDriver + run: | + export DISPLAY=:99.0 + xvfb-run --server-args="-screen 0, 2560x1440x24" java -Dwebdriver.chrome.driver=/usr/local/bin/chromedriver -jar /usr/share/selenium/selenium-server-standalone.jar > /tmp/selenium.log & + - name: Run Unit Tests env: DB: ${{ matrix.db }} From 6d9ab46fd17a2bc0ef6e1f7cf0f30432abb5b7ab Mon Sep 17 00:00:00 2001 From: Spuds Date: Sat, 30 Mar 2024 09:31:31 -0500 Subject: [PATCH 10/10] ! that is only a bool on inline --- sources/ElkArte/AdminController/ManageFeatures.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/ElkArte/AdminController/ManageFeatures.php b/sources/ElkArte/AdminController/ManageFeatures.php index f68aded9d8..ad1aad5fc0 100644 --- a/sources/ElkArte/AdminController/ManageFeatures.php +++ b/sources/ElkArte/AdminController/ManageFeatures.php @@ -462,7 +462,7 @@ public function action_pwaSettings_display() pwaPreview("pwa_small_icon"); pwaPreview("pwa_large_icon"); pwaPreview("favicon_icon"); - pwaPreview("apple_touch_icon");', ['defer' => true]); + pwaPreview("apple_touch_icon");', true); $settingsForm->prepare(); }