diff --git a/app/asset/favicon/mstile-310x310.png b/app/asset/favicon/mstile-310x310.png index 1d9fac1f..e54f68fd 100644 Binary files a/app/asset/favicon/mstile-310x310.png and b/app/asset/favicon/mstile-310x310.png differ diff --git a/app/asset/img/livecodes-logo.svg b/app/asset/img/livecodes-logo.svg deleted file mode 100644 index 7bd508bd..00000000 --- a/app/asset/img/livecodes-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/asset/manifest.json b/app/asset/manifest.json index b36859b2..73927c3f 100644 --- a/app/asset/manifest.json +++ b/app/asset/manifest.json @@ -1,6 +1,7 @@ { "short_name": "Codemo", "name": "Codemo Digital Nomad", + "lang": "en-US", "icons": [ { "src": "https://gigamaster.github.io/codemo/asset/favicon/apple-touch-icon.png", diff --git a/app/asset/service-worker.js b/app/asset/service-worker.js new file mode 100644 index 00000000..ddd9c798 --- /dev/null +++ b/app/asset/service-worker.js @@ -0,0 +1,353 @@ +// service worker version number +const SW_VERSION = 4; + +// cache name including version number +const cacheName = `web-app-cache-${SW_VERSION}`; + +// static files to cache +const staticFiles = [ + '/codemo/asset/sw-registration.js', + '/codemo/index.html', + '/codemo/about/index.html', + '/codemo/asset/manifest.json', + '/codemo/asset/offline.html', + '/codemo/asset/favicon/android-chrome-192x192.png', + '/codemo/asset/favicon/android-chrome-512x512.png', +]; + +// routes to cache +const routes = [ + '/codemo', + '/codemo/tools/tasks/index.html', + '/codemo/tools/notes/', +]; + +// combine static files and routes to cache +const filesToCache = [ + ...routes, + ...staticFiles, +]; + +const requestsToRetryWhenOffline = []; + +const IDBConfig = { + name: 'web-app-db', + version: SW_VERSION, + stores: { + requestStore: { + name: `request-store`, + keyPath: 'timestamp' + } + } +}; + +// returns if the app is offline +const isOffline = () => !self.navigator.onLine; + +// return if a request should be retried when offline, in this example, all POST, PUT, DELETE requests +// and requests that are listed in the requestsToRetryWhenOffline array +// you can adapt this function to your specific needs +const isRequestEligibleForRetry = ({url, method}) => { + return ['POST', 'PUT', 'DELETE'].includes(method) || requestsToRetryWhenOffline.includes(url); +}; + +const createIndexedDB = ({name, stores}) => { + const request = self.indexedDB.open(name, 1); + + return new Promise((resolve, reject) => { + request.onupgradeneeded = e => { + const db = e.target.result; + + Object.keys(stores).forEach((store) => { + const {name, keyPath} = stores[store]; + + if(!db.objectStoreNames.contains(name)) { + db.createObjectStore(name, {keyPath}); + console.log('create objectstore', name); + } + }); + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +const getStoreFactory = (dbName) => ({name}, mode = 'readonly') => { + return new Promise((resolve, reject) => { + + const request = self.indexedDB.open(dbName, 1); + + request.onsuccess = e => { + const db = request.result; + const transaction = db.transaction(name, mode); + const store = transaction.objectStore(name); + + // return a proxy object for the IDBObjectStore, allowing for promise-based access to methods + const storeProxy = new Proxy(store, { + get(target, prop) { + if(typeof target[prop] === 'function') { + return (...args) => new Promise((resolve, reject) => { + const req = target[prop].apply(target, args); + + req.onsuccess = () => resolve(req.result); + req.onerror = err => reject(err); + }); + } + + return target[prop]; + }, + }); + + return resolve(storeProxy); + }; + + request.onerror = e => reject(request.error); + }); +}; + +const openStore = getStoreFactory(IDBConfig.name); + +// serialize request headers for storage in IndexedDB +const serializeHeaders = (headers) => [...headers.entries()].reduce((acc, [key, value]) => ({ + ...acc, + [key]: value +}), {}); + +// store the request in IndexedDB +const storeRequest = async ({url, method, body, headers, mode, credentials}) => { + const serializedHeaders = serializeHeaders(headers); + + try { + // Read the body stream and convert it to text or ArrayBuffer + let storedBody = body; + + if(body && body instanceof ReadableStream) { + const clonedBody = body.tee()[0]; + storedBody = await new Response(clonedBody).arrayBuffer(); + } + + const timestamp = Date.now(); + const store = await openStore(IDBConfig.stores.requestStore, 'readwrite'); + + await store.add({ + timestamp, + url, + method, + ...(storedBody && {body: storedBody}), + headers: serializedHeaders, + mode, + credentials + }); + + // register a sync event for retrying failed requests if Background Sync is supported + if('sync' in self.registration) { + console.log('register sync for retry request'); + await self.registration.sync.register(`retry-request`); + } + } + catch(error) { + console.log('idb error', error); + } +}; + +// get the names of the caches of the current Service Worker and any outdated ones +const getCacheStorageNames = async () => { + const cacheNames = await caches.keys() || []; + const outdatedCacheNames = cacheNames.filter(name => !name.includes(cacheName)); + const latestCacheName = cacheNames.find(name => name.includes(cacheName)); + + return {latestCacheName, outdatedCacheNames}; +}; + + +// update outdated caches with the content of the latest one so new content is served immediately +// when the Service Worker is updated but it can't serve this new content yet on the first navigation or reload +const updateLastCache = async () => { + const {latestCacheName, outdatedCacheNames} = await getCacheStorageNames(); + if(!latestCacheName || !outdatedCacheNames?.length) { + return null; + } + + const latestCache = await caches.open(latestCacheName); + const latestCacheEntries = (await latestCache?.keys())?.map(c => c.url) || []; + + for(const outdatedCacheName of outdatedCacheNames) { + const outdatedCache = await caches.open(outdatedCacheName); + + for(const entry of latestCacheEntries) { + const latestCacheResponse = await latestCache.match(entry); + + await outdatedCache.put(entry, latestCacheResponse.clone()); + } + } +}; + +// get all requests from IndexedDB that were stored when the app was offline +const getRequests = async () => { + try { + const store = await openStore(IDBConfig.stores.requestStore, 'readwrite'); + return await store.getAll(); + } + catch(err) { + return err; + } +}; + +// retry failed requests that were stored in IndexedDB when the app was offline +const retryRequests = async () => { + const reqs = await getRequests(); + const requests = reqs.map(({url, method, headers: serializedHeaders, body, mode, credentials}) => { + const headers = new Headers(serializedHeaders); + + return fetch(url, {method, headers, body, mode, credentials}); + }); + + const responses = await Promise.allSettled(requests); + const requestStore = await openStore(IDBConfig.stores.requestStore, 'readwrite'); + const {keyPath} = IDBConfig.stores.requestStore; + + responses.forEach((response, index) => { + const key = reqs[index][keyPath]; + + // remove the request from IndexedDB if the response was successful + if(response.status === 'fulfilled') { + requestStore.delete(key); + } + else { + console.log(`retrying response with ${keyPath} ${key} failed: ${response.reason}`); + } + }); +}; + +// cache all files and routes when the Service Worker is installed +// add {cache: 'no-cache'} } to all requests to bypass the browser cache so content is always fetched from the server +const installHandler = e => { + e.waitUntil( + caches.open(cacheName) + .then((cache) => Promise.all([ + cache.addAll(filesToCache.map(file => new Request(file, {cache: 'no-cache'}))), + createIndexedDB(IDBConfig) + ])) + .catch(err => console.error('install error', err)) + ); +}; + +// delete any outdated caches when the Service Worker is activated +const activateHandler = e => { + e.waitUntil( + caches.keys() + .then(names => Promise.all( + names + .filter(name => name !== cacheName) + .map(name => caches.delete(name)) + )) + ); +}; + +// in case the caches response is a redirect, we need to clone it to set its "redirected" property to false +// otherwise the Service Worker will throw an error since this is a security restriction +const cleanRedirect = async (response) => { + const clonedResponse = response.clone(); + const {headers, status, statusText} = clonedResponse; + + return new Response(clonedResponse.body, { + headers, + status, + statusText, + }); +}; + +// the fetch event handler for the Service Worker that is invoked for each request +const fetchHandler = async e => { + const {request} = e; + + e.respondWith( + (async () => { + try { + // store requests to IndexedDB that are eligible for retry when offline and return the offline page + // as response so no error is logged + if(isOffline() && isRequestEligibleForRetry(request)) { + console.log('storing request', request); + await storeRequest(request); + + return await caches.match('/offline.html'); + } + + // try to get the response from the cache + const response = await caches.match(request, {ignoreVary: true, ignoreSearch: true}); + if(response) { + return response.redirected ? cleanRedirect(response) : response; + } + + // if not in the cache, try to fetch the response from the network + const fetchResponse = await fetch(e.request); + if(fetchResponse) { + return fetchResponse; + } + } + catch(err) { + // a fetch error occurred, serve the offline page since we don't have a cached response + return await caches.match('/offline.html'); + } + })() + ); + +}; + + +// message handler for communication between the main thread and the Service Worker through postMessage +const messageHandler = async ({data}) => { + const {type} = data; + + switch(type) { + case 'SKIP_WAITING': + const clients = await self.clients.matchAll({ + includeUncontrolled: true, + }); + + // if the Service Worker is serving 1 client at most, it can be safely skip waiting to update immediately + if(clients.length < 2) { + await self.skipWaiting(); + await self.clients.claim(); + } + + break; + + // move the files of the new cache to the old one so when the user navigates to another page or reloads the + // current one, the new content will be served immediately + case 'PREPARE_CACHES_FOR_UPDATE': + await updateLastCache(); + + break; + + // retry any requests that were stored in IndexedDB when the app was offline in browsers that don't + // support Background Sync + case 'retry-requests': + if(!('sync' in self.registration)) { + console.log('retry requests when Background Sync is not supported'); + await retryRequests(); + } + + break; + } +}; + +const syncHandler = async e => { + console.log('sync event with tag:', e.tag); + + const {tag} = e; + + switch(tag) { + case 'retry-request': + e.waitUntil(retryRequests()); + + break; + } +}; + +self.addEventListener('install', installHandler); +self.addEventListener('activate', activateHandler); +self.addEventListener('fetch', fetchHandler); +self.addEventListener('message', messageHandler); +self.addEventListener('sync', syncHandler); diff --git a/app/asset/sw-registration.js b/app/asset/sw-registration.js new file mode 100644 index 00000000..20748f2d --- /dev/null +++ b/app/asset/sw-registration.js @@ -0,0 +1,91 @@ +if('serviceWorker' in navigator) { + window.addEventListener('load', async () => { + const registerServiceWorker = async () => { + const registration = await navigator.serviceWorker.register('./service-worker.js'); + const newServiceWorkerWaiting = registration.waiting && registration.active; + + // if there is already a new Service Worker waiting when the page is loaded, skip waiting to update immediately + if(newServiceWorkerWaiting) { + console.log('new sw waiting'); + window.swUpdate = true; + await SWHelper.skipWaiting(); + } + + // listen for service worker updates + registration.addEventListener('updatefound', () => { + const installingWorker = registration.installing; + + // installing Service Worker found + if(installingWorker) { + console.log('installing sw found'); + installingWorker.addEventListener('statechange', async () => { + // the new Service Worker is installed and waiting to be activated + // the outdated caches can be updated and the Service Worker will be activated on the next navigation or reload + if(installingWorker.state === 'installed' && navigator.serviceWorker.controller) { + console.log('new sw installed'); + + window.swUpdate = true; + + setTimeout(async () => { + + // move the files of the new cache to the old one so when the user navigates to another page or reloads the + // current one, the new version will be served immediately. At the same time, this navigation or reload will + // cause the waiting service worker to be activated. + await SWHelper.prepareCachesForUpdate(); + }, 500); + } + }); + } + }); + }; + + registerServiceWorker(); + + const SWHelper = { + async getWaitingWorker() { + const registrations = await navigator.serviceWorker.getRegistrations(); + const registrationWithWaiting = registrations.find(reg => reg.waiting); + return registrationWithWaiting?.waiting; + }, + + async skipWaiting() { + return (await SWHelper.getWaitingWorker())?.postMessage({type: 'SKIP_WAITING'}); + }, + + async prepareCachesForUpdate() { + return (await SWHelper.getWaitingWorker())?.postMessage({type: 'PREPARE_CACHES_FOR_UPDATE'}); + } + }; + + const updateServiceWorkerIfNeeded = async (e) => { + if(window.swUpdate) { + // set swUpdate to false to avoid multiple calls to skipWaiting which can cause the service worker + // to stay in the waiting state + window.swUpdate = false; + await SWHelper.skipWaiting(); + } + }; + + const retryRequests = () => navigator.serviceWorker.controller.postMessage({type: 'retry-requests'}); + + // check if the Service Worker needs to be updated on page navigation or reload + // beforeunload is reliably triggered on desktop, pagehide is more reliable on mobile and is the only event that is + // fired when the user closes the app from the app switcher. + // NOTE: on iOS, the pagehide event is only fired when the app is added to the Home Screen and the user closes it + // from the app switcher. + window.addEventListener('beforeunload', updateServiceWorkerIfNeeded); + window.addEventListener('pagehide', updateServiceWorkerIfNeeded); + + // send a message to the Service Worker to retry any requests that were stored + // when the user was offline + // in browsers that support Background Sync, this will be handled by the Sync event + window.addEventListener('online', retryRequests); + + // retry any requests that were stored in IndexedDB when the app was offline + // we need to run this function when the page is loaded otherwise it will only be triggered when the app + // comes back online + // if the app is closed while offline and reopened when online, the online event will not be triggered, + // so we need to manually call this function + retryRequests(); + }); +} diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 00000000..ec8314a7 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,74 @@ + + +http://gigamaster.github.io/codemo2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/alert2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/animation2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/button2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/card2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/chart2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/codeblock2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/color2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/css2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/dropdown2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/form2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/javascript2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/list2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/modal2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/native2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/components/typography2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/city-roads2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/global-arms-3d2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/global-arms-sales2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/mark-twain-portrait2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/package-manager2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/primerpedia-master2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/dataviz/women-in-computing2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/game2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/game/engine2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/game/play2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/game/tools2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/gen-ai2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/gen-ai/web-ai-demos2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/map-travel2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia/audio2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia/graphic2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia/photography2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia/video2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/multimedia/webgl2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/private2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/archimagine-report2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/archimate-graph-explorer2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/archimate-smart-gov2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/archimate-viewer2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/cmmn-modeler2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/project/sparql-data-gui2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/rd2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/samples2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/samples/common-extensions2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/template2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/template/adaptive2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/template/layout2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/template/responsive2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/tools2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/tools/notes2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/tools/tasks2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/dpaint2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/drawio2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/encrypt2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/erd-editor2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/fuxa-hmi-scada2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/grapesjs2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/graphite2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/mermaid-editor2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/studio2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/tldraw2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/vixel2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/voxedit2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/voxel-builder2024-11-08T20:49:46.165Zdaily0.7 +http://gigamaster.github.io/codemo/web-app/vvvebjs2024-11-08T20:49:46.165Zdaily0.7 + \ No newline at end of file diff --git a/src/template/foot.html b/src/template/foot.html index ad271e2f..da884dfb 100644 --- a/src/template/foot.html +++ b/src/template/foot.html @@ -435,7 +435,7 @@
Gigamaster
appNameDisplay: "standalone", // Display position of the app name [Optional] appIconUrl: "https://gigamaster.github.io/codemo/asset/favicon/codemo-logo-80.png", // App icon (square, min. 40 x 40 pixels) [Required] assetUrl: "https://gigamaster.github.io/codemo/asset/favicon/", // assets images [Required] - maxModalDisplayCount: -1, // modal display times [Optional. Default: -1 (no limit).] + maxModalDisplayCount: 1, // modal display times [Optional. Default: -1 (no limit).] // (Debugging: Use this.clearModalDisplayCount() to reset the count) }); window.AddToHomeScreenInstance.show(); // popup is only shown if not already added to homescreen @@ -444,5 +444,6 @@
Gigamaster
+ \ No newline at end of file