diff --git a/data/pwa/README.md b/data/pwa/README.md new file mode 100644 index 000000000..4794430b3 --- /dev/null +++ b/data/pwa/README.md @@ -0,0 +1,15 @@ + +The files in this directory are used for deploying the Mol* Viewer as a PWA (Progressive Web App) at https://molstar.org/viewer/. They may serve as an example for creating your own PWA but wont work as-is. See `/script/deploy.js` for where these files are copied and how they are transformed during deployment. + + +## PWA features + +- The Service Worker will cache static resources so the Viewer can be used without internet access. This works without installing, i.e., also in Firefox. +- Once installed, file types listed in the Manifest can be opened from, e.g., the Windows File Explorer. + + +## Notes for development + +In Chrome you can see a list of installed PWAs at chrome://apps/. A right-click opens a menu with an option uninstall. + +The Chrome Dev Tools have a section 'Application' to inspect and manage PWA aspects like the Manifest and Service Workers. diff --git a/src/apps/viewer/logo-144.png b/data/pwa/logo-144.png similarity index 100% rename from src/apps/viewer/logo-144.png rename to data/pwa/logo-144.png diff --git a/src/apps/viewer/manifest.webmanifest b/data/pwa/manifest.webmanifest similarity index 80% rename from src/apps/viewer/manifest.webmanifest rename to data/pwa/manifest.webmanifest index 86bc75b61..2e7d8de1b 100644 --- a/src/apps/viewer/manifest.webmanifest +++ b/data/pwa/manifest.webmanifest @@ -1,11 +1,11 @@ { - "id": "/molstar/build/viewer/index.html", + "id": "https://molstar.org/viewer/", "name": "Mol* Viewer", "short_name": "Mol*", - "description": "Mol* Viewer", + "description": "Mol* Viewer: a modern web app for 3D visualization and analysis of large biomolecular structures.", "start_url": "./index.html", - "theme_color": "#eeffee", - "background_color": "#eeffee", + "theme_color": "#eeece7", + "background_color": "#eeece7", "display": "standalone", "icons": [ { diff --git a/data/pwa/pwa.js b/data/pwa/pwa.js new file mode 100644 index 000000000..e57b3a501 --- /dev/null +++ b/data/pwa/pwa.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Andy Turner + * @author Alexander Rose + */ + +window.addEventListener('molstarViewerCreated', e => { + const viewer = e.detail.viewer; + + // Handle incoming files + if ('launchQueue' in window) { + launchQueue.setConsumer((launchParams) => { + if (!launchParams.files.length) return; + + const files = []; + for (const fileHandle of launchParams.files) { + files.push(fileHandle.getFile()); + } + + Promise.all(files).then((files) => { + viewer.loadFiles(files); + }); + }); + } +}); + +// Register Progressive Web App service worker. +if ('serviceWorker' in navigator) { + window.addEventListener('load', function () { + navigator.serviceWorker.register('./sw.js') + .then(function (registration) { + // Registration was successful + if (molstar.isDebugMode) { + console.log('ServiceWorker registration successful with scope: ', registration.scope); + } + }, function (err) { + // registration failed :( + if (molstar.isDebugMode) { + console.error('ServiceWorker registration failed: ', err); + } + }); + }); +} diff --git a/data/pwa/sw.js b/data/pwa/sw.js new file mode 100644 index 000000000..8f368bbee --- /dev/null +++ b/data/pwa/sw.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Andy Turner + * @author Alexander Rose + */ + +/** version from package.json, to be filled in during deployment */ +const VERSION = '__MOLSTAR_VERSION__'; + +const CACHE_NAME = `molstar-viewer-${VERSION}`; + +// The static resources that the app needs to function. +const APP_STATIC_RESOURCES = [ + 'favicon.ico', + 'index.html', + 'molstar.css', + 'molstar.js', + 'manifest.webmanifest', + 'logo-144.png', + 'pwa.js' +]; + +async function cacheStaticResources() { + const cache = await caches.open(CACHE_NAME); + await cache.addAll(APP_STATIC_RESOURCES); + await self.skipWaiting(); // Ensures the new service worker takes control immediately. +} + +async function deleteOldCaches() { + const keys = await caches.keys(); + await Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) { + return caches.delete(key); + } + }), + ); + await self.clients.claim(); // Ensures the new service worker takes control immediately. +} + +async function respondWithCacheFirst(request) { + // Try to match the request with the cache + const cachedResponse = await caches.match(request); + return cachedResponse || fetch(request); +} + +self.addEventListener('install', (event) => { + // console.log(`Service Worker version ${VERSION} installed.`); + event.waitUntil(cacheStaticResources()); +}); + +self.addEventListener('activate', (event) => { + // console.log(`Service Worker version ${VERSION} activated.`); + event.waitUntil(deleteOldCaches()); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith(respondWithCacheFirst(event.request)); +}); diff --git a/package.json b/package.json index b6369c90c..532e1b527 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "rebuild": "npm run clean && npm run build", "build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer", "build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"", - "build-extra": "cpx \"src/**/*.{scss,html,ico,jpg,webmanifest,png}\" lib/ && cpx \"src/**/sw.js\" lib/", + "build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/", "build-webpack": "webpack --mode production --config ./webpack.config.production.js", "build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js", "watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"", @@ -28,7 +28,7 @@ "watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"", "watch-tsc": "tsc --watch --incremental", "watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental", - "watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg,webmanifest,png}\" lib/ --watch && cpx \"src/**/sw.js\" lib/ --watch", + "watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch", "watch-webpack": "webpack -w --mode development --stats minimal", "watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js", "watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js", @@ -134,7 +134,6 @@ "@typescript-eslint/parser": "^7.18.0", "benchmark": "^2.1.4", "concurrently": "^9.1.2", - "copy-webpack-plugin": "^13.0.0", "cpx2": "^8.0.0", "crypto-browserify": "^3.12.1", "css-loader": "^7.1.2", diff --git a/scripts/deploy.js b/scripts/deploy.js index c35d6f7ce..c284055f2 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2019-2024 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose */ @@ -9,7 +9,10 @@ const path = require('path'); const fs = require("fs"); const fse = require("fs-extra"); +const VERSION = require(path.resolve(__dirname, '../package.json')).version; + const remoteUrl = "https://github.com/molstar/molstar.github.io.git"; +const dataDir = path.resolve(__dirname, '../data/'); const buildDir = path.resolve(__dirname, '../build/'); const deployDir = path.resolve(buildDir, 'deploy/'); const localPath = path.resolve(deployDir, 'molstar.github.io/'); @@ -17,6 +20,12 @@ const localPath = path.resolve(deployDir, 'molstar.github.io/'); const analyticsTag = //g; const analyticsCode = ``; +const manifestTag = //g; +const manifestCode = ``; + +const pwaTag = //g; +const pwaCode = ``; + function log(command, stdout, stderr) { if (command) { console.log('\n###', command); @@ -31,17 +40,41 @@ function addAnalytics(path) { fs.writeFileSync(path, result, 'utf8'); } +function addManifest(path) { + const data = fs.readFileSync(path, 'utf8'); + const result = data.replace(manifestTag, manifestCode); + fs.writeFileSync(path, result, 'utf8'); +} + +function addPwa(path) { + const data = fs.readFileSync(path, 'utf8'); + const result = data.replace(pwaTag, pwaCode); + fs.writeFileSync(path, result, 'utf8'); +} + +function addVersion(path) { + const data = fs.readFileSync(path, 'utf8'); + const result = data.replace('__MOLSTAR_VERSION__', VERSION); + fs.writeFileSync(path, result, 'utf8'); +} + function copyViewer() { console.log('\n###', 'copy viewer files'); - const viewerBuildPath = path.resolve(buildDir, '../build/viewer/'); + const viewerBuildPath = path.resolve(buildDir, 'viewer/'); const viewerDeployPath = path.resolve(localPath, 'viewer/'); fse.copySync(viewerBuildPath, viewerDeployPath, { overwrite: true }); addAnalytics(path.resolve(viewerDeployPath, 'index.html')); + addManifest(path.resolve(viewerDeployPath, 'index.html')); + addPwa(path.resolve(viewerDeployPath, 'index.html')); + + const pwaDataPath = path.resolve(dataDir, 'pwa/'); + fse.copySync(pwaDataPath, viewerDeployPath, { overwrite: true }); + addVersion(path.resolve(viewerDeployPath, 'sw.js')); } function copyMe() { console.log('\n###', 'copy me files'); - const meBuildPath = path.resolve(buildDir, '../build/mesoscale-explorer/'); + const meBuildPath = path.resolve(buildDir, 'mesoscale-explorer/'); const meDeployPath = path.resolve(localPath, 'me/viewer/'); fse.copySync(meBuildPath, meDeployPath, { overwrite: true }); addAnalytics(path.resolve(meDeployPath, 'index.html')); @@ -49,12 +82,12 @@ function copyMe() { function copyDemos() { console.log('\n###', 'copy demos files'); - const lightingBuildPath = path.resolve(buildDir, '../build/examples/lighting/'); + const lightingBuildPath = path.resolve(buildDir, 'examples/lighting/'); const lightingDeployPath = path.resolve(localPath, 'demos/lighting/'); fse.copySync(lightingBuildPath, lightingDeployPath, { overwrite: true }); addAnalytics(path.resolve(lightingDeployPath, 'index.html')); - const orbitalsBuildPath = path.resolve(buildDir, '../build/examples/alpha-orbitals/'); + const orbitalsBuildPath = path.resolve(buildDir, 'examples/alpha-orbitals/'); const orbitalsDeployPath = path.resolve(localPath, 'demos/alpha-orbitals/'); fse.copySync(orbitalsBuildPath, orbitalsDeployPath, { overwrite: true }); addAnalytics(path.resolve(orbitalsDeployPath, 'index.html')); diff --git a/src/apps/viewer/app.ts b/src/apps/viewer/app.ts index 9d38290c9..efa990121 100644 --- a/src/apps/viewer/app.ts +++ b/src/apps/viewer/app.ts @@ -61,7 +61,7 @@ import { ObjectKeys } from '../../mol-util/type-helpers'; import { OpenFiles } from '../../mol-plugin-state/actions/file'; export { PLUGIN_VERSION as version } from '../../mol-plugin/version'; -export { consoleStats, setDebugMode, setProductionMode, setTimingMode } from '../../mol-util/debug'; +export { consoleStats, setDebugMode, setProductionMode, setTimingMode, isProductionMode, isDebugMode, isTimingMode } from '../../mol-util/debug'; const CustomFormats = [ ['g3d', G3dProvider] as const @@ -561,14 +561,14 @@ export class Viewer { } } - async loadFiles(files: File[]) { + loadFiles(files: File[]) { const sessions = files.filter(f => { const fn = f.name.toLowerCase(); return fn.endsWith('.molx') || fn.endsWith('.molj'); }); if (sessions.length > 0) { - PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: sessions[0] }); + return PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: sessions[0] }); } else { return this.plugin.runTask(this.plugin.state.data.applyAction(OpenFiles, { files: files.map(f => Asset.File(f)), diff --git a/src/apps/viewer/index.html b/src/apps/viewer/index.html index 2a118deb5..f787e8808 100644 --- a/src/apps/viewer/index.html +++ b/src/apps/viewer/index.html @@ -35,7 +35,7 @@ } - +
@@ -132,37 +132,11 @@ viewer.dispose(); }); - // Handle incoming files - if ('launchQueue' in window) { - launchQueue.setConsumer((launchParams) => { - if (!launchParams.files.length) return; - - const files = []; - for (const fileHandle of launchParams.files) { - files.push(fileHandle.getFile()); - } - - Promise.all(files).then((files) => { - viewer.loadFiles(files); - }); - }); - } + const event = new CustomEvent("molstarViewerCreated", { detail: { viewer } }); + window.dispatchEvent(event); }); - - // Register Progressive Web App service worker. - if ('serviceWorker' in navigator) { - window.addEventListener('load', function () { - navigator.serviceWorker.register('./sw.js') - .then(function (registration) { - // Registration was successful - console.log('ServiceWorker registration successful with scope: ', registration.scope); - }, function (err) { - // registration failed :( - console.log('ServiceWorker registration failed: ', err); - }); - }); - } + \ No newline at end of file diff --git a/src/apps/viewer/index.ts b/src/apps/viewer/index.ts index c0870478a..9d8b9c3c8 100644 --- a/src/apps/viewer/index.ts +++ b/src/apps/viewer/index.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -8,7 +8,5 @@ import './embedded.html'; import './favicon.ico'; import './index.html'; -import './manifest.webmanifest'; -import './logo-144.png'; import '../../mol-plugin-ui/skin/light.scss'; export * from './app'; diff --git a/src/apps/viewer/sw.js b/src/apps/viewer/sw.js deleted file mode 100644 index 8b7a82fa0..000000000 --- a/src/apps/viewer/sw.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. - * - * @author Andy Turner - * - * @description Service worker for the viewer app. - * The service worker: - * - caches the static resources that the app needs to function - * - intercepts server requests and responds with cached responses instead of going to the network - * - deletes old caches on activation - * - responds with cached resources on fetch - * - responds with a network error if fetching fails - * - responds with a cache error if the cache match fails - * - * To not complicate the build process, this file is not transpiled but written in JavaScript. - * It is coppied to the build directory as is and no imports must be used. - */ - -/** version from package.json, to be filled in at build time */ -const VERSION = ''; - -const CACHE_NAME = `molstar-viewer-${VERSION}`; - -// The static resources that the app needs to function. -const APP_STATIC_RESOURCES = [ - 'favicon.ico', - 'index.html', - 'molstar.css', - 'molstar.js', - 'manifest.webmanifest', - 'logo-144.png' -]; - -// On install, cache the static resources. -self.addEventListener('install', (event) => { - console.log(`Service Worker version ${VERSION} installed.`); - event.waitUntil( - (async () => { - const cache = await caches.open(CACHE_NAME); - await cache.addAll(APP_STATIC_RESOURCES); - await self.skipWaiting(); // Ensures the new service worker takes control immediately. - })(), - ); -}); - -// On activate, delete old caches. -self.addEventListener('activate', (event) => { - console.log(`Service Worker version ${VERSION} activated.`); - event.waitUntil( - (async () => { - const keys = await caches.keys(); - await Promise.all( - keys.map((key) => { - if (key !== CACHE_NAME) { - return caches.delete(key); - } - }), - ); - await self.clients.claim(); // Ensures the new service worker takes control immediately. - })(), - ); -}); - -// On fetch, respond with cached resources. -self.addEventListener('fetch', (event) => { - event.respondWith( - (async () => { - try { - // Try to match the request with the cache - const cachedResponse = await caches.match(event.request); - if (cachedResponse) { - return cachedResponse; - } - // Fetch from network if not found in cache - const networkResponse = await fetch(event.request); - // Check if the network response is valid - if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') { - return networkResponse; - } - // Clone the network response - const responseToCache = networkResponse.clone(); - // Open the cache and put the network response in it - const cache = await caches.open(CACHE_NAME); - await cache.put(event.request, responseToCache); - return networkResponse; - } catch (error) { - console.error('Fetching failed:', error); - return new Response('Network error occurred', { - status: 408, - statusText: 'Network error occurred' - }); - } - })() - ); -}); diff --git a/webpack.config.common.js b/webpack.config.common.js index bd8d78880..477658c85 100644 --- a/webpack.config.common.js +++ b/webpack.config.common.js @@ -3,7 +3,6 @@ const fs = require('fs'); const webpack = require('webpack'); const ExtraWatchWebpackPlugin = require('extra-watch-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const CopyPlugin = require('copy-webpack-plugin'); const VERSION = require('./package.json').version; class VersionFilePlugin { @@ -14,8 +13,31 @@ class VersionFilePlugin { } } -function getSharedConfig(copyPluginPatterns) { - const plugins = [ +const sharedConfig = { + module: { + rules: [ + { + test: /\.(html|ico)$/, + use: [{ + loader: 'file-loader', + options: { name: '[name].[ext]' } + }] + }, + { + test: /\.(s*)css$/, + use: [ + MiniCssExtractPlugin.loader, + { loader: 'css-loader', options: { sourceMap: false } }, + { loader: 'sass-loader', options: { sourceMap: false } }, + ] + }, + { + test: /\.(jpg)$/i, + type: 'asset/resource', + }, + ] + }, + plugins: [ new ExtraWatchWebpackPlugin({ files: [ './lib/**/*.scss', @@ -28,73 +50,40 @@ function getSharedConfig(copyPluginPatterns) { }), new MiniCssExtractPlugin({ filename: 'molstar.css' }), new VersionFilePlugin(), - ]; - - if (copyPluginPatterns && copyPluginPatterns.length > 0) { - plugins.push(new CopyPlugin({ - patterns: copyPluginPatterns, - })); - } - - return { - module: { - rules: [ - { - test: /\.(html|ico|webmanifest|png)$/, - use: [{ - loader: 'file-loader', - options: { name: '[name].[ext]' } - }] - }, - { - test: /\.(s*)css$/, - use: [ - MiniCssExtractPlugin.loader, - { loader: 'css-loader', options: { sourceMap: false } }, - { loader: 'sass-loader', options: { sourceMap: false } }, - ] - }, - { - test: /\.(jpg)$/i, - type: 'asset/resource', - }, - ] - }, - plugins: plugins, - resolve: { - modules: [ - 'node_modules', - path.resolve(__dirname, 'lib/') - ], - fallback: { - fs: false, - vm: false, - buffer: false, - crypto: require.resolve('crypto-browserify'), - path: require.resolve('path-browserify'), - stream: require.resolve('stream-browserify'), - } - }, - watchOptions: { - aggregateTimeout: 750 + ], + resolve: { + modules: [ + 'node_modules', + path.resolve(__dirname, 'lib/') + ], + fallback: { + fs: false, + vm: false, + buffer: false, + crypto: require.resolve('crypto-browserify'), + path: require.resolve('path-browserify'), + stream: require.resolve('stream-browserify'), } - }; -} + }, + watchOptions: { + aggregateTimeout: 750 + } +}; function createEntry(src, outFolder, outFilename, isNode) { return { target: isNode ? 'node' : void 0, entry: path.resolve(__dirname, `lib/${src}.js`), output: { filename: `${outFilename}.js`, path: path.resolve(__dirname, `build/${outFolder}`) }, - ...getSharedConfig() + ...sharedConfig }; } -function createEntryPoint(name, dir, out, library, copyPluginPatterns) { +function createEntryPoint(name, dir, out, library) { return { entry: path.resolve(__dirname, `lib/${dir}/${name}.js`), output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' }, - ...getSharedConfig(copyPluginPatterns) + ...sharedConfig }; } @@ -109,11 +98,11 @@ function createNodeEntryPoint(name, dir, out) { 'util.promisify': 'require("util.promisify")', xhr2: 'require("xhr2")', }, - ...getSharedConfig() + ...sharedConfig }; } -function createApp(name, library, copyPluginPatterns) { return createEntryPoint('index', `apps/${name}`, name, library, copyPluginPatterns); } +function createApp(name, library) { return createEntryPoint('index', `apps/${name}`, name, library); } function createExample(name) { return createEntry(`examples/${name}/index`, `examples/${name}`, 'index'); } function createBrowserTest(name) { return createEntryPoint(name, 'tests/browser', 'tests'); } function createNodeApp(name) { return createNodeEntryPoint('index', `apps/${name}`, name); } @@ -125,4 +114,4 @@ module.exports = { createBrowserTest, createNodeEntryPoint, createNodeApp -}; +}; \ No newline at end of file diff --git a/webpack.config.viewer.js b/webpack.config.viewer.js index d058b72ae..1ff60061f 100644 --- a/webpack.config.viewer.js +++ b/webpack.config.viewer.js @@ -1,14 +1,6 @@ const common = require('./webpack.config.common.js'); -const VERSION = require('./package.json').version; const createApp = common.createApp; -module.exports = (env, argv) => { - return [ - createApp('viewer', 'molstar', [{ - from: 'lib/apps/viewer/sw.js', - transform: (content) => { - return content.toString().replace('const VERSION = \'\';', `const VERSION = '${VERSION}${env.WEBPACK_WATCH ? '-' + new Date().valueOf() : ''}';`); - } - }]), - createApp('mesoscale-explorer', 'molstar'), - ]; -}; \ No newline at end of file +module.exports = [ + createApp('viewer', 'molstar'), + createApp('mesoscale-explorer', 'molstar'), +]; \ No newline at end of file