Merge pull request #808 from lbryio/auto-update

Auto Update with electron-updater (WIP)
This commit is contained in:
Liam Cardenas 2018-01-24 14:51:48 -08:00 committed by GitHub
commit 896a894ee1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 366 additions and 45 deletions

View file

@ -79,6 +79,12 @@ if [ "$FULL_BUILD" == "true" ]; then
yarn build
# Workaround: TeamCity expects the dmg to be in dist/mac, but in the new electron-builder
# it's put directly in dist/ (the right way to solve this is to update the TeamCity config)
if $OSX; then
cp dist/*.dmg dist/mac
fi
# electron-build has a publish feature, but I had a hard time getting
# it to reliably work and it also seemed difficult to configure. Not proud of
# this, but it seemed better to write my own.

View file

@ -9,48 +9,42 @@ import github
import uritemplate
import boto3
def main():
upload_to_github_if_tagged('lbryio/lbry-app')
upload_to_s3('app')
def get_asset_filename():
def get_asset_path():
this_dir = os.path.dirname(os.path.realpath(__file__))
system = platform.system()
if system == 'Darwin':
return glob.glob(this_dir + '/../dist/LBRY*.dmg')[0]
suffix = 'dmg'
elif system == 'Linux':
return glob.glob(this_dir + '/../dist/LBRY*.deb')[0]
suffix = 'deb'
elif system == 'Windows':
return glob.glob(this_dir + '/../dist/LBRY*.exe')[0]
suffix = 'exe'
else:
raise Exception("I don't know about any artifact on {}".format(system))
return os.path.realpath(glob.glob(this_dir + '/../dist/LBRY*.' + suffix)[0])
def upload_to_s3(folder):
tag = subprocess.check_output(['git', 'describe', '--always', '--abbrev=8', 'HEAD']).strip()
commit_date = subprocess.check_output([
'git', 'show', '-s', '--format=%cd', '--date=format:%Y%m%d-%H%I%S', 'HEAD']).strip()
def get_update_asset_path():
# Get the asset used used for updates. On Mac, this is a .zip; on
# Windows it's just the installer file.
if platform.system() == 'Darwin':
this_dir = os.path.dirname(os.path.realpath(__file__))
return os.path.realpath(glob.glob(this_dir + '/../dist/LBRY*.zip')[0])
else:
return get_asset_path()
asset_path = get_asset_filename()
bucket = 'releases.lbry.io'
key = folder + '/' + commit_date + '-' + tag + '/' + os.path.basename(asset_path)
print "Uploading " + asset_path + " to s3://" + bucket + '/' + key + ''
def get_latest_file_path():
# The update metadata file is called latest.yml on Windows, latest-mac.yml on
# Mac, latest-linux.yml on Linux
this_dir = os.path.dirname(os.path.realpath(__file__))
if 'AWS_ACCESS_KEY_ID' not in os.environ or 'AWS_SECRET_ACCESS_KEY' not in os.environ:
print 'Must set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to publish assets to s3'
return 1
s3 = boto3.resource(
's3',
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
config=boto3.session.Config(signature_version='s3v4')
)
s3.meta.client.upload_file(asset_path, bucket, key)
latestfilematches = glob.glob(this_dir + '/../dist/latest*.yml')
return latestfilematches[0] if latestfilematches else None
def upload_to_github_if_tagged(repo_name):
try:
@ -75,7 +69,7 @@ def upload_to_github_if_tagged(repo_name):
# TODO: maybe this should be an error
return 1
asset_path = get_asset_filename()
asset_path = get_asset_path()
print "Uploading " + asset_path + " to Github tag " + current_tag
release = get_github_release(repo, current_tag)
upload_asset_to_github(release, asset_path, gh_token)

View file

@ -1,6 +1,10 @@
{
"appId": "io.lbry.LBRY",
"productName": "LBRY",
"publish": {
"provider": "s3",
"bucket": "releases.lbry.io",
"path": "app/latest"
},
"mac": {
"category": "public.app-category.entertainment"
},

View file

@ -1,6 +1,6 @@
{
"name": "LBRY",
"version": "0.19.4",
"version": "0.19.4-dev",
"description": "A browser for the LBRY network, a digital marketplace controlled by its users.",
"homepage": "https://lbry.io/",
"bugs": {
@ -38,6 +38,9 @@
"classnames": "^2.2.5",
"country-data": "^0.0.31",
"electron-dl": "^1.6.0",
"electron-log": "^2.2.12",
"electron-publisher-s3": "^19.47.0",
"electron-updater": "^2.16.1",
"formik": "^0.10.4",
"from2": "^2.3.0",
"install": "^0.10.2",

View file

@ -5,10 +5,25 @@ import SemVer from 'semver';
import url from 'url';
import https from 'https';
import { shell, app, ipcMain, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import Daemon from './Daemon';
import Tray from './Tray';
import createWindow from './createWindow';
autoUpdater.autoDownload = true;
// This is set to true if an auto update has been downloaded through the Electron
// auto-update system and is ready to install. If the user declined an update earlier,
// it will still install on shutdown.
let autoUpdateDownloaded = false;
// Keeps track of whether the user has accepted an auto-update through the interface.
let autoUpdateAccepted = false;
// This is used to keep track of whether we are showing the special dialog
// that we show on Windows after you decline an upgrade and close the app later.
let showingAutoUpdateCloseAlert = false;
// Keep a global reference, if you don't, they will be closed automatically when the JavaScript
// object is garbage collected.
let rendererWindow;
@ -68,7 +83,26 @@ app.on('activate', () => {
if (!rendererWindow) rendererWindow = createWindow();
});
app.on('will-quit', () => {
app.on('will-quit', (event) => {
if (process.platform === 'win32' && autoUpdateDownloaded && !autoUpdateAccepted && !showingAutoUpdateCloseAlert) {
// We're on Win and have an update downloaded, but the user declined it (or closed
// the app without accepting it). Now the user is closing the app, so the new update
// will install. On Mac this is silent, but on Windows they get a confusing permission
// escalation dialog, so we show Windows users a warning dialog first.
showingAutoUpdateCloseAlert = true;
dialog.showMessageBox({
type: 'info',
title: 'LBRY Will Upgrade',
message: 'LBRY has a pending upgrade. Please select "Yes" to install it on the prompt shown after this one.',
}, () => {
app.quit();
});
event.preventDefault();
return;
}
isQuitting = true;
if (daemon) daemon.quit();
});
@ -108,6 +142,15 @@ ipcMain.on('upgrade', (event, installerPath) => {
app.quit();
});
autoUpdater.on('update-downloaded', () => {
autoUpdateDownloaded = true;
})
ipcMain.on('autoUpdateAccepted', () => {
autoUpdateAccepted = true;
autoUpdater.quitAndInstall();
});
ipcMain.on('version-info-requested', () => {
function formatRc(ver) {
// Adds dash if needed to make RC suffix SemVer friendly
@ -195,4 +238,4 @@ const isSecondInstance = app.makeSingleInstance(argv => {
if (isSecondInstance) {
app.exit();
}
}

View file

@ -5,13 +5,14 @@ import { selectIsBackDisabled, selectIsForwardDisabled } from 'redux/selectors/n
import { selectBalance } from 'redux/selectors/wallet';
import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation';
import Header from './view';
import { selectIsUpgradeAvailable } from 'redux/selectors/app';
import { doDownloadUpgrade } from 'redux/actions/app';
import { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app';
import { doDownloadUpgradeRequested } from 'redux/actions/app';
const select = state => ({
isBackDisabled: selectIsBackDisabled(state),
isForwardDisabled: selectIsForwardDisabled(state),
isUpgradeAvailable: selectIsUpgradeAvailable(state),
autoUpdateDownloaded: selectAutoUpdateDownloaded(state),
balance: formatCredits(selectBalance(state) || 0, 2),
});
@ -19,7 +20,7 @@ const perform = dispatch => ({
navigate: path => dispatch(doNavigate(path)),
back: () => dispatch(doHistoryBack()),
forward: () => dispatch(doHistoryForward()),
downloadUpgrade: () => dispatch(doDownloadUpgrade()),
downloadUpgradeRequested: () => dispatch(doDownloadUpgradeRequested()),
});
export default connect(select, perform)(Header);

View file

@ -10,8 +10,9 @@ export const Header = props => {
isBackDisabled,
isForwardDisabled,
isUpgradeAvailable,
autoUpdateDownloaded,
navigate,
downloadUpgrade,
downloadUpgradeRequested,
} = props;
return (
<header id="header">
@ -86,9 +87,9 @@ export const Header = props => {
title={__('Settings')}
/>
</div>
{isUpgradeAvailable && (
{(autoUpdateDownloaded || (process.platform === 'linux' && isUpgradeAvailable)) && (
<Link
onClick={() => downloadUpgrade()}
onClick={() => downloadUpgradeRequested()}
button="primary button--flat"
icon="icon-arrow-up"
label={__('Upgrade App')}

View file

@ -28,6 +28,8 @@ export const UPDATE_VERSION = 'UPDATE_VERSION';
export const UPDATE_REMOTE_VERSION = 'UPDATE_REMOTE_VERSION';
export const SKIP_UPGRADE = 'SKIP_UPGRADE';
export const START_UPGRADE = 'START_UPGRADE';
export const AUTO_UPDATE_DECLINED = 'AUTO_UPDATE_DECLINED';
export const AUTO_UPDATE_DOWNLOADED = 'AUTO_UPDATE_DOWNLOADED';
// Wallet
export const GET_NEW_ADDRESS_STARTED = 'GET_NEW_ADDRESS_STARTED';

View file

@ -2,6 +2,8 @@ export const CONFIRM_FILE_REMOVE = 'confirmFileRemove';
export const INCOMPATIBLE_DAEMON = 'incompatibleDaemon';
export const FILE_TIMEOUT = 'file_timeout';
export const DOWNLOADING = 'downloading';
export const AUTO_UPDATE_DOWNLOADED = "auto_update_downloaded";
export const AUTO_UPDATE_CONFIRM = 'auto_update_confirm';
export const ERROR = 'error';
export const INSUFFICIENT_CREDITS = 'insufficient_credits';
export const UPGRADE = 'upgrade';

View file

@ -9,7 +9,7 @@ import lbry from 'lbry';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { doConditionalAuthNavigate, doDaemonReady, doShowSnackBar } from 'redux/actions/app';
import { doConditionalAuthNavigate, doDaemonReady, doShowSnackBar, doAutoUpdate } from 'redux/actions/app';
import { doUpdateIsNightAsync } from 'redux/actions/settings';
import { doNavigate } from 'redux/actions/navigation';
import { doDownloadLanguages } from 'redux/actions/settings';
@ -18,6 +18,15 @@ import 'scss/all.scss';
import store from 'store';
import app from './app';
const { autoUpdater } = remote.require('electron-updater');
autoUpdater.logger = remote.require("electron-log");
window.addEventListener('contextmenu', event => {
contextMenu(remote.getCurrentWindow(), event.x, event.y, app.env === 'development');
event.preventDefault();
});
ipcRenderer.on('open-uri-requested', (event, uri, newSession) => {
if (uri && uri.startsWith('lbry://')) {
if (uri.startsWith('lbry://?verify=')) {
@ -91,6 +100,22 @@ document.addEventListener('click', event => {
});
const init = () => {
autoUpdater.on("update-downloaded", () => {
app.store.dispatch(doAutoUpdate());
});
if (["win32", "darwin"].includes(process.platform)) {
autoUpdater.on("update-available", () => {
console.log("Update available");
});
autoUpdater.on("update-not-available", () => {
console.log("Update not available");
});
autoUpdater.on("update-downloaded", () => {
console.log("Update downloaded");
app.store.dispatch(doAutoUpdate());
});
}
app.store.dispatch(doUpdateIsNightAsync());
app.store.dispatch(doDownloadLanguages());

View file

@ -0,0 +1,11 @@
import React from "react";
import { connect } from "react-redux";
import { doCloseModal, doAutoUpdateDeclined } from "redux/actions/app";
import ModalAutoUpdateConfirm from "./view";
const perform = dispatch => ({
closeModal: () => dispatch(doCloseModal()),
declineAutoUpdate: () => dispatch(doAutoUpdateDeclined()),
});
export default connect(null, perform)(ModalAutoUpdateConfirm);

View file

@ -0,0 +1,44 @@
import React from "react";
import { Modal } from "modal/modal";
import { Line } from "rc-progress";
import Link from "component/link/index";
const { ipcRenderer } = require("electron");
class ModalAutoUpdateConfirm extends React.PureComponent {
render() {
const { closeModal, declineAutoUpdate } = this.props;
return (
<Modal
isOpen={true}
type="confirm"
contentLabel={__("Update Downloaded")}
confirmButtonLabel={__("Upgrade")}
abortButtonLabel={__("Not now")}
onConfirmed={() => {
ipcRenderer.send("autoUpdateAccepted");
}}
onAborted={() => {
declineAutoUpdate();
closeModal();
}}
>
<section>
<h3 className="text-center">{__("LBRY Update Ready")}</h3>
<p>
{__(
'Your LBRY update is ready. Restart LBRY now to use it!'
)}
</p>
<p className="meta text-center">
{__('Want to know what has changed?')} See the{' '}
<Link label={__('release notes')} href="https://github.com/lbryio/lbry-app/releases" />.
</p>
</section>
</Modal>
);
}
}
export default ModalAutoUpdateConfirm;

View file

@ -0,0 +1,11 @@
import React from "react";
import { connect } from "react-redux";
import { doCloseModal, doAutoUpdateDeclined } from "redux/actions/app";
import ModalAutoUpdateDownloaded from "./view";
const perform = dispatch => ({
closeModal: () => dispatch(doCloseModal()),
declineAutoUpdate: () => dispatch(doAutoUpdateDeclined()),
});
export default connect(null, perform)(ModalAutoUpdateDownloaded);

View file

@ -0,0 +1,41 @@
import React from "react";
import { Modal } from "modal/modal";
import { Line } from "rc-progress";
import Link from "component/link/index";
const { ipcRenderer } = require("electron");
class ModalAutoUpdateDownloaded extends React.PureComponent {
render() {
const { closeModal, declineAutoUpdate } = this.props;
return (
<Modal
isOpen={true}
type="confirm"
contentLabel={__("Update Downloaded")}
confirmButtonLabel={__("Use it Now")}
abortButtonLabel={__("Upgrade on Close")}
onConfirmed={() => {
ipcRenderer.send("autoUpdateAccepted");
}}
onAborted={() => {
declineAutoUpdate();
ipcRenderer.send("autoUpdateDeclined");
closeModal();
}}
>
<section>
<h3 className="text-center">{__("LBRY Leveled Up")}</h3>
<p>
{__(
'A new version of LBRY has been released, downloaded, and is ready for you to use pending a restart.'
)}
</p>
</section>
</Modal>
);
}
}
export default ModalAutoUpdateDownloaded;

View file

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import * as settings from 'constants/settings';
import { selectCurrentModal, selectModalProps } from 'redux/selectors/app';
import { selectCurrentModal, selectModalProps, selectModalsAllowed } from 'redux/selectors/app';
import { selectCurrentPage } from 'redux/selectors/navigation';
import { selectCostForCurrentPageUri } from 'redux/selectors/cost_info';
import { makeSelectClientSetting } from 'redux/selectors/settings';
@ -24,6 +24,7 @@ const select = (state, props) => ({
),
isWelcomeAcknowledged: makeSelectClientSetting(settings.NEW_USER_ACKNOWLEDGED)(state),
user: selectUser(state),
modalsAllowed: selectModalsAllowed(state),
});
const perform = dispatch => ({

View file

@ -2,6 +2,8 @@ import React from 'react';
import ModalError from 'modal/modalError';
import ModalAuthFailure from 'modal/modalAuthFailure';
import ModalDownloading from 'modal/modalDownloading';
import ModalAutoUpdateDownloaded from 'modal/modalAutoUpdateDownloaded';
import ModalAutoUpdateConfirm from 'modal/modalAutoUpdateConfirm';
import ModalUpgrade from 'modal/modalUpgrade';
import ModalWelcome from 'modal/modalWelcome';
import ModalFirstReward from 'modal/modalFirstReward';
@ -96,13 +98,17 @@ class ModalRouter extends React.PureComponent {
}
render() {
const { modal, modalProps } = this.props;
const { modal, modalsAllowed, modalProps } = this.props;
switch (modal) {
case modals.UPGRADE:
return <ModalUpgrade {...modalProps} />;
case modals.DOWNLOADING:
return <ModalDownloading {...modalProps} />;
case modals.AUTO_UPDATE_DOWNLOADED:
return <ModalAutoUpdateDownloaded {...modalProps} />;
case modals.AUTO_UPDATE_CONFIRM:
return <ModalAutoUpdateConfirm {...modalProps} />;
case modals.ERROR:
return <ModalError {...modalProps} />;
case modals.FILE_TIMEOUT:

View file

@ -10,6 +10,8 @@ import { doAuthNavigate } from 'redux/actions/navigation';
import { doFetchDaemonSettings } from 'redux/actions/settings';
import { doAuthenticate } from 'redux/actions/user';
import { doBalanceSubscribe } from 'redux/actions/wallet';
import { doPause } from "redux/actions/media";
import {
selectCurrentModal,
selectIsUpgradeSkipped,
@ -18,8 +20,10 @@ import {
selectUpgradeDownloadItem,
selectUpgradeDownloadPath,
selectUpgradeFilename,
selectAutoUpdateDeclined,
} from 'redux/selectors/app';
const { autoUpdater } = remote.require('electron-updater');
const { download } = remote.require('electron-dl');
const Fs = remote.require('fs');
const { lbrySettings: config } = require('package.json');
@ -66,6 +70,41 @@ export function doStartUpgrade() {
};
}
export function doDownloadUpgradeRequested() {
// This means the user requested an upgrade by clicking the "upgrade" button in the navbar.
// If on Mac and Windows, we do some new behavior for the auto-update system.
// This will probably be reorganized once we get auto-update going on Linux and remove
// the old logic.
return (dispatch, getState) => {
const state = getState();
// Pause video if needed
dispatch(doPause());
const autoUpdateDeclined = selectAutoUpdateDeclined(state);
if (['win32', 'darwin'].includes(process.platform)) { // electron-updater behavior
if (autoUpdateDeclined) {
// The user declined an update before, so show the "confirm" dialog
dispatch({
type: ACTIONS.OPEN_MODAL,
data: { modal: MODALS.AUTO_UPDATE_CONFIRM },
});
} else {
// The user was never shown the original update dialog (e.g. because they were
// watching a video). So show the inital "update downloaded" dialog.
dispatch({
type: ACTIONS.OPEN_MODAL,
data: { modal: MODALS.AUTO_UPDATE_DOWNLOADED },
});
}
} else { // Old behavior for Linux
dispatch(doDownloadUpgrade());
}
};
}
export function doDownloadUpgrade() {
return (dispatch, getState) => {
const state = getState();
@ -105,6 +144,29 @@ export function doDownloadUpgrade() {
};
}
export function doAutoUpdate() {
return function(dispatch, getState) {
const state = getState();
dispatch({
type: ACTIONS.AUTO_UPDATE_DOWNLOADED,
});
dispatch({
type: ACTIONS.OPEN_MODAL,
data: { modal: MODALS.AUTO_UPDATE_DOWNLOADED },
});
};
}
export function doAutoUpdateDeclined() {
return function(dispatch, getState) {
const state = getState();
dispatch({
type: ACTIONS.AUTO_UPDATE_DECLINED,
});
}
}
export function doCancelUpgrade() {
return (dispatch, getState) => {
const state = getState();
@ -135,6 +197,17 @@ export function doCheckUpgradeAvailable() {
type: ACTIONS.CHECK_UPGRADE_START,
});
if (["win32", "darwin"].includes(process.platform)) {
// On Windows and Mac, updates happen silently through
// electron-updater.
const autoUpdateDeclined = selectAutoUpdateDeclined(state);
if (!autoUpdateDeclined) {
autoUpdater.checkForUpdates();
}
return;
}
const success = ({ remoteVersion, upgradeAvailable }) => {
dispatch({
type: ACTIONS.CHECK_UPGRADE_SUCCESS,

View file

@ -26,6 +26,8 @@ export type AppState = {
hasSignature: boolean,
badgeNumber: number,
volume: number,
autoUpdateDeclined: boolean,
modalsAllowed: boolean,
downloadProgress: ?number,
upgradeDownloading: ?boolean,
upgradeDownloadComplete: ?boolean,
@ -46,6 +48,9 @@ const defaultState: AppState = {
hasSignature: false,
badgeNumber: 0,
volume: Number(sessionStorage.getItem('volume')) || 1,
autoUpdateDownloaded: false,
autoUpdateDeclined: false,
modalsAllowed: true,
downloadProgress: undefined,
upgradeDownloading: undefined,
@ -79,6 +84,17 @@ reducers[ACTIONS.UPGRADE_CANCELLED] = state =>
modal: null,
});
reducers[ACTIONS.AUTO_UPDATE_DOWNLOADED] = state =>
Object.assign({}, state, {
autoUpdateDownloaded: true,
});
reducers[ACTIONS.AUTO_UPDATE_DECLINED] = state => {
return Object.assign({}, state, {
autoUpdateDeclined: true,
});
}
reducers[ACTIONS.UPGRADE_DOWNLOAD_COMPLETED] = (state, action) =>
Object.assign({}, state, {
downloadPath: action.data.path,
@ -91,6 +107,11 @@ reducers[ACTIONS.UPGRADE_DOWNLOAD_STARTED] = state =>
upgradeDownloading: true,
});
reducers[ACTIONS.CHANGE_MODALS_ALLOWED] = (state, action) =>
Object.assign({}, state, {
modalsAllowed: action.data.modalsAllowed,
});
reducers[ACTIONS.SKIP_UPGRADE] = state => {
sessionStorage.setItem('upgradeSkipped', 'true');
@ -100,6 +121,28 @@ reducers[ACTIONS.SKIP_UPGRADE] = state => {
});
};
reducers[ACTIONS.MEDIA_PLAY] = state => {
return Object.assign({}, state, {
modalsAllowed: false,
});
};
reducers[ACTIONS.MEDIA_PAUSE] = state => {
return Object.assign({}, state, {
modalsAllowed: true,
});
};
reducers[ACTIONS.SET_PLAYING_URI] = (state, action) => {
if (action.data.uri === null) {
return Object.assign({}, state, {
modalsAllowed: true,
});
} else {
return state;
}
};
reducers[ACTIONS.UPDATE_VERSION] = (state, action) =>
Object.assign({}, state, {
version: action.data.version,
@ -116,12 +159,16 @@ reducers[ACTIONS.CHECK_UPGRADE_SUBSCRIBE] = (state, action) =>
checkUpgradeTimer: action.data.checkUpgradeTimer,
});
reducers[ACTIONS.OPEN_MODAL] = (state, action) =>
Object.assign({}, state, {
modal: action.data.modal,
modalProps: action.data.modalProps || {},
});
reducers[ACTIONS.OPEN_MODAL] = (state, action) => {
if (!state.modalsAllowed) {
return state;
} else {
return Object.assign({}, state, {
modal: action.data.modal,
modalProps: action.data.modalProps || {},
});
}
};
reducers[ACTIONS.CLOSE_MODAL] = state =>
Object.assign({}, state, {
modal: undefined,

View file

@ -56,6 +56,12 @@ export const selectUpgradeDownloadPath = createSelector(selectState, state => st
export const selectUpgradeDownloadItem = createSelector(selectState, state => state.downloadItem);
export const selectAutoUpdateDownloaded = createSelector(selectState, state => state.autoUpdateDownloaded);
export const selectAutoUpdateDeclined = createSelector(selectState, state => state.autoUpdateDeclined);
export const selectModalsAllowed = createSelector(selectState, state => state.modalsAllowed);
export const selectModalProps = createSelector(selectState, state => state.modalProps);
export const selectDaemonVersionMatched = createSelector(