diff --git a/.github/setup-elkarte.sh b/.github/setup-elkarte.sh index ad697e66d3..340f7cb0e9 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/.github/setup-selenium.sh b/.github/setup-selenium.sh index ddac29cde8..1c6eb7b360 100755 --- a/.github/setup-selenium.sh +++ b/.github/setup-selenium.sh @@ -6,59 +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='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 dpkg -i google-chrome-stable_${CHROME_VERSION}_amd64.deb - -# Download Chrome Driver -echo "Downloading chromedriver" -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 @@ -77,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 }} diff --git a/elkManifest.php b/elkManifest.php new file mode 100644 index 0000000000..67e738eef1 --- /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..e1d06c225a --- /dev/null +++ b/elkServiceWorker.js @@ -0,0 +1,571 @@ +/*! + * @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) + { + /*jshint -W054 */ + 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/favicon.ico b/favicon.ico index f89c137b50..d7c6dab39e 100644 Binary files a/favicon.ico and b/favicon.ico differ 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..ad1aad5fc0 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");', 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/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/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..30c621465f 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; @@ -584,11 +585,8 @@ 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; - } + // 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() @@ -645,7 +643,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'])) @@ -696,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) { @@ -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/tests/ElkArte/Controller/OfflineTest.php b/tests/ElkArte/Controller/OfflineTest.php new file mode 100644 index 0000000000..2e03489c57 --- /dev/null +++ b/tests/ElkArte/Controller/OfflineTest.php @@ -0,0 +1,33 @@ +offlineController = new Offline(new EventManager()); + } + + /** + * 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'); + obStart(); + $this->offlineController->action_offline(); + $output = ob_get_clean(); + + $this->assertStringContainsString('RETRY', $output); + } +} \ No newline at end of file diff --git a/tests/ElkArte/ManifestMinimusTest.php b/tests/ElkArte/ManifestMinimusTest.php new file mode 100644 index 0000000000..3603a40350 --- /dev/null +++ b/tests/ElkArte/ManifestMinimusTest.php @@ -0,0 +1,80 @@ + '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'; + + require_once(SOURCEDIR . '/Subs.php'); + $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 diff --git a/themes/default/Offline.template.php b/themes/default/Offline.template.php new file mode 100644 index 0000000000..08d6069c62 --- /dev/null +++ b/themes/default/Offline.template.php @@ -0,0 +1,79 @@ + + + + + + + + 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/images/apple-touch-icon.png b/themes/default/images/apple-touch-icon.png new file mode 100644 index 0000000000..0b0314f4b9 Binary files /dev/null and b/themes/default/images/apple-touch-icon.png differ diff --git a/themes/default/images/icon_pwa_large.png b/themes/default/images/icon_pwa_large.png new file mode 100644 index 0000000000..3c5d127c1d Binary files /dev/null and b/themes/default/images/icon_pwa_large.png differ diff --git a/themes/default/images/icon_pwa_small.png b/themes/default/images/icon_pwa_small.png new file mode 100644 index 0000000000..a40d7fd2ff Binary files /dev/null and b/themes/default/images/icon_pwa_small.png differ 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 + }; +};