Upgrade improvements and refactor shutdown process #20
213
app/main.js
|
@ -1,20 +1,64 @@
|
|||
const {app, BrowserWindow, ipcMain, shell} = require('electron');
|
||||
const {app, BrowserWindow, ipcMain} = require('electron');
|
||||
const path = require('path');
|
||||
const jayson = require('jayson');
|
||||
// tree-kill has better cross-platform handling of
|
||||
// killing a process. child-process.kill was unreliable
|
||||
const kill = require('tree-kill');
|
||||
const child_process = require('child_process');
|
||||
const assert = require('assert');
|
||||
|
||||
|
||||
let client = jayson.client.http('http://localhost:5279/lbryapi');
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
let win
|
||||
let win;
|
||||
// Also keep the daemon subprocess alive
|
||||
let subpy
|
||||
// set to true when the quitting sequence has started
|
||||
let quitting = false;
|
||||
let daemonSubprocess;
|
||||
|
||||
// This is set to true right before we try to shut the daemon subprocess --
|
||||
// if it dies when we didn't ask it to shut down, we want to alert the user.
|
||||
let daemonSubprocessKillRequested = false;
|
||||
|
||||
// When a quit is attempted, we cancel the quit, do some preparations, then
|
||||
// this is set to true and app.quit() is called again to quit for real.
|
||||
let readyToQuit = false;
|
||||
|
||||
/*
|
||||
* Replacement for Electron's shell.openItem. The Electron version doesn't
|
||||
* reliably work from the main process, and we need to be able to run it
|
||||
* when no windows are open.
|
||||
*/
|
||||
function openItem(fullPath) {
|
||||
const subprocOptions = {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
};
|
||||
|
||||
let child;
|
||||
if (process.platform == 'darwin') {
|
||||
child = child_process.spawn('open', [fullPath], subprocOptions);
|
||||
} else if (process.platform == 'linux') {
|
||||
child = child_process.spawn('xdg-open', [fullPath], subprocOptions);
|
||||
} else if (process.platform == 'win32') {
|
||||
child = child_process.execSync('start', [fullPath], subprocOptions);
|
||||
}
|
||||
|
||||
// Causes child process reference to be garbage collected, allowing main process to exit
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function getPidsForProcessName(name) {
|
||||
if (process.platform == 'windows') {
|
||||
const tasklistOut = child_process.execSync('tasklist',
|
||||
['/fi', `Imagename eq ${name}.exe`, '/nh'],
|
||||
{encoding: 'utf8'}
|
||||
).stdout;
|
||||
return tasklistOut.match(/[^\n]+/g).map((line) => line.split(/\s+/)[1]); // Second column of every non-empty line
|
||||
![]() No idea where that came from. Maybe just a copy-and-paste error. I meant for it to say something like "did not intentionally quit daemon, so scheduling quit." No idea where that came from. Maybe just a copy-and-paste error. I meant for it to say something like "did not intentionally quit daemon, so scheduling quit."
|
||||
} else {
|
||||
const pgrepOut = child_process.spawnSync('pgrep', ['-x', name], {encoding: 'utf8'}).stdout;
|
||||
return pgrepOut.match(/\d+/g);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow () {
|
||||
win = new BrowserWindow({backgroundColor: '#155b4a'})
|
||||
|
@ -26,49 +70,61 @@ function createWindow () {
|
|||
})
|
||||
};
|
||||
|
||||
function handleDaemonSubprocessExited() {
|
||||
console.log('The daemon has exited.');
|
||||
daemonSubprocess = null;
|
||||
if (!daemonSubprocessKillRequested) {
|
||||
// We didn't stop the daemon subprocess on purpose, so display a
|
||||
// warning and schedule a quit.
|
||||
//
|
||||
// TODO: maybe it would be better to restart the daemon?
|
||||
if (win) {
|
||||
console.log('Did not request daemon stop, so quitting in 5 seconds.');
|
||||
win.loadURL(`file://${__dirname}/dist/warning.html`);
|
||||
setTimeout(quitNow, 5000);
|
||||
} else {
|
||||
console.log('Did not request daemon stop, so quitting.');
|
||||
quitNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function launchDaemon() {
|
||||
if (subpy) {
|
||||
return;
|
||||
}
|
||||
assert(!daemonSubprocess, 'Tried to launch daemon twice');
|
||||
|
||||
if (process.env.LBRY_DAEMON) {
|
||||
executable = process.env.LBRY_DAEMON;
|
||||
} else {
|
||||
executable = path.join(__dirname, 'dist', 'lbrynet-daemon');
|
||||
}
|
||||
console.log('Launching daemon: ' + executable)
|
||||
subpy = require('child_process').spawn(executable)
|
||||
console.log('Launching daemon:', executable)
|
||||
daemonSubprocess = child_process.spawn(executable)
|
||||
// Need to handle the data event instead of attaching to
|
||||
// process.stdout because the latter doesn't work. I believe on
|
||||
// windows it buffers stdout and we don't get any meaningful output
|
||||
subpy.stdout.on('data', (buf) => {console.log(String(buf).trim());});
|
||||
subpy.stderr.on('data', (buf) => {console.log(String(buf).trim());});
|
||||
subpy.on('exit', () => {
|
||||
console.log('The daemon has exited. Quitting the app');
|
||||
subpy = null;
|
||||
if (quitting) {
|
||||
// If quitting is True it means that we were expecting the daemon
|
||||
// to be shutdown so we can quit right away
|
||||
app.quit();
|
||||
} else {
|
||||
// Otherwise, this shutdown was a surprise so display a warning
|
||||
// and schedule a quit
|
||||
//
|
||||
// TODO: maybe it would be better to restart the daemon?
|
||||
win.loadURL(`file://${__dirname}/dist/warning.html`);
|
||||
setTimeout(app.quit, 5000)
|
||||
}
|
||||
});
|
||||
daemonSubprocess.stdout.on('data', (buf) => {console.log(String(buf).trim());});
|
||||
daemonSubprocess.stderr.on('data', (buf) => {console.log(String(buf).trim());});
|
||||
daemonSubprocess.on('exit', handleDaemonSubprocessExited);
|
||||
console.log('lbrynet daemon has launched')
|
||||
}
|
||||
|
||||
/*
|
||||
* Quits without any preparation. When a quit is requested (either through the
|
||||
* interface or through app.quit()), we abort the quit, try to shut down the daemon,
|
||||
* and then call this to quit for real.
|
||||
*/
|
||||
function quitNow() {
|
||||
readyToQuit = true;
|
||||
app.quit();
|
||||
}
|
||||
|
||||
|
||||
app.on('ready', function(){
|
||||
launchDaemonIfNotRunning();
|
||||
![]() We could probably use Promises to simplify the shutdown process so we're not calling quitNow in so many places, but for now my goal is to make the logic extremely explicit and easy to reason about. We could probably use Promises to simplify the shutdown process so we're not calling quitNow in so many places, but for now my goal is to make the logic extremely explicit and easy to reason about.
![]() is this cross-platform? is this cross-platform?
![]() It'll work on Mac and Linux. Still thinking about Windows. It'll work on Mac and Linux. Still thinking about Windows.
|
||||
createWindow();
|
||||
});
|
||||
|
||||
|
||||
function launchDaemonIfNotRunning() {
|
||||
// Check if the daemon is already running. If we get
|
||||
// an error its because its not running
|
||||
|
@ -86,6 +142,36 @@ function launchDaemonIfNotRunning() {
|
|||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Last resort for killing unresponsive daemon instances.
|
||||
* Looks for any processes called "lbrynet-daemon" and
|
||||
* tries to force kill them.
|
||||
*/
|
||||
function forceKillAllDaemonsAndQuit() {
|
||||
console.log('Attempting to force kill any running lbrynet-daemon instances...');
|
||||
|
||||
const daemonPids = getPidsForProcessName('lbrynet-daemon');
|
||||
if (!daemonPids) {
|
||||
console.log('No lbrynet-daemon found running.');
|
||||
quitNow();
|
||||
} else {
|
||||
console.log(`Found ${daemonPids.length} running daemon instances. Attempting to force kill...`);
|
||||
|
||||
for (const pid of daemonPids) {
|
||||
const daemonKillAttemptsComplete = 0;
|
||||
kill(pid, 'SIGKILL', (err) => {
|
||||
daemonKillAttemptsComplete++;
|
||||
if (err) {
|
||||
console.log(`Failed to force kill running daemon with pid ${pid}. Error message: ${err.message}`);
|
||||
}
|
||||
if (daemonKillAttemptsComplete >= daemonPids.length) {
|
||||
quitNow();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Quit when all windows are closed.
|
||||
![]() does this work on osx and windows? or is this only called on linux? does this work on osx and windows? or is this only called on linux?
![]() also, i find it hard to believe that we cant tell whether this worked or failed also, i find it hard to believe that we cant tell whether this worked or failed
![]() It's called everywhere, but yesterday I factored this out and made it choose the command based on OS. That'll go up with my next set of changes. Re: checking if it worked or failed, we can detect some failure conditions (where the installer or package manager completely fails to launch, arguably the least likely scenario) by seeing if the process returns with an error status code. But we can't detect anything that happens after that, like getting that weird dialog box because of a user mismatch. But you're right, I'll add a check for an error code. It's called everywhere, but yesterday I factored this out and made it choose the command based on OS. That'll go up with my next set of changes.
Re: checking if it worked or failed, we can detect _some_ failure conditions (where the installer or package manager completely fails to launch, arguably the least likely scenario) by seeing if the process returns with an error status code. But we can't detect anything that happens after that, like getting that weird dialog box because of a user mismatch. But you're right, I'll add a check for an error code.
![]() gotcha gotcha
|
||||
app.on('window-all-closed', () => {
|
||||
|
@ -98,12 +184,15 @@ app.on('window-all-closed', () => {
|
|||
|
||||
![]() reminder to remove this before merging reminder to remove this before merging
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (subpy == null) {
|
||||
return
|
||||
if (!readyToQuit) {
|
||||
// We need to shutdown the daemon before we're ready to actually quit. This
|
||||
// event will be triggered re-entrantly once preparation is done.
|
||||
event.preventDefault();
|
||||
shutdownDaemonAndQuit();
|
||||
} else {
|
||||
console.log('Quitting.')
|
||||
}
|
||||
event.preventDefault();
|
||||
shutdownDaemon();
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
app.on('activate', () => {
|
||||
|
@ -112,52 +201,60 @@ app.on('activate', () => {
|
|||
if (win === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
function shutdownDaemon(evenIfNotStartedByApp = false) {
|
||||
if (subpy) {
|
||||
// When a quit is attempted, this is called. It attempts to shutdown the daemon,
|
||||
// then calls quitNow() to quit for real.
|
||||
function shutdownDaemonAndQuit(evenIfNotStartedByApp = false) {
|
||||
if (daemonSubprocess) {
|
||||
console.log('Killing lbrynet-daemon process');
|
||||
kill(subpy.pid, undefined, (err) => {
|
||||
kill(daemonSubprocess.pid, undefined, (err) => {
|
||||
console.log('Killed lbrynet-daemon process');
|
||||
requestedDaemonSubprocessKilled = true;
|
||||
quitNow();
|
||||
});
|
||||
} else if (evenIfNotStartedByApp) {
|
||||
console.log('Killing lbrynet-daemon, even though app did not start it');
|
||||
client.request('daemon_stop', []);
|
||||
// TODO: If the daemon errors or times out when we make this request, find
|
||||
// the process and force quit it.
|
||||
console.log('Stopping lbrynet-daemon, even though app did not start it');
|
||||
client.request('daemon_stop', [], (err, res) => {
|
||||
if (err) {
|
||||
// We could get an error because the daemon is already stopped (good)
|
||||
// or because it's running but not responding properly (bad).
|
||||
// So try to force kill any daemons that are still running.
|
||||
|
||||
console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}`);
|
||||
forceKillAllDaemonsAndQuit();
|
||||
} else {
|
||||
console.log('Successfully stopped daemon via RPC call.')
|
||||
quitNow();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('Not killing lbrynet-daemon because app did not start it')
|
||||
console.log('Not killing lbrynet-daemon because app did not start it');
|
||||
quitNow();
|
||||
}
|
||||
|
||||
// Is it safe to start the installer before the daemon finishes running?
|
||||
// If not, we should wait until the daemon is closed before we start the install.
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
if (win) {
|
||||
win.loadURL(`file://${__dirname}/dist/quit.html`);
|
||||
}
|
||||
quitting = true;
|
||||
shutdownDaemon();
|
||||
}
|
||||
|
||||
function upgrade(event, installerPath) {
|
||||
app.on('quit', () => {
|
||||
shell.openItem(installerPath);
|
||||
console.log('Launching upgrade installer at', installerPath);
|
||||
// This gets triggered called after *all* other quit-related events, so
|
||||
// we'll only get here if we're fully prepared and quitting for real.
|
||||
openItem(installerPath);
|
||||
});
|
||||
|
||||
if (win) {
|
||||
win.loadURL(`file://${__dirname}/dist/upgrade.html`);
|
||||
}
|
||||
quitting = true;
|
||||
shutdownDaemon(true);
|
||||
|
||||
app.quit();
|
||||
// wait for daemon to shut down before upgrading
|
||||
// what to do if no shutdown in a long time?
|
||||
console.log('Update downloaded to ', installerPath);
|
||||
console.log('Update downloaded to', installerPath);
|
||||
console.log('The app will close, and you will be prompted to install the latest version of LBRY.');
|
||||
console.log('After the install is complete, please reopen the app.');
|
||||
}
|
||||
|
||||
ipcMain.on('upgrade', upgrade);
|
||||
|
||||
ipcMain.on('shutdown', shutdown);
|
86
ui/js/app.js
|
@ -24,9 +24,9 @@ import {Link} from './component/link.js';
|
|||
|
||||
const {remote, ipcRenderer, shell} = require('electron');
|
||||
const {download} = remote.require('electron-dl');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const app = require('electron').remote.app;
|
||||
const fs = remote.require('fs');
|
||||
|
||||
|
||||
var App = React.createClass({
|
||||
|
@ -42,14 +42,26 @@ var App = React.createClass({
|
|||
_upgradeDownloadItem: null,
|
||||
_version: null,
|
||||
|
||||
// Temporary workaround since electron-dl throws errors when you try to get the filename
|
||||
getUpdateUrl: function() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return 'https://lbry.io/get/lbry.dmg';
|
||||
case 'linux':
|
||||
return 'https://lbry.io/get/lbry.deb';
|
||||
case 'win32':
|
||||
return 'https://lbry.io/get/lbry.exe';
|
||||
}
|
||||
},
|
||||
// Hard code the filenames as a temporary workaroound, because
|
||||
// electron-dl throws errors when you try to get the filename
|
||||
getUpgradeFilename: function() {
|
||||
if (os.platform() == 'darwin') {
|
||||
return `LBRY-${this._version}.dmg`;
|
||||
} else if (os.platform() == 'linux') {
|
||||
return `LBRY-${this._version}.deb`;
|
||||
} else {
|
||||
return `LBRY-${this._version}_amd64.deb`;
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return `LBRY-${this._version}.dmg`;
|
||||
case 'linux':
|
||||
return `LBRY_${this._version}_amd64.deb`;
|
||||
case 'windows':
|
||||
return `LBRY.Setup.${this._version}.exe`;
|
||||
}
|
||||
},
|
||||
getInitialState: function() {
|
||||
|
@ -66,8 +78,6 @@ var App = React.createClass({
|
|||
pageArgs: typeof val !== 'undefined' ? val : null,
|
||||
errorInfo: null,
|
||||
modal: null,
|
||||
updateUrl: null,
|
||||
isOldOSX: null,
|
||||
downloadProgress: null,
|
||||
downloadComplete: false,
|
||||
};
|
||||
|
@ -90,38 +100,20 @@ var App = React.createClass({
|
|||
}
|
||||
});
|
||||
|
||||
lbry.checkNewVersionAvailable((isAvailable) => {
|
||||
if (!isAvailable || sessionStorage.getItem('upgradeSkipped')) {
|
||||
return;
|
||||
}
|
||||
|
||||
lbry.getVersionInfo((versionInfo) => {
|
||||
this._version = versionInfo.lbrynet_version; // temp for building upgrade filename
|
||||
|
||||
var isOldOSX = false;
|
||||
if (versionInfo.os_system == 'Darwin') {
|
||||
var updateUrl = 'https://lbry.io/get/lbry.dmg';
|
||||
|
||||
var maj, min, patch;
|
||||
[maj, min, patch] = versionInfo.lbrynet_version.split('.');
|
||||
if (maj == 0 && min <= 2 && patch <= 2) {
|
||||
isOldOSX = true;
|
||||
}
|
||||
} else if (versionInfo.os_system == 'Linux') {
|
||||
var updateUrl = 'https://lbry.io/get/lbry.deb';
|
||||
} else if (versionInfo.os_system == 'Windows') {
|
||||
var updateUrl = 'https://lbry.io/get/lbry.exe';
|
||||
} else {
|
||||
var updateUrl = 'https://lbry.io/get';
|
||||
if (!sessionStorage.getItem('upgradeSkipped')) {
|
||||
lbry.checkNewVersionAvailable(({isAvailable}) => {
|
||||
if (!isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
modal: 'upgrade',
|
||||
isOldOSX: isOldOSX,
|
||||
updateUrl: updateUrl,
|
||||
})
|
||||
lbry.getVersionInfo((versionInfo) => {
|
||||
this._version = versionInfo.lbrynet_version;
|
||||
this.setState({
|
||||
modal: 'upgrade',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
openDrawer: function() {
|
||||
sessionStorage.setItem('drawerOpen', true);
|
||||
|
@ -137,16 +129,14 @@ var App = React.createClass({
|
|||
});
|
||||
},
|
||||
handleUpgradeClicked: function() {
|
||||
// TODO: create a callback for onProgress and have the UI
|
||||
// show download progress
|
||||
// TODO: calling lbry.stop() ends up displaying the "daemon
|
||||
// unexpectedly stopped" page. Have a better way of shutting down
|
||||
let dir = app.getPath('temp');
|
||||
// Make a new directory within temp directory so the filename is guaranteed to be available
|
||||
const dir = fs.mkdtempSync(app.getPath('temp') + require('path').sep);
|
||||
|
||||
let options = {
|
||||
onProgress: (p) => this.setState({downloadProgress: Math.round(p * 100)}),
|
||||
directory: dir,
|
||||
};
|
||||
download(remote.getCurrentWindow(), this.state.updateUrl, options)
|
||||
download(remote.getCurrentWindow(), this.getUpdateUrl(), options)
|
||||
.then(downloadItem => {
|
||||
/**
|
||||
* TODO: get the download path directly from the download object. It should just be
|
||||
|
@ -289,11 +279,7 @@ var App = React.createClass({
|
|||
<Modal isOpen={this.state.modal == 'upgrade'} contentLabel="Update available"
|
||||
type="confirm" confirmButtonLabel="Upgrade" abortButtonLabel="Skip"
|
||||
onConfirmed={this.handleUpgradeClicked} onAborted={this.handleSkipClicked}>
|
||||
<p>Your version of LBRY is out of date and may be unreliable or insecure.</p>
|
||||
{this.state.isOldOSX
|
||||
? <p>Before installing the new version, make sure to exit LBRY. If you started the app, click the LBRY icon in your status bar and choose "Quit."</p>
|
||||
: null}
|
||||
|
||||
Your version of LBRY is out of date and may be unreliable or insecure.
|
||||
</Modal>
|
||||
<Modal isOpen={this.state.modal == 'downloading'} contentLabel="Downloading Update" type="custom">
|
||||
Downloading Update{this.state.downloadProgress ? `: ${this.state.downloadProgress}%` : null}
|
||||
|
|
What does this mean?
Did not display
?