Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add auto-update on hide workflow for Electron on Mac #1318

Merged
merged 6 commits into from
Mar 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config/webpack/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const platformIndex = process.argv.findIndex(arg => arg === '--platform');
const platform = (platformIndex > 0) ? process.argv[platformIndex + 1] : 'web';
const platformExclude = platform === 'web' ? new RegExp(/\.desktop\.js$/) : new RegExp(/\.website\.js$/);

module.exports = {
const webpackConfig = {
entry: {
app: './index.js',
},
Expand Down Expand Up @@ -125,3 +125,9 @@ module.exports = {
extensions: ['.web.js', (platform === 'web') ? '.website.js' : '.desktop.js', '.js', '.jsx'],
},
};

if (platform === 'desktop') {
webpackConfig.target = 'electron-renderer';
}

module.exports = webpackConfig;
48 changes: 48 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Testing Electron Auto-Update
Testing the auto-update process can be a little involved. The most effective way to test this involves setting up your own release channel locally and making sure that you can notarize your builds.

**Note:** In order to test with a notarized build you'll need to have a paid Apple developer account.

## Setting up Min.IO
Rather than pushing new builds to the production S3 bucket, the best way to test locally is to use [Min.IO](https://min.io). Min.IO is an S3-compatible service that you can set up and deploy locally. In order to set up a local Min.IO instance to emulate an S3 bucket, follow these steps:

1. [Install Docker Desktop for Mac 🐳](https://docs.docker.com/docker-for-mac/install/) and make sure it's running. If you're not familiar with Docker, it might be a good idea to [follow the Docker quickstart](https://docs.docker.com/get-started/) and/or [learn more about Docker](https://docker-curriculum.com/).
1. Next, you can [install Min.IO](https://docs.min.io/docs/minio-docker-quickstart-guide.html) using the command: `brew install minio/stable/mc`
1. Now you can run Min.IO in a Docker container by executing this command:
```bash
docker run -p 9000:9000 \
-e "MINIO_ROOT_USER=USER"
-e "MINIO_ROOT_PASSWORD=Password1"
--name minio1
minio/minio server /data
```
1. Next, confirm that the docker container is running using the command `docker ps`


Once you're running a local Min.IO instance (which emulates an S3 instance), the next step is to point your electron config at the Min.IO server. To do so, edit the [`publish` block of electon.config.js](https://github.com/Expensify/Expensify.cash/blob/bd776babbfa196fa7b29cef07590b71fc1df73ab/config/electron.config.js#L21-L25) to look like:
```
publish: [{
provider: 's3',
endpoint: 'http://localhost:9000',
bucket: 'minio1',
channel: 'latest',
}],
```

**Note:** while the `electron-updater` docs tell you to create a file named `dev-app-update.yaml`, this will **not** be helpful. Setting that file will, in development, tell the auto-updater where to look for builds. Unfortunately, on Mac the auto-updater will not install the new bits unless the app that is currently running is signed.

Now, you need to upload a build. Before you can do so, you need to make sure that you can notarize builds. For this you will need an [Apple Developer](https://developer.apple.com) account. Go to the [Certificates, Identifiers, and Profiles](https://developer.apple.com/account/resources/certificates/list) page and create a new certificate for a Developer ID Application. Follow the instructions to create a Certificate Signing Request, and once the certificate has been created, add it to your keychain with the Keychain Access app.

You will need to pass your Apple ID (username) and an [app-specific password](https://appleid.apple.com/account/manage) to the environment of the local desktop build. Entering your normal password will not work, so generate an app-specific password before continuing.

Now that your credentials have been set up properly, you can push a build to Min.IO. Start by updating the app version in `package.json` to something sufficiently high (i.e. `9.9.9-999`). Then run:

```bash
APPLE_ID=<your_apple_id_username> \
APPLE_ID_PASSWORD=<your_app_specific_password> \
npm run desktop-build
```

This will push your new build to the server.

Once this is done, revert the version update in `package.json`, remove `--publish always` from the `desktop-build` command and again run `npm run desktop-build`. From the `dist/` folder in the root of the project, you will find `Expensify.cash.dmg`. Open the `.dmg` and install the app. Your app will attempt to auto-update in the background.
67 changes: 60 additions & 7 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,49 @@ if (isDev) {
} catch {}
}

// This sets up the command line arguments used to manage the update. When
// the --expected-update-version flag is set, the app will open pre-hidden
// until it detects that it has been upgraded to the correct version.

const EXPECTED_UPDATE_VERSION_FLAG = '--expected-update-version';

let expectedUpdateVersion;
for (let i = 0; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg.startsWith(`${EXPECTED_UPDATE_VERSION_FLAG}=`)) {
expectedUpdateVersion = arg.substr((`${EXPECTED_UPDATE_VERSION_FLAG}=`).length);
}
}

// Add the listeners and variables required to ensure that auto-updating
// happens correctly.
let hasUpdate = false;

const quitAndInstallWithUpdate = (version) => {
app.relaunch({
args: [`${EXPECTED_UPDATE_VERSION_FLAG}=${version}`],
});
hasUpdate = true;
autoUpdater.quitAndInstall();
};

const electronUpdater = browserWindow => ({
init: () => {
autoUpdater.on('update-downloaded', (info) => {
if (browserWindow.isVisible()) {
browserWindow.webContents.send('update-downloaded', info.version);
} else {
quitAndInstallWithUpdate(info.version);
}
});

ipcMain.on('start-update', quitAndInstallWithUpdate);
autoUpdater.checkForUpdates();
},
update: () => {
autoUpdater.checkForUpdates();
},
});

const mainWindow = (() => {
const loadURL = isDev
Expand All @@ -63,6 +106,7 @@ const mainWindow = (() => {
width: 1200,
height: 900,
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true,
},
titleBarStyle: 'hidden',
Expand Down Expand Up @@ -121,7 +165,7 @@ const mainWindow = (() => {

// Closing the chat window should just hide it (vs. fully quitting the application)
browserWindow.on('close', (evt) => {
if (!quitting) {
if (!quitting && !hasUpdate) {
evt.preventDefault();
browserWindow.hide();
}
Expand All @@ -138,7 +182,12 @@ const mainWindow = (() => {
});

app.on('before-quit', () => quitting = true);
app.on('activate', () => browserWindow.show());

// Hide the app if we expected to upgrade to a new version but never did.
if (expectedUpdateVersion && app.getVersion() !== expectedUpdateVersion) {
browserWindow.hide();
app.hide();
}

ipcMain.on(ELECTRON_EVENTS.REQUEST_VISIBILITY, (event) => {
// This is how synchronous messages work in Electron
Expand All @@ -162,13 +211,17 @@ const mainWindow = (() => {
})

// After initializing and configuring the browser window, load the compiled JavaScript
.then(browserWindow => loadURL(browserWindow))
.then((browserWindow) => {
loadURL(browserWindow);
return browserWindow;
})

// Start checking for JS updates
.then(() => checkForUpdates({
init: () => autoUpdater.checkForUpdatesAndNotify(),
update: () => autoUpdater.checkForUpdatesAndNotify(),
}));
.then((browserWindow) => {
if (!isDev) {
checkForUpdates(electronUpdater(browserWindow));
}
});
});

mainWindow().then(window => window);
12 changes: 12 additions & 0 deletions src/Expensify.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from './libs/Router';
import ROUTES from './ROUTES';
import PushNotification from './libs/Notification/PushNotification';
import UpdateAppModal from './components/UpdateAppModal';

// Initialize the store when the app loads for the first time
Onyx.init({
Expand Down Expand Up @@ -50,10 +51,14 @@ const propTypes = {

// A route set by Onyx that we will redirect to if present. Always empty on app init.
redirectTo: PropTypes.string,

// Version of newly downloaded update.
version: PropTypes.string,
};

const defaultProps = {
redirectTo: '',
version: '',
};

class Expensify extends PureComponent {
Expand Down Expand Up @@ -107,6 +112,9 @@ class Expensify extends PureComponent {
}
return (
<Router>
{/* We include the modal for showing a new update at the top level so the option is always present. */}
{this.props.version ? <UpdateAppModal updateVersion={this.props.version} /> : null}

{/* If there is ever a property for redirecting, we do the redirect here */}
{/* Leave this as a ternary or else iOS throws an error about text not being wrapped in <Text> */}
{this.props.redirectTo ? <Redirect push to={this.props.redirectTo} /> : null}
Expand Down Expand Up @@ -158,4 +166,8 @@ export default withOnyx({
// report you are brought back to the root of the site (ie. "/").
initWithStoredValues: false,
},
version: {
key: ONYXKEYS.UPDATE_VERSION,
initWithStoredValues: false,
},
})(Expensify);
3 changes: 3 additions & 0 deletions src/ONYXKEYS.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export default {
// Contains the user preference for the LHN priority mode
PRIORITY_MODE: 'priorityMode',

// Contains the version of the update that has newly been downloaded.
UPDATE_VERSION: 'updateVersion',

// Saves the current country code which is displayed when the user types a phone number without
// an international code
COUNTRY_CODE: 'countryCode',
Expand Down
70 changes: 70 additions & 0 deletions src/components/UpdateAppModal/BaseUpdateAppModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import _ from 'underscore';
import React, {PureComponent} from 'react';
import {
TouchableOpacity, Text,
} from 'react-native';
import HeaderWithCloseButton from '../HeaderWithCloseButton';
import Modal from '../Modal';
import styles from '../../styles/styles';
import {propTypes, defaultProps} from './UpdateAppModalPropTypes';

class BaseUpdateAppModal extends PureComponent {
constructor(props) {
super(props);

this.state = {
isModalOpen: true,
};

this.submitAndClose = this.submitAndClose.bind(this);
}

/**
* Execute the onSubmit callback and close the modal.
*/
submitAndClose() {
this.props.onSubmit(this.state.file);
this.setState({isModalOpen: false});
}

render() {
return (
<>
<Modal
onSubmit={this.submitAndClose}
onClose={() => this.setState({isModalOpen: false})}
isVisible={this.state.isModalOpen}
>
<HeaderWithCloseButton
title="Update App"
onCloseButtonPress={() => this.setState({isModalOpen: false})}
/>
<Text style={[styles.textLabel, styles.p4]}>
A new version of Expensify.cash is available.
Update now or restart the app at a later time to download the latest changes.
</Text>
{this.props.onSubmit && (
<TouchableOpacity
style={[styles.button, styles.buttonSuccess, styles.buttonConfirm]}
onPress={this.submitAndClose}
>
<Text
style={[
styles.buttonText,
styles.buttonSuccessText,
styles.buttonConfirmText,
]}
>
Update App
</Text>
</TouchableOpacity>
Copy link
Contributor

@roryabraham roryabraham Feb 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB, but might be nice to have a "Maybe later" cancel button too.

)}
</Modal>
</>
);
}
}

BaseUpdateAppModal.propTypes = _.omit(propTypes, 'version');
BaseUpdateAppModal.defaultProps = _.omit(defaultProps, 'version');
export default BaseUpdateAppModal;
17 changes: 17 additions & 0 deletions src/components/UpdateAppModal/UpdateAppModalPropTypes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import PropTypes from 'prop-types';

const propTypes = {
// Callback to fire when we want to trigger the update.
onSubmit: PropTypes.func,

// Version string for the app to update to.
// eslint-disable-next-line react/no-unused-prop-types
version: PropTypes.string,
};

const defaultProps = {
onSubmit: null,
version: '',
};

export {propTypes, defaultProps};
17 changes: 17 additions & 0 deletions src/components/UpdateAppModal/index.desktop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import {ipcRenderer} from 'electron';
import BaseUpdateAppModal from './BaseUpdateAppModal';
import {propTypes} from './UpdateAppModalPropTypes';

const UpdateAppModal = (props) => {
const updateApp = () => {
if (props.onSubmit) {
props.onSubmit();
}
ipcRenderer.sendSync('start-update', props.version);
};
return <BaseUpdateAppModal onSubmit={updateApp} />;
};
UpdateAppModal.propTypes = propTypes;
UpdateAppModal.displayName = 'UpdateAppModal';
export default UpdateAppModal;
13 changes: 13 additions & 0 deletions src/components/UpdateAppModal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import BaseUpdateAppModal from './BaseUpdateAppModal';
import {propTypes} from './UpdateAppModalPropTypes';

const UpdateAppModal = props => (
<BaseUpdateAppModal
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
UpdateAppModal.propTypes = propTypes;
UpdateAppModal.displayName = 'UpdateAppModal';
export default UpdateAppModal;
19 changes: 19 additions & 0 deletions src/libs/Notification/LocalNotification/BrowserNotifications.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Web and desktop implementation only. Do not import for direct use. Use LocalNotification.
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import focusApp from './focusApp';
import EXPENSIFY_ICON_URL from '../../../../assets/images/expensify-logo-round.png';
import ONYXKEYS from '../../../ONYXKEYS';

const DEFAULT_DELAY = 4000;

Expand Down Expand Up @@ -117,4 +119,21 @@ export default {
onClick,
});
},

/**
* Create a notification to indicate that an update is available.
*
* @param {Object} params
* @param {String} params.version
*/
pushUpdateAvailableNotification({version}) {
push({
title: 'Update available',
body: 'A new version of Expensify.cash is available!',
delay: 0,
onClick: () => {
Onyx.merge(ONYXKEYS.UPDATE_VERSION, version);
},
});
},
};
5 changes: 5 additions & 0 deletions src/libs/Notification/LocalNotification/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ function showCommentNotification({reportAction, onClick}) {
BrowserNotifications.pushReportCommentNotification({reportAction, onClick});
}

function showUpdateAvailableNotification({version}) {
BrowserNotifications.pushUpdateAvailableNotification({version});
}

export default {
showCommentNotification,
showUpdateAvailableNotification,
};
1 change: 1 addition & 0 deletions src/libs/Notification/LocalNotification/index.native.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Local Notifications are not currently supported on mobile so we'll just noop here.
export default {
showCommentNotification: () => {},
showUpdateAvailableNotification: () => {},
};
Loading