/*
* ShootOFF - Software for Laser Dry Fire Training
* Copyright (C) 2016 phrack
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.shootoff.gui.controller;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import com.google.common.io.Files;
import com.shootoff.Main;
import com.shootoff.plugins.ExerciseMetadata;
import com.shootoff.plugins.engine.Plugin;
import com.shootoff.plugins.engine.PluginEngine;
import com.shootoff.util.VersionChecker;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import javafx.util.Callback;
public class PluginManagerController {
private static final Logger logger = LoggerFactory.getLogger(PluginManagerController.class);
@FXML private VBox pluginManagerPane;
@FXML private TableView<PluginMetadata> pluginsTableView;
private static final String PLUGIN_METADATA_NAME = "shootoff-plugins.xml";
private final ObservableList<PluginMetadata> pluginEntries = FXCollections.observableArrayList();
private PluginEngine pluginEngine;
public void init(PluginEngine pluginEngine, Stage pluginManagerStage) {
final String pluginMetadataAddress = Main.SHOOTOFF_DOMAIN + PLUGIN_METADATA_NAME;
this.pluginEngine = pluginEngine;
final TableColumn<PluginMetadata, String> actionCol = new TableColumn<>("Action");
actionCol.setMinWidth(90);
actionCol
.setCellFactory(new Callback<TableColumn<PluginMetadata, String>, TableCell<PluginMetadata, String>>() {
@Override
public TableCell<PluginMetadata, String> call(TableColumn<PluginMetadata, String> p) {
return new ActionTableCell(p);
}
});
final TableColumn<PluginMetadata, String> nameCol = new TableColumn<>("Name");
nameCol.setMinWidth(160);
nameCol.setCellValueFactory(new PropertyValueFactory<PluginMetadata, String>("Name"));
final TableColumn<PluginMetadata, String> versionCol = new TableColumn<>("Version");
versionCol.setMinWidth(85);
versionCol.setCellValueFactory(new PropertyValueFactory<PluginMetadata, String>("Version"));
final TableColumn<PluginMetadata, String> creatorCol = new TableColumn<>("Creator");
creatorCol.setMinWidth(85);
creatorCol.setCellValueFactory(new PropertyValueFactory<PluginMetadata, String>("Creator"));
final TableColumn<PluginMetadata, String> descriptionCol = new TableColumn<>(
"Description");
descriptionCol.prefWidthProperty().bind(pluginsTableView.widthProperty()
.subtract(actionCol.getWidth() + nameCol.getWidth() + versionCol.getWidth() + creatorCol.getWidth()));
descriptionCol.setCellValueFactory(new PropertyValueFactory<PluginMetadata, String>("Description"));
descriptionCol
.setCellFactory(new Callback<TableColumn<PluginMetadata, String>, TableCell<PluginMetadata, String>>() {
@Override
public TableCell<PluginMetadata, String> call(TableColumn<PluginMetadata, String> param) {
final TableCell<PluginMetadata, String> cell = new TableCell<PluginMetadata, String>() {
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (!isEmpty()) {
final Text text = new Text(item);
text.wrappingWidthProperty().bind(descriptionCol.widthProperty());
setGraphic(text);
} else {
setGraphic(null);
}
}
};
return cell;
}
});
pluginsTableView.getColumns().add(actionCol);
pluginsTableView.getColumns().add(nameCol);
pluginsTableView.getColumns().add(versionCol);
pluginsTableView.getColumns().add(creatorCol);
pluginsTableView.getColumns().add(descriptionCol);
pluginsTableView.setItems(pluginEntries);
final Optional<String> pluginMetadata = getPluginMetadataXML(pluginMetadataAddress);
if (pluginMetadata.isPresent()) {
final Set<PluginMetadata> parsedMetadata = parsePluginMetadata(pluginMetadata.get());
if (parsedMetadata.isEmpty()) {
final Alert metadataAlert = new Alert(AlertType.WARNING);
metadataAlert.setTitle("No Plugins Found");
metadataAlert.setHeaderText("Did not find any plugins.");
metadataAlert.setResizable(true);
metadataAlert.setContentText("There were no plugins found. This likely means there is a problem with "
+ "the metadata and you should email us: project.shootoff@gmail.com.");
metadataAlert.initOwner(pluginManagerStage);
metadataAlert.showAndWait();
} else {
processMetadata(parsedMetadata);
}
} else {
final Alert metadataAlert = new Alert(AlertType.ERROR);
metadataAlert.setTitle("Failed to Get Exercise Data");
metadataAlert.setHeaderText("Could not fetch exercise data from the Internet!");
metadataAlert.setResizable(true);
metadataAlert.setContentText("ShootOFF could not connect to \n\n" + pluginMetadataAddress + " \n\n"
+ "to fetch data about known plugins. Please ensure you are connected to the Internet. The "
+ "exercise manager will now close.");
metadataAlert.initOwner(pluginManagerStage);
metadataAlert.showAndWait();
pluginManagerStage.getOnCloseRequest()
.handle(new WindowEvent(pluginManagerStage, WindowEvent.WINDOW_CLOSE_REQUEST));
pluginManagerStage.close();
}
}
private class ActionTableCell extends TableCell<PluginMetadata, String> {
private final TableColumn<PluginMetadata, String> actionColumn;
private Optional<Task<Boolean>> downloadTask = Optional.empty();
public ActionTableCell(TableColumn<PluginMetadata, String> actionColumn) {
this.actionColumn = actionColumn;
setAlignment(Pos.CENTER);
}
@Override
protected void updateItem(String item, boolean empty) {
if (!empty) {
final int currentIndex = indexProperty().getValue() < 0 ? 0 : indexProperty().getValue();
final PluginMetadata metadata = actionColumn.getTableView().getItems().get(currentIndex);
final Button actionButton = new Button();
actionButton.setOnAction((e) -> {
if (downloadTask.isPresent()) {
downloadTask.get().cancel();
downloadTask = Optional.empty();
} else {
final Optional<Plugin> installedPlugin = metadata.findInstalledPlugin(pluginEngine.getPlugins());
if (installedPlugin.isPresent()) {
if (uninstallPlugin(installedPlugin.get())) actionButton.setText("Install");
} else {
final ProgressIndicator progress = new ProgressIndicator();
progress.setPrefHeight(actionButton.getHeight() - 2);
progress.setPrefWidth(actionButton.getHeight() - 2);
progress.setOnMouseClicked((event) -> actionButton.fire());
actionButton.setGraphic(progress);
downloadTask = installPlugin(metadata, () -> actionButton.setText("Uninstall"),
() -> actionButton.setGraphic(null));
}
}
});
if (metadata.findInstalledPlugin(pluginEngine.getPlugins()).isPresent()) {
actionButton.setText("Uninstall");
} else {
actionButton.setText("Install");
}
setGraphic(actionButton);
} else {
setGraphic(null);
}
}
};
private Optional<Task<Boolean>> installPlugin(PluginMetadata metadata, Runnable successAction,
Runnable completionAction) {
HttpURLConnection connection = null;
InputStream stream = null;
try {
connection = (HttpURLConnection) new URL(metadata.getDownload()).openConnection();
stream = connection.getInputStream();
} catch (final UnknownHostException e) {
logger.error("Could not connect to remote host " + e.getMessage() + " to download plugin.", e);
return Optional.empty();
} catch (final IOException e) {
if (connection != null) connection.disconnect();
logger.error("Failed to get stream to download plugin.", e);
return Optional.empty();
}
final InputStream remoteStream = stream;
final HttpURLConnection con = connection;
final File downloadedFile = new File(String.format("%s%s%s-%s-%s.jar", System.getProperty("shootoff.home"),
File.separator, metadata.getName().replaceAll("\\s", "_"), metadata.getVersion(),
metadata.getCreator()));
final Task<Boolean> downloadTask = new Task<Boolean>() {
@Override
public Boolean call() throws InterruptedException {
final BufferedInputStream bufferedInputStream = new BufferedInputStream(remoteStream);
try (FileOutputStream fileOutputStream = new FileOutputStream(downloadedFile)) {
int count;
final byte buffer[] = new byte[1024];
while (!isCancelled() && (count = bufferedInputStream.read(buffer, 0, buffer.length)) != -1) {
fileOutputStream.write(buffer, 0, count);
}
} catch (final IOException e) {
logger.error("Failed to download plugin", e);
return false;
}
return true;
}
};
downloadTask.setOnCancelled((e) -> {
if (completionAction != null) completionAction.run();
con.disconnect();
if (!downloadedFile.delete()) {
logger.warn("Failed to delete {} from cancelled plugin download.", downloadedFile.getPath());
}
});
downloadTask.setOnSucceeded((e) -> {
if (completionAction != null) completionAction.run();
if (successAction != null) successAction.run();
con.disconnect();
final File pluginFile = new File(
System.getProperty("shootoff.plugins") + File.separator + downloadedFile.getName());
try {
Files.move(downloadedFile, pluginFile);
} catch (final Exception e1) {
logger.error("Failed to move {} to {} after downloading plugin.", downloadedFile.getPath(),
pluginFile.getPath());
}
});
new Thread(downloadTask).start();
return Optional.of(downloadTask);
}
private boolean uninstallPlugin(final Plugin plugin) {
if (!plugin.getJarPath().toFile().delete()) {
logger.error("Failed to uninstall file {} -- if Windows it's because the OS has a write lock on the file", plugin.getJarPath().toString());
return false;
}
return true;
}
private Optional<String> getPluginMetadataXML(String metadataAddress) {
HttpURLConnection connection = null;
InputStream stream = null;
try {
connection = (HttpURLConnection) new URL(metadataAddress).openConnection();
stream = connection.getInputStream();
} catch (final UnknownHostException e) {
logger.error("Could not connect to remote host " + e.getMessage() + " to download plugin metadata.", e);
return Optional.empty();
} catch (final IOException e) {
if (connection != null) connection.disconnect();
logger.error("Error downloading plugin metadata", e);
return Optional.empty();
}
final StringBuilder versionXML = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(stream, "UTF-8"))) {
String line;
while ((line = br.readLine()) != null) {
if (versionXML.length() > 0) versionXML.append("\n");
versionXML.append(line);
}
} catch (final IOException e) {
connection.disconnect();
logger.error("Failed to fetch plugin metadata", e);
return Optional.empty();
}
connection.disconnect();
return Optional.of(versionXML.toString());
}
protected Set<PluginMetadata> parsePluginMetadata(final String pluginMetadata) {
final PluginMetadataXMLHandler handler = new PluginMetadataXMLHandler();
try (InputStream xmlInput = new ByteArrayInputStream(pluginMetadata.getBytes("UTF-8"))) {
final SAXParser saxParser = SAXParserFactory.newInstance().newSAXParser();
saxParser.parse(xmlInput, handler);
} catch (ParserConfigurationException | IOException e) {
logger.error("Error reading plugin metadata XML file from website", e);
} catch (SAXException e) {
if (logger.isWarnEnabled()) logger.warn("Failed to download complete plugin metadata, no or "
+ "unreliable Internet connection. pluginMetadata = {}", pluginMetadata);
}
return handler.getPluginMetada();
}
private boolean isPluginCompatible(final String minShootOFFVersion, final String maxShootOFFVersion) {
return isPluginCompatible(Main.getVersion(), minShootOFFVersion, maxShootOFFVersion);
}
// For testing
protected boolean isPluginCompatible(final Optional<String> currentShootOFFVersion, final String minShootOFFVersion,
final String maxShootOFFVersion) {
return currentShootOFFVersion.isPresent()
&& VersionChecker.compareVersions(currentShootOFFVersion.get(), minShootOFFVersion) >= 0
&& VersionChecker.compareVersions(currentShootOFFVersion.get(), maxShootOFFVersion) <= 0;
}
protected static class PluginMetadata {
private final String name;
private final String version;
private final String minShootOFFVersion;
private final String maxShootOFFVersion;
private final String creator;
private final String download;
private final String description;
public PluginMetadata(String name, String version, String minShootOFFVersion, String maxShootOFFVersion,
String creator, String download, String description) {
this.name = name;
this.version = version;
this.minShootOFFVersion = minShootOFFVersion;
this.maxShootOFFVersion = maxShootOFFVersion;
this.creator = creator;
this.download = download;
this.description = description;
}
public String getName() {
return name;
}
public String getVersion() {
return version;
}
public String getMinShootOFFVersion() {
return minShootOFFVersion;
}
public String getMaxShootOFFVersion() {
return maxShootOFFVersion;
}
public String getCreator() {
return creator;
}
public String getDownload() {
return download;
}
public String getDescription() {
return description;
}
public Optional<Plugin> findInstalledPlugin(final Set<Plugin> plugins) {
for (final Plugin p : plugins) {
final ExerciseMetadata exerciseMetadata = p.getExercise().getInfo();
if (exerciseMetadata.getName().equals(getName())) {
return Optional.of(p);
}
}
return Optional.empty();
}
}
private static class PluginMetadataXMLHandler extends DefaultHandler {
private final Set<PluginMetadata> pluginMetadata = new HashSet<>();
public Set<PluginMetadata> getPluginMetada() {
return pluginMetadata;
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes)
throws SAXException {
switch (qName) {
case "plugin":
pluginMetadata.add(new PluginMetadata(attributes.getValue("name"), attributes.getValue("version"),
attributes.getValue("minShootOFFVersion"), attributes.getValue("maxShootOFFVersion"),
attributes.getValue("creator"), attributes.getValue("download"),
attributes.getValue("description")));
break;
}
}
}
private void processMetadata(Set<PluginMetadata> pluginMetadata) {
for (final PluginMetadata metadata : pluginMetadata) {
final Optional<Plugin> installedPlugin = metadata.findInstalledPlugin(pluginEngine.getPlugins());
if (isPluginCompatible(metadata.getMinShootOFFVersion(), metadata.getMaxShootOFFVersion())) {
if (installedPlugin.isPresent()
&& VersionChecker.compareVersions(installedPlugin.get().getExercise().getInfo().getVersion(),
metadata.getVersion()) < 0) {
// Plugin is already installed but the installed version is
// older than the current compatible version, so auto-update
// it
uninstallPlugin(installedPlugin.get());
installPlugin(metadata, null, () -> pluginEntries.add(metadata));
} else {
pluginEntries.add(metadata);
}
}
}
}
public Node getPane() {
return pluginManagerPane;
}
}