diff --git a/.versions b/.versions index f6c3481..a47de2c 100644 --- a/.versions +++ b/.versions @@ -13,7 +13,7 @@ modern-browsers@0.1.5 modules@0.15.0 modules-runtime@0.12.0 promise@0.11.2 -quave:reloader@2.0.1 +quave:reloader@2.0.2 quave:settings@1.0.0 reactive-var@1.0.11 reload@1.3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index b045e9c..7d5ec8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog + +## 2.0.2 - 2020-10-19 +### New feature +You can control from your app code when your app should update. + +### Clean up +We removed options that we don't believe are necessary, read more details [here](./README.md) + ## 1.6.0 - 2020-06-12 ### Config using Meteor.settings - Config now is set using `Meteor.settings.public.packages.reloader` object diff --git a/README.md b/README.md index 1474c53..38089fe 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,85 @@ # Reloader -## 2.0 introduced the ability for the app to control the reload. Readme will be updated soon. +More control over hot code push reloading for your mobile apps. A replacement +for [`mdg:reload-on-resume`](https://github.com/meteor/mobile-packages/blob/master/packages/mdg:reload-on-resume/README.md) +with more options and better UX. -More control over hot code push reloading for your mobile apps. A replacement for [`mdg:reload-on-resume`](https://github.com/meteor/mobile-packages/blob/master/packages/mdg:reload-on-resume/README.md) with more options and better UX. +Before using this package we recommend that you understand what Hot Code Push is, you can learn all about it [here](https://guide.meteor.com/hot-code-push.html) -As of Meteor 1.3, if you prevent instant reloading on updates, the newest version of the code will be used on your app's next cold start - no reload necessary. This can be achieved with `Reloader.configure({check: false, refresh: 'start'})`. However, you can also: +We provide two ways for you to handle your app code updates: -- Reload on resume, to update to the newest version of the code when the app is returned from the background: see [`refresh`](#refresh) (the launch screen is put back up during such reloads to hide the white screen you get with `mdg:reload-on-resume`) -- On start or resume, leave the launch screen up and wait to see whether there is an update available: see [`check`](#check), [`checkTimer`](#checktimer), and [`idleCutoff`](#idlecutoff) -- Delay removal of the launch screen, to hide the white screen that appears at the beginning of a reload: see [`launchScreenDelay`](#launchscreendelay) +- Always reload +- Reload when the app allows (recommended) -### Contents +### Always reload -- [Options](#options) - - [check](#check) - - [checkTimer](#checktimer) - - [refresh](#refresh) - - [idleCutoff](#idlecutoff) - - [launchScreenDelay](#launchscreendelay) - - [automaticInitialization](#automaticInitialization) -- [Helpers](#helpers) - - [Reloader.updateAvailable.get()](#reloaderupdateavailableget) - - [Reloader.reload()](#reloaderreload) -- [Development](#development) - - [Run tests](#run-tests) - - [Credits](#credits) +You don't need to configure anything, your app is going to reload as soon as the +code is received. -### Installation +If you want you can inform `launchScreenDelay` (0 by default) in milliseconds to +hold your splashscreen longer, avoiding a flash when the app is starting and +reloading. -```sh -meteor add quave:reloader -meteor remove mdg:reload-on-resume +```json +"public": { + "packages": { + "quave:reloader": { + "launchScreenDelay": 200 + } + } +} ``` -If you have any calls to `location.reload()` or `location.replace(location.href)` in your app, replace them with `Reloader.reload()`. - -## Options - -The default options are shown below. You can override them in your settings. - -```js -const DEFAULT_OPTIONS = { - check: 'everyStart', - checkTimer: 0, - refresh: 'startAndResume', - idleCutoff: 1000 * 60 * 5, // 5 minutes - launchScreenDelay: 100, -}; -``` +### Reload when the app allows -These default options will make sure that your app is up to date every time a user starts your app, or comes back to it after 5 minutes of being idle. +We recommend this method as with it you can control when you are app is going to +reload. You can even delegate this decision to the final user. -Another popular configuration is: +In this case you must use `automaticInitialization` as `false` in your settings. ```json -{ - "public": - "packages": { - "quave:reloader": { - "check": "firstStart", - "checkTimer": 5000, - "refresh": "start" - } +"public": { + "packages": { + "quave:reloader": { + "automaticInitialization": false } } } ``` -This will make sure the first time your app is run it is up to date, will download new versions of code while the app is being used, and then only update when the app is next started. - -You can have a different configuration for development just using a different settings. - -### check - -When to make additional checks for new code bundles. The app splash screen is shown during the check. Possible values are: - -- `everyStart` (default): Check every time the app starts. Does not include resuming the app, unless the `idleCutOff` has been reached. -- `firstStart`: Check only the first time the app starts (just after downloading it). -- `false`: Never make additional checks and rely purely on code bundles being downlaoded in the background while the app is being used. - -### checkTimer - -Default: `3000` +You also need to call +`Reloader.initialize` in the render or initialization of your app providing a function (can be async) in the property `beforeReload`. -How long to wait (in ms) when making additional checks for new file bundles. In future versions of Meteor we will have an API to instantly check if an update is available or not, but until then we need to simply wait to see if a new code bundle is downloaded. Depending on the size of your app bundle and the phone's connection speed, the default three seconds may not be enough - you can increase it if you find that you have new code immediately after starting the app. +## Installing -### refresh - -When to refresh to the latest code bundle if one finished downloading after the end of the `check` period. The app splash screen is shown during the refresh. Possible values are: +```sh +meteor add quave:reloader +meteor remove mdg:reload-on-resume +``` -- `startAndResume` (default): Refresh to the latest bundle both when starting and resuming the app. -- `start`: Refresh only when the app is started (not resumed). -- `instantly`: Overrides everything else. If set, your app will have similar behaviour to the default in Meteor, with code updates being refreshed immeidately. The only improvement/difference is that the app's splash screen is displayed during the refresh. +## Configuration Options ### idleCutoff -Default: `1000 * 60 * 10 // 10 minutes` +Default: `1000 * 60 * 5 // 5 minutes` -How long (in ms) can an app be idle before we consider it a start and not a resume. Applies only when `check: 'everyStart'`. Set to `0` to never check on resume. +How long (in ms) can an app be idle before we consider it a start and not a +resume. Applies only when `check: 'everyStart'`. Set to `0` to never check on +resume. ### launchScreenDelay -**Planned option for future version. Currently not configurable.** - -Default: `100` +Default: `0` -How long to wait (in ms) after reload before hiding the launch screen. The goal is to leave it up until your page has finished rendering, so the user does not see a blank white screen. The duration will vary based on your app's render time and the speed of the device. To be more precise, set `launchScreenDelay` to 0 and release the launch screen yourself when the page has rendered. For example, if the only two pages that might be displayed on reload are `index` and `post`, then you would do: - -```javascript -launchScreenHandle = Launchscreen.hold(); - -Template.index.onRendered(() => { - launchScreenHandle.release(); -}); - -Template.post.onRendered(() => { - launchScreenHandle.release(); -}); -``` - -Or if you have a layout template, you could put a single `.release()` in that template's `onRendered`. +How long the splash screen will be visible, it's useful to avoid your app being rendered just for a few milliseconds and then refreshing. ### automaticInitialization -If you want to initialize the `reloader` yourself you need to turn off `automaticInitialization`, this is useful when you want to provide code to some callback as this is not possible using JSON initialization. +Default: `true` + +If you want to initialize the `reloader` yourself you need to turn +off `automaticInitialization`, this is useful when you want to provide code to +some callback as this is not possible using JSON initialization. You can provide your callbacks calling Reloader.initialize(), for example: @@ -140,28 +96,146 @@ ReloaderCordova.initialize({ }); ``` -## Helpers - -These helpers can help you to have an "Update Now" button. - -### A note about using these helpers - -Some people have reported having their app rejected during the Apple review process for having an "Update Now" button or similar as opposed to using the refresh on resume behavior that this package provides by default. If you really want to have an update button when new code is available, make sure you don't push any new code to the server until after your app has been approved. But it's probably safer/better to simply not have an update button at all! - -### How to use them anyway +## Example with React -#### Reloader.updateAvailable.get() +File: `Routes.js` (where we render the routes) +```javascript -`Reloader.updateAvailable` is a reactive variable that returns true when an update has been downloaded. +export const Routes = () => { + useEffect(() => initializeReloader(), []); -```js -ReloaderCordova.updateAvailable.get(); // Reactively returns true if an update is ready + return ( + + // React router routes... + + ); +} ``` -#### Reloader.reload() +File: `initializeReloader.js` +```javascript +import { Reloader } from 'meteor/quave:reloader'; +import { loggerClient } from 'meteor/quave:logs/loggerClient'; +import { showConfirm } from './ConfirmationDialog'; +import { methodCall } from '../../methods/methodCall'; +import { version } from '../../version'; + +export const initializeReloader = () => { + loggerClient.info({ message: 'initializeReloader' }); + Reloader.initialize({ + async beforeReload(updateApp, holdAppUpdate) { + loggerClient.info({ message: 'initializeReloader beforeReload' }); + let appUpdateData = {}; + try { + appUpdateData = + (await methodCall('getAppUpdateData', { clientVersion: version })) || + {}; + } catch (e) { + loggerClient.info({ + message: 'forcing app reload because getAppUpdateData is breaking', + }); + updateApp(); + return; + } + loggerClient.info({ + message: 'initializeReloader beforeReload appUpdateData', + appUpdateData, + }); + if (appUpdateData.ignore) { + loggerClient.info({ + message: + 'initializeReloader beforeReload appUpdateData ignore is true', + appUpdateData, + }); + return; + } + const cancelAction = appUpdateData.forceUpdate + ? updateApp + : holdAppUpdate; + try { + const message = appUpdateData.forceUpdate + ? 'Precisamos atualizar o aplicativo. É rapidinho!' + : 'Deseja atualizar agora? É rapidinho!'; + const result = await showConfirm({ + autoFocus: false, + title: appUpdateData.title || 'Atualização disponível', + content: appUpdateData.message || message, + confirmText: appUpdateData.actionLabel || 'Beleza', + cancelText: appUpdateData.noActionLabel || 'Mais tarde', + hideCancel: !!appUpdateData.forceUpdate, + dismiss: cancelAction, + onCancel() { + loggerClient.info({ + message: 'initializeReloader beforeReload onCancel', + appUpdateData, + }); + cancelAction(); + }, + }); + loggerClient.info({ + message: `initializeReloader beforeReload showConfirm result is ${result}`, + appUpdateData, + }); + if (result) { + loggerClient.info({ + message: 'initializeReloader beforeReload showConfirm ok', + appUpdateData, + }); + updateApp(); + return; + } + loggerClient.info({ + message: 'initializeReloader beforeReload showConfirm nok', + appUpdateData, + }); + cancelAction(); + } catch (e) { + loggerClient.info({ + message: 'initializeReloader beforeReload showConfirm catch call nok', + appUpdateData, + }); + cancelAction(); + } + }, + }); +}; -Call `Reloader.reload()` to refresh the page. +``` -### Credits +File: `getAppUpdateData.js` +```javascript +import { Meteor } from 'meteor/meteor'; +import { logger } from 'meteor/quave:logs/logger'; +import { AppUpdatesCollection } from '../db/AppUpdatesCollection'; +import { version } from '../version'; + +Meteor.methods({ + getAppUpdateData({ clientVersion } = {}) { + this.unblock(); + + if (Meteor.isClient) return null; + + const appUpdate = AppUpdatesCollection.findOne() || {}; + + const result = { + ...appUpdate, + ...(appUpdate.ignoreVersions && + appUpdate.ignoreVersions.length && + appUpdate.ignoreVersions.includes(version) + ? { ignore: true } + : {}), + version, + }; + logger.info({ + message: `getAppUpdateData clientVersion=${clientVersion}, newClientVersion=${version}, ${JSON.stringify( + result + )}`, + appUpdateData: appUpdate, + appUpdateResult: result, + clientVersion, + }); + return result; + }, +}); -thanks to @jamielob and @martijnwalraven for his help with this package (forks)! +``` diff --git a/package.js b/package.js index 8793863..b653297 100644 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'quave:reloader', - version: '2.0.1', + version: '2.0.2', summary: 'More control over hot code push reloading', git: 'https://github.com/quavedev/reloader/', }); diff --git a/reloader-cordova.js b/reloader-cordova.js index b9cdd99..850f418 100644 --- a/reloader-cordova.js +++ b/reloader-cordova.js @@ -1,46 +1,20 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { LaunchScreen } from 'meteor/launch-screen'; -import { settings, debugFn, PACKAGE_NAME } from './common'; - -const RefreshType = { - INSTANTLY: 'instantly', - START_AND_RESUME: 'startAndResume', - START: 'start', -}; - -const CheckType = { - EVERY_START: 'everyStart', - FIRST_START: 'firstStart', - NEVER: 'never', -}; +import {Meteor} from 'meteor/meteor'; +import {Tracker} from 'meteor/tracker'; +import {ReactiveVar} from 'meteor/reactive-var'; +import {LaunchScreen} from 'meteor/launch-screen'; +import {settings, debugFn, PACKAGE_NAME} from './common'; -/** - * check: Match.Optional(Match.OneOf('everyStart', 'firstStart', false)), - * checkTimer: Match.Optional(Match.Integer), - * refresh: Match.Optional( - * Match.OneOf('startAndResume', 'start', 'instantly') - * ), - * idleCutoff: Match.Optional(Match.Integer), - * launchScreenDelay: Match.Optional(Match.Integer), - * debug: Boolean - */ const DEFAULT_OPTIONS = { - check: CheckType.EVERY_START, - checkTimer: 0, - refresh: RefreshType.START_AND_RESUME, idleCutoff: 1000 * 60 * 5, // 5 minutes - launchScreenDelay: 500, - alwaysCheckBeforeReload: true, automaticInitialization: true, + launchScreenDelay: 0, }; -debugFn('starting - DEFAULT_OPTIONS', { DEFAULT_OPTIONS }); -debugFn('starting - settings', { settings }); +debugFn('starting - DEFAULT_OPTIONS', {DEFAULT_OPTIONS}); +debugFn('starting - settings', {settings}); const options = Object.assign({}, DEFAULT_OPTIONS, settings); -debugFn('starting - options', { options }); +debugFn('starting - options', {options}); const defaultRetry = () => console.log(`[${PACKAGE_NAME}] no retry function yet`); @@ -79,7 +53,7 @@ const Reloader = { navigator.splashscreen.show(); const currentDate = Date.now(); - this.debug('prereload - reloaderWasRefreshed', { currentDate }); + this.debug('prereload - reloaderWasRefreshed', {currentDate}); // Set the refresh flag localStorage.setItem('reloaderWasRefreshed', currentDate); }, @@ -88,7 +62,7 @@ const Reloader = { this.debug('reloadNow'); if (this._isCheckBeforeReload() && !this.isChecked.get()) { this.debug( - 'not reloading because alwaysCheckBeforeReload is true and it is not checked yet' + 'not reloading because beforeReload is provided and it is not checked yet' ); return; } @@ -112,12 +86,6 @@ const Reloader = { // is set and it's our first start) _shouldCheckForUpdateOnStart() { this.debug('_shouldCheckForUpdateOnStart'); - if (!this._options.check || this._options.check === CheckType.NEVER) { - this.debug('_shouldCheckForUpdateOnStart - check false', { - check: this._options.check, - }); - return false; - } const isColdStart = !localStorage.getItem('reloaderWasRefreshed'); const reloaderLastStart = localStorage.getItem('reloaderLastStart'); @@ -127,24 +95,15 @@ const Reloader = { reloaderLastStart, }); const should = - isColdStart && - (this._options.check === CheckType.EVERY_START || - (this._options.check === CheckType.FIRST_START && !reloaderLastStart)); + isColdStart; - this.debug('_shouldCheckForUpdateOnStart - should', { should }); + this.debug('_shouldCheckForUpdateOnStart - should', {should}); return should; }, // Check if the idleCutoff is set AND we exceeded the idleCutOff limit AND the everyStart check is set _shouldCheckForUpdateOnResume() { this.debug('_shouldCheckForUpdateOnResume'); - if (!this._options.check || this._options.check === CheckType.NEVER) { - this.debug('_shouldCheckForUpdateOnResume - check false', { - check: this._options.check, - }); - return false; - } - const reloaderLastPause = localStorage.getItem('reloaderLastPause'); // In case a pause event was missed, assume it didn't make the cutoff if (!reloaderLastPause) { @@ -166,8 +125,7 @@ const Reloader = { }); return ( this._options.idleCutoff && - lastPause < idleCutoffAt && - this._options.check === CheckType.EVERY_START + lastPause < idleCutoffAt ); }, @@ -201,7 +159,7 @@ const Reloader = { this.debug('prereload - hide splashscreen'); navigator.splashscreen.hide(); } - }, this._options.checkTimer || 0); + }, 0); }, _checkForUpdate() { @@ -251,20 +209,11 @@ const Reloader = { this._checkForUpdate(); return; } - - // If we don't need to do an additional check - // Check if there's a new version available already AND we need to refresh on resume - if ( - this.updateAvailable.get() && - this._options.refresh === RefreshType.START_AND_RESUME - ) { - this.reloadNow(); - } }, _isCheckBeforeReload() { this.debug('_isCheckBeforeReload'); - return this._options.alwaysCheckBeforeReload && this._options.beforeReload; + return !!this._options.beforeReload && typeof this._options.beforeReload === 'function'; }, _callBeforeLoad() { @@ -285,10 +234,7 @@ const Reloader = { _onMigrate(retry) { this.debug('_onMigrate'); this._retry = retry || this._retry; - if ( - this._options.refresh === RefreshType.INSTANTLY && - (!this._isCheckBeforeReload() || this.isChecked.get()) - ) { + if (!this._isCheckBeforeReload() || this.isChecked.get()) { // we are calling prepareToReload because as we are returning true the reload // will happen in the reload package then we are updating our timestamps this.prepareToReload(); @@ -342,4 +288,4 @@ document.addEventListener( // eslint-disable-next-line no-undef Reload._onMigrate(`${PACKAGE_NAME}`, retry => Reloader._onMigrate(retry)); -export { Reloader }; +export {Reloader};