/* * 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.plugins.engine; import java.io.IOException; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.shootoff.plugins.BouncingTargets; import com.shootoff.plugins.DuelingTree; import com.shootoff.plugins.ExerciseMetadata; import com.shootoff.plugins.ISSFStandardPistol; import com.shootoff.plugins.ParForScore; import com.shootoff.plugins.ParRandomShot; import com.shootoff.plugins.RandomShoot; import com.shootoff.plugins.ShootDontShoot; import com.shootoff.plugins.ShootForScore; import com.shootoff.plugins.SteelChallenge; import com.shootoff.plugins.TimedHolsterDrill; import com.shootoff.plugins.TrainingExercise; import com.shootoff.util.VersionChecker; /** * Watch for new plugin jars and manage plugin registration and deletion. * * @author phrack */ public class PluginEngine implements Runnable { private static final Logger logger = LoggerFactory.getLogger(PluginEngine.class); private final Path pluginDir; private final PluginListener pluginListener; private final PathMatcher jarMatcher = FileSystems.getDefault().getPathMatcher("glob:*.jar"); private final WatchService watcher = FileSystems.getDefault().newWatchService(); private final Set<Plugin> plugins = new HashSet<>(); private final AtomicBoolean watching = new AtomicBoolean(false); public PluginEngine(final PluginListener pluginListener) throws IOException { if (pluginListener == null) { throw new IllegalArgumentException("pluginListener cannot be null"); } pluginDir = Paths.get(System.getProperty("shootoff.plugins")); this.pluginListener = pluginListener; if (!Files.exists(pluginDir) && !pluginDir.toFile().mkdirs()) { logger.error("The path specified by shootoff.plugins doesn't exist and we couldn't create it."); return; } if (!Files.isDirectory(pluginDir)) { logger.error("Can't enumerate existing plugins or watch for new plugins because the " + "shootoff.plugins property is not set to a directory"); } registerDefaultStandardTrainingExercises(); enumerateExistingPlugins(); registerDefaultProjectorExercises(); pluginDir.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); } private void registerDefaultStandardTrainingExercises() { pluginListener.registerExercise(new ISSFStandardPistol()); pluginListener.registerExercise(new RandomShoot()); pluginListener.registerExercise(new ShootForScore()); pluginListener.registerExercise(new TimedHolsterDrill()); pluginListener.registerExercise(new ParForScore()); pluginListener.registerExercise(new ParRandomShot()); } private void registerDefaultProjectorExercises() { pluginListener.registerProjectorExercise(new BouncingTargets()); pluginListener.registerProjectorExercise(new DuelingTree()); pluginListener.registerProjectorExercise(new ShootDontShoot()); pluginListener.registerProjectorExercise(new SteelChallenge()); } private boolean registerPlugin(final Path jarPath) { final Plugin registeringPlugin; try { registeringPlugin = new Plugin(jarPath); } catch (final Exception e) { logger.error("Error creating new plugin", e); return false; } // If the plugin already exists and the new plugin is newer, // unregister the old plugin before registering the new one. // If the new plugin is actually older, don't load it final Optional<Plugin> existingPlugin = findPlugin(registeringPlugin); if (existingPlugin.isPresent()) { final Plugin existing = existingPlugin.get(); final ExerciseMetadata existingMetadata = existing.getExercise().getInfo(); final ExerciseMetadata registeringMetadata = registeringPlugin.getExercise().getInfo(); final String existingVersion = existing.getExercise().getInfo().getVersion(); final String loadedVersion = registeringPlugin.getExercise().getInfo().getVersion(); if (VersionChecker.compareVersions(existingVersion, loadedVersion) == -1) { // Existing is older logger.debug("Registering plugin ({}, {}, {}, {}) is a newer duplicate of an " + "already registered plugin ({}, {}, {}, {})", registeringMetadata.getName(), registeringMetadata.getVersion(), registeringMetadata.getCreator(), registeringPlugin.getJarPath(), existingMetadata.getName(), existingMetadata.getVersion(), existingMetadata.getCreator(), existing.getJarPath()); unregisterPlugin(existing); } else { // Existing is newer or the same, do nothing logger.debug("Registering plugin ({}, {}, {}, {}) is an older or same version duplicate of an " + "already registered plugin ({}, {}, {}, {})", registeringMetadata.getName(), registeringMetadata.getVersion(), registeringMetadata.getCreator(), registeringPlugin.getJarPath(), existingMetadata.getName(), existingMetadata.getVersion(), existingMetadata.getCreator(), existing.getJarPath()); return false; } } if (plugins.add(registeringPlugin)) { if (PluginType.STANDARD.equals(registeringPlugin.getType())) { pluginListener.registerExercise(registeringPlugin.getExercise()); } else if (PluginType.PROJECTOR_ONLY.equals(registeringPlugin.getType())) { pluginListener.registerProjectorExercise(registeringPlugin.getExercise()); } } return true; } private void unregisterPlugin(Plugin plugin) { pluginListener.unregisterExercise(plugin.getExercise()); plugins.remove(plugin); } private void enumerateExistingPlugins() { try { Files.walk(pluginDir).forEach(filePath -> { if (Files.isRegularFile(filePath) && jarMatcher.matches(filePath.getFileName())) { registerPlugin(filePath); } }); } catch (final IOException e) { logger.error("Error enumerating existing external plugins", e); } } private Optional<Plugin> findPlugin(Plugin plugin) { for (final Plugin p : plugins) { final ExerciseMetadata existingMetadata = p.getExercise().getInfo(); final ExerciseMetadata newMetadata = plugin.getExercise().getInfo(); // Plugins are considered to be the same if they have the // same name and creator if (existingMetadata.getName().equals(newMetadata.getName()) && existingMetadata.getCreator().equals(newMetadata.getCreator())) { return Optional.of(p); } } return Optional.empty(); } public Set<Plugin> getPlugins() { return plugins; } public Optional<Plugin> getPlugin(TrainingExercise trainingExercise) { for (final Plugin p : plugins) { if (p.getExercise().getInfo().equals(trainingExercise.getInfo())) return Optional.of(p); } return Optional.empty(); } /** * Start watching for plugin creation and deletion in shootoff.plugins. * Plugin jar creation or deletion leads to a plugin registration or * unregistration. */ public void startWatching() { watching.set(true); new Thread(this, "Plugin Watcher").start(); } /** * Stop watching for plugin creation and deletion in shootoff.plugins. */ public void stopWatching() { watching.set(false); } @Override public void run() { logger.debug("Starting to watch plugins directory"); while (watching.get()) { WatchKey key; try { key = watcher.poll(1, TimeUnit.SECONDS); if (key == null) continue; } catch (final InterruptedException e) { logger.error("Plugin watcher service was interrupted", e); return; } for (final WatchEvent<?> event : key.pollEvents()) { if (StandardWatchEventKinds.OVERFLOW.equals(event.kind())) { continue; } @SuppressWarnings("unchecked") final WatchEvent<Path> ev = (WatchEvent<Path>) event; final Path updatedFile = ev.context(); if (!jarMatcher.matches(updatedFile)) { continue; } final Path fqUpdatedFile = pluginDir.resolve(updatedFile); if (StandardWatchEventKinds.ENTRY_CREATE.equals(event.kind())) { if (!registerPlugin(fqUpdatedFile)) continue; } else if (StandardWatchEventKinds.ENTRY_DELETE.equals(event.kind())) { Optional<Plugin> deletedPlugin = Optional.empty(); for (final Plugin p : plugins) { if (p.getJarPath().equals(fqUpdatedFile)) { deletedPlugin = Optional.of(p); break; } } if (deletedPlugin.isPresent()) { unregisterPlugin(deletedPlugin.get()); } } else { logger.warn("Unexpected plugin watcher event {}", event.kind().toString()); } } if (!key.reset()) { logger.error("Could not reset watch key, cannot receive further plugin watch events"); watching.set(false); } } try { watcher.close(); } catch (final IOException e) { logger.error("Error when stopping plugins directory watcher", e); } logger.debug("Stopped watching plugins directory"); } }