diff --git a/runelite-client/src/main/java/com/openosrs/client/OpenOSRS.java b/runelite-client/src/main/java/com/openosrs/client/OpenOSRS.java new file mode 100644 index 0000000000..e2e0d25071 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/OpenOSRS.java @@ -0,0 +1,13 @@ +package com.openosrs.client; + +import java.io.File; +import java.util.UUID; + +public class OpenOSRS +{ + public static final File OPENOSRS_DIR = new File(System.getProperty("user.home"), ".openosrs"); + public static final File EXTERNALPLUGIN_DIR = new File(OPENOSRS_DIR, "plugins"); + public static final String SYSTEM_VERSION = "0.0.1"; + + public static String uuid = UUID.randomUUID().toString(); +} diff --git a/runelite-client/src/main/java/com/openosrs/client/config/OpenOSRSConfig.java b/runelite-client/src/main/java/com/openosrs/client/config/OpenOSRSConfig.java new file mode 100644 index 0000000000..061daec88a --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/config/OpenOSRSConfig.java @@ -0,0 +1,160 @@ +/* + * + * Copyright (c) 2019, Zeruth + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ +package com.openosrs.client.config; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.Keybind; +import net.runelite.client.config.Range; +import net.runelite.client.config.Units; +import com.openosrs.client.plugins.ExternalPluginManager; + +@ConfigGroup("openosrs") +public interface OpenOSRSConfig extends Config +{ + @Getter(AccessLevel.PUBLIC) + @AllArgsConstructor + enum SortStyle + { + CATEGORY("Category"), + ALPHABETICALLY("Alphabetically"), + REPOSITORY("Repository"); + + private String name; + + @Override + public String toString() + { + return getName(); + } + } + + @ConfigItem( + position = 3, + keyName = "shareLogs", + name = "Share anonymous error data", + description = "Share anonymous error data with the OpenOSRS developers" + ) + default boolean shareLogs() + { + return true; + } + + @ConfigItem( + keyName = "enableOpacity", + name = "Enable opacity", + description = "Enables opacity for the whole window.
NOTE: This only stays enabled if your pc supports this!", + position = 18 + ) + default boolean enableOpacity() + { + return false; + } + + @Range( + min = 15, + max = 100 + ) + @ConfigItem( + keyName = "opacityPercentage", + name = "Opacity percentage", + description = "Changes the opacity of the window if opacity is enabled", + position = 19 + ) + @Units(Units.PERCENT) + default int opacityPercentage() + { + return 100; + } + + @ConfigItem( + keyName = "localSync", + name = "Sync local instances", + description = "Enables multiple local instances of OpenOSRS to communicate (this enables syncing plugin state and config options)", + position = 21 + ) + default boolean localSync() + { + return true; + } + + @ConfigItem( + keyName = "keyboardPin", + name = "Keyboard bank pin", + description = "Enables you to type your bank pin", + position = 22 + ) + default boolean keyboardPin() + { + return false; + } + + @ConfigItem( + keyName = "detachHotkey", + name = "Detach Cam", + description = "Detach Camera hotkey, press this and it will activate detatched camera.", + position = 23 + ) + default Keybind detachHotkey() + { + return Keybind.NOT_SET; + } + + @ConfigItem( + keyName = "externalRepositories", + name = "", + description = "", + hidden = true + ) + default String getExternalRepositories() + { + return ExternalPluginManager.DEFAULT_PLUGIN_REPOS; + } + + @ConfigItem( + keyName = "externalRepositories", + name = "", + description = "", + hidden = true + ) + void setExternalRepositories(String val); + + @ConfigItem( + keyName = "warning", + name = "", + description = "", + hidden = true + ) + default boolean warning() + { + return true; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/neverlog/NeverLogoutPlugin.java b/runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginChanged.java similarity index 61% rename from runelite-client/src/main/java/com/openosrs/client/plugins/neverlog/NeverLogoutPlugin.java rename to runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginChanged.java index fdb0b66d4b..c7bc255929 100644 --- a/runelite-client/src/main/java/com/openosrs/client/plugins/neverlog/NeverLogoutPlugin.java +++ b/runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginChanged.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, OpenOSRS + * Copyright (c) 2019 Owain van Brakel * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -22,38 +22,16 @@ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +package com.openosrs.client.events; -package com.openosrs.client.plugins.neverlog; - -import javax.inject.Inject; -import net.runelite.api.Client; -import net.runelite.api.events.GameTick; -import net.runelite.client.eventbus.Subscribe; +import lombok.Data; +import net.runelite.api.events.Event; import net.runelite.client.plugins.Plugin; -import net.runelite.client.plugins.PluginDescriptor; -@PluginDescriptor( - name = "Never Logout", - enabledByDefault = false, - description = "Overrides the 5 minute AFK logout timer.", - tags = {"openosrs", "never log", "idle", "logout", "log", "never"} -) -@SuppressWarnings("unchecked") -public class NeverLogoutPlugin extends Plugin +@Data +public class ExternalPluginChanged implements Event { - @Inject - private Client client; - - @Subscribe - private void onGameTick(GameTick gameTick) - { - if (client.getKeyboardIdleTicks() > 14900) - { - client.setKeyboardIdleTicks(0); - } - if (client.getMouseIdleTicks() > 14900) - { - client.setMouseIdleTicks(0); - } - } -} \ No newline at end of file + private final String pluginId; + private final Plugin plugin; + private final boolean added; +} diff --git a/runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginsLoaded.java b/runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginsLoaded.java new file mode 100644 index 0000000000..75f3910d81 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/events/ExternalPluginsLoaded.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019 Owain van Brakel + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.openosrs.client.events; + +import lombok.Data; +import net.runelite.api.events.Event; + +@Data +public class ExternalPluginsLoaded implements Event +{} diff --git a/runelite-client/src/main/java/com/openosrs/client/events/ExternalRepositoryChanged.java b/runelite-client/src/main/java/com/openosrs/client/events/ExternalRepositoryChanged.java new file mode 100644 index 0000000000..ff4e46c795 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/events/ExternalRepositoryChanged.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019 Owain van Brakel + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.openosrs.client.events; + +import lombok.Data; +import net.runelite.api.events.Event; + +@Data +public class ExternalRepositoryChanged implements Event +{ + private final String owner; + private final boolean added; +} diff --git a/runelite-client/src/main/java/com/openosrs/client/PluginManager.java b/runelite-client/src/main/java/com/openosrs/client/plugins/BuiltInPluginManager.java similarity index 72% rename from runelite-client/src/main/java/com/openosrs/client/PluginManager.java rename to runelite-client/src/main/java/com/openosrs/client/plugins/BuiltInPluginManager.java index 8911d77ea5..56397a78e7 100644 --- a/runelite-client/src/main/java/com/openosrs/client/PluginManager.java +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/BuiltInPluginManager.java @@ -1,12 +1,12 @@ -package com.openosrs.client; +package com.openosrs.client.plugins; -import com.openosrs.client.plugins.neverlog.NeverLogoutPlugin; +import com.openosrs.client.plugins.openosrs.OpenOSRSPlugin; import java.util.ArrayList; import java.util.List; import net.runelite.client.externalplugins.ExternalPluginManager; import net.runelite.client.plugins.PluginInstantiationException; -public class PluginManager +public class BuiltInPluginManager { public static List> oprsPlugins = new ArrayList<>(); @@ -24,6 +24,6 @@ public class PluginManager static { - oprsPlugins.add(NeverLogoutPlugin.class); + oprsPlugins.add(OpenOSRSPlugin.class); } } diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPf4jPluginManager.java b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPf4jPluginManager.java new file mode 100644 index 0000000000..e151ba3df2 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPf4jPluginManager.java @@ -0,0 +1,346 @@ +package com.openosrs.client.plugins; + +import com.openosrs.client.OpenOSRS; +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.BasePluginLoader; +import org.pf4j.CompoundPluginLoader; +import org.pf4j.CompoundPluginRepository; +import org.pf4j.DefaultPluginManager; +import org.pf4j.DependencyResolver; +import org.pf4j.JarPluginLoader; +import org.pf4j.JarPluginRepository; +import org.pf4j.ManifestPluginDescriptorFinder; +import org.pf4j.PluginAlreadyLoadedException; +import org.pf4j.PluginDescriptorFinder; +import org.pf4j.PluginLoader; +import org.pf4j.PluginRepository; +import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginState; +import org.pf4j.PluginStateEvent; +import org.pf4j.PluginWrapper; +import org.pf4j.RuntimeMode; + +@Slf4j +class ExternalPf4jPluginManager extends DefaultPluginManager +{ + private final ExternalPluginManager externalPluginManager; + + public ExternalPf4jPluginManager(ExternalPluginManager externalPluginManager) + { + super(OpenOSRS.EXTERNALPLUGIN_DIR.toPath()); + this.externalPluginManager = externalPluginManager; + } + + @Override + protected PluginDescriptorFinder createPluginDescriptorFinder() + { + return new ManifestPluginDescriptorFinder() + { + protected Path getManifestPath(Path pluginPath) + { + if (isDevelopment()) + { + // The superclass performs a find, which is slow in development mode since we're pointing + // at a sources directory, which can have a lot of files. The external plugin template + // will always output the manifest at the following location, so we can hardcode this path. + return pluginPath.resolve(ExternalPluginManager.DEVELOPMENT_MANIFEST_PATH); + } + + return super.getManifestPath(pluginPath); + } + }; + } + + @Override + protected PluginRepository createPluginRepository() + { + CompoundPluginRepository compoundPluginRepository = new CompoundPluginRepository(); + + JarPluginRepository jarPluginRepository = new JarPluginRepository(getPluginsRoot()); + compoundPluginRepository.add(jarPluginRepository); + + return compoundPluginRepository; + } + + @Override + protected PluginLoader createPluginLoader() + { + return new CompoundPluginLoader() + .add(new BasePluginLoader(this, new ExternalPluginClasspath()), this::isDevelopment) + .add(new JarPluginLoader(this), this::isNotDevelopment); + } + + @Override + public void loadPlugins() + { + for (Path path : pluginsRoots) + { + if (Files.notExists(path) || !Files.isDirectory(path)) + { + log.warn("No '{}' root", path); + + return; + } + } + + List pluginPaths = pluginRepository.getPluginPaths(); + Collections.reverse(pluginPaths); + + if (pluginPaths.isEmpty()) + { + log.warn("No plugins"); + return; + } + + log.debug("Found {} possible plugins: {}", pluginPaths.size(), pluginPaths); + + Set duplicatePlugins = new HashSet<>(); + for (Path pluginPath : pluginPaths) + { + try + { + if (!isPluginEligibleForLoading(pluginPath) && isNotDevelopment()) + { + continue; + } + loadPluginFromPath(pluginPath); + } + catch (PluginRuntimeException e) + { + if (!(e instanceof PluginAlreadyLoadedException)) + { + log.error("Could not load plugin {}", pluginPath, e); + } + } + } + + if (!duplicatePlugins.isEmpty()) + { + log.error("Duplicate plugins detected: {}", String.join(", ", duplicatePlugins)); + + String formatted = String.join("\n", duplicatePlugins); + + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(null, "You have duplicate plugins in your externalmanager.\n" + + "Having duplicate plugins will result in an unstable\n" + + "experience, It is highly recommended to delete any\n" + + "duplicates, here is a list of the plugins.\n\n" + + formatted, "Duplicate Plugins Detected", JOptionPane.WARNING_MESSAGE)); + } + + try + { + resolvePlugins(); + } + catch (PluginRuntimeException e) + { + if (e instanceof DependencyResolver.DependenciesNotFoundException) + { + throw e; + } + + log.error("Could not resolve plugins", e); + } + } + + @Override + protected void resolvePlugins() + { + // retrieves the plugins descriptors + List descriptors = new ArrayList<>(); + for (PluginWrapper plugin : plugins.values()) + { + descriptors.add(plugin.getDescriptor()); + } + + // retrieves the plugins descriptors from the resolvedPlugins list. This allows to load plugins that have already loaded dependencies. + for (PluginWrapper plugin : resolvedPlugins) + { + descriptors.add(plugin.getDescriptor()); + } + + DependencyResolver.Result result = dependencyResolver.resolve(descriptors); + + if (result.hasCyclicDependency()) + { + throw new DependencyResolver.CyclicDependencyException(); + } + + List notFoundDependencies = result.getNotFoundDependencies(); + if (!notFoundDependencies.isEmpty()) + { + throw new DependencyResolver.DependenciesNotFoundException(notFoundDependencies); + } + + List wrongVersionDependencies = result.getWrongVersionDependencies(); + if (!wrongVersionDependencies.isEmpty()) + { + throw new DependencyResolver.DependenciesWrongVersionException(wrongVersionDependencies); + } + + List sortedPlugins = result.getSortedPlugins(); + + // move plugins from "unresolved" to "resolved" + for (String pluginId : sortedPlugins) + { + PluginWrapper pluginWrapper = plugins.get(pluginId); + + //The plugin is already resolved. Don't put a copy in the resolvedPlugins. + if (resolvedPlugins.contains(pluginWrapper)) + { + continue; + } + + if (unresolvedPlugins.remove(pluginWrapper)) + { + PluginState pluginState = pluginWrapper.getPluginState(); + if (pluginState != PluginState.DISABLED) + { + pluginWrapper.setPluginState(PluginState.RESOLVED); + } + + resolvedPlugins.add(pluginWrapper); + + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + } + } + } + + @Override + public RuntimeMode getRuntimeMode() + { + return RuntimeMode.DEPLOYMENT; + } + + @Override + public PluginState stopPlugin(String pluginId) + { + if (!plugins.containsKey(pluginId)) + { + throw new IllegalArgumentException(String.format("Unknown pluginId %s", pluginId)); + } + + PluginWrapper pluginWrapper = getPlugin(pluginId); + org.pf4j.PluginDescriptor pluginDescriptor = pluginWrapper.getDescriptor(); + PluginState pluginState = pluginWrapper.getPluginState(); + if (PluginState.STOPPED == pluginState) + { + log.debug("Already stopped plugin '{}'", getPluginLabel(pluginDescriptor)); + return PluginState.STOPPED; + } + + // test for disabled plugin + if (PluginState.DISABLED == pluginState) + { + // do nothing + return pluginState; + } + + pluginWrapper.getPlugin().stop(); + pluginWrapper.setPluginState(PluginState.STOPPED); + startedPlugins.remove(pluginWrapper); + + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + + return pluginWrapper.getPluginState(); + } + + @Override + public boolean unloadPlugin(String pluginId) + { + try + { + PluginState pluginState = stopPlugin(pluginId); + if (PluginState.STARTED == pluginState) + { + return false; + } + + PluginWrapper pluginWrapper = getPlugin(pluginId); + + // remove the plugin + plugins.remove(pluginId); + getResolvedPlugins().remove(pluginWrapper); + + firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState)); + + // remove the classloader + Map pluginClassLoaders = getPluginClassLoaders(); + if (pluginClassLoaders.containsKey(pluginId)) + { + ClassLoader classLoader = pluginClassLoaders.remove(pluginId); + if (classLoader instanceof Closeable) + { + try + { + ((Closeable) classLoader).close(); + } + catch (IOException e) + { + throw new PluginRuntimeException(e, "Cannot close classloader"); + } + } + } + + return true; + } + catch (IllegalArgumentException e) + { + // ignore not found exceptions because this method is recursive + } + + return false; + } + + @Override + public boolean deletePlugin(String pluginId) + { + if (!plugins.containsKey(pluginId)) + { + throw new IllegalArgumentException(String.format("Unknown pluginId %s", pluginId)); + } + + PluginWrapper pluginWrapper = getPlugin(pluginId); + // stop the plugin if it's started + PluginState pluginState = stopPlugin(pluginId); + if (PluginState.STARTED == pluginState) + { + log.error("Failed to stop plugin '{}' on delete", pluginId); + return false; + } + + // get an instance of plugin before the plugin is unloaded + // for reason see https://github.com/pf4j/pf4j/issues/309 + + org.pf4j.Plugin plugin = pluginWrapper.getPlugin(); + + if (!unloadPlugin(pluginId)) + { + log.error("Failed to unload plugin '{}' on delete", pluginId); + return false; + } + + // notify the plugin as it's deleted + plugin.delete(); + + Path pluginPath = pluginWrapper.getPluginPath(); + + return pluginRepository.deletePluginPath(pluginPath); + } + + private boolean isPluginEligibleForLoading(Path path) + { + return path.toFile().getName().endsWith(".jar"); + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginClasspath.java b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginClasspath.java new file mode 100644 index 0000000000..0e0e72842b --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginClasspath.java @@ -0,0 +1,13 @@ +package com.openosrs.client.plugins; + +import org.pf4j.DevelopmentPluginClasspath; + +class ExternalPluginClasspath extends DevelopmentPluginClasspath +{ + static final String GRADLE_DEPS_PATH = "build/deps"; + + ExternalPluginClasspath() + { + addJarsDirectories(GRADLE_DEPS_PATH); + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginFileFilter.java b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginFileFilter.java new file mode 100644 index 0000000000..39b261d861 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginFileFilter.java @@ -0,0 +1,61 @@ +package com.openosrs.client.plugins; + +import java.io.File; +import java.io.FileFilter; +import java.util.Arrays; +import java.util.List; + +/** + * Determines whether a {@link File} is an external plugin folder. To be considered a plugin a folder must: + *

+ * * Must not be a blacklisted name + * * Have a {@code .gradle.kts} file in the root named after the folder + * * Have a {@code MANIFEST.MF} located at {@code build/tmp/jar/MANIFEST.MF} + */ +public class ExternalPluginFileFilter implements FileFilter +{ + private static final List blacklist = Arrays.asList( + ".git", + "build", + "target", + "release" + ); + + private static final List buildFiles = Arrays.asList( + "%s.gradle.kts", + "%s.gradle" + ); + + @Override + public boolean accept(File pathName) + { + // Check if this path looks like a plugin development directory + if (!pathName.isDirectory()) + { + return false; + } + + String dirName = pathName.getName(); + if (blacklist.contains(dirName)) + { + return false; + } + + // Check if the plugin directory has a MANIFEST.MF which si required for loading + if (!new File(pathName, ExternalPluginManager.DEVELOPMENT_MANIFEST_PATH).exists()) + { + return false; + } + + // By convention plugins their directory is $name and they have a $name.gradle.kts or $name.gradle file in their root + for (String buildFile : buildFiles) + { + if (new File(pathName, String.format(buildFile, dirName)).exists()) + { + return true; + } + } + + return false; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginManager.java b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginManager.java new file mode 100644 index 0000000000..f9ea6350df --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/ExternalPluginManager.java @@ -0,0 +1,1073 @@ +/* + * Copyright (c) 2020, Owain van Brakel + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.openosrs.client.plugins; + +import com.google.common.collect.Lists; +import com.google.common.graph.GraphBuilder; +import com.google.common.graph.Graphs; +import com.google.common.graph.MutableGraph; +import com.google.inject.Binder; +import com.google.inject.CreationException; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.Module; +import java.lang.reflect.InvocationTargetException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.swing.JOptionPane; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.RuneLite; +import static com.openosrs.client.OpenOSRS.EXTERNALPLUGIN_DIR; +import static com.openosrs.client.OpenOSRS.SYSTEM_VERSION; +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigManager; +import com.openosrs.client.config.OpenOSRSConfig; +import net.runelite.client.config.RuneLiteConfig; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.events.ConfigChanged; +import com.openosrs.client.events.ExternalPluginChanged; +import com.openosrs.client.events.ExternalRepositoryChanged; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginInstantiationException; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.ui.ClientUI; +import com.openosrs.client.ui.OpenOSRSSplashScreen; +import com.openosrs.client.util.Groups; +import com.openosrs.client.util.MiscUtils; +import com.openosrs.client.util.SwingUtil; +import org.jgroups.Message; +import org.pf4j.DefaultPluginManager; +import org.pf4j.DependencyResolver; +import org.pf4j.PluginDependency; +import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginWrapper; +import org.pf4j.update.DefaultUpdateRepository; +import org.pf4j.update.PluginInfo; +import org.pf4j.update.UpdateManager; +import org.pf4j.update.UpdateRepository; +import org.pf4j.update.VerifyException; + +@Slf4j +@Singleton +public class ExternalPluginManager +{ + public static final String DEFAULT_PLUGIN_REPOS = "OpenOSRS:https://raw.githubusercontent.com/open-osrs/plugin-hosting/master/"; + static final String DEVELOPMENT_MANIFEST_PATH = "build/tmp/jar/MANIFEST.MF"; + + public static ArrayList pluginClassLoaders = new ArrayList<>(); + private final net.runelite.client.plugins.PluginManager runelitePluginManager; + private org.pf4j.PluginManager externalPluginManager; + @Getter(AccessLevel.PUBLIC) + private final List repositories = new ArrayList<>(); + private final OpenOSRSConfig openOSRSConfig; + private final EventBus eventBus; + private final ExecutorService executorService; + private final ConfigManager configManager; + private final Map pluginsMap = new HashMap<>(); + @Getter(AccessLevel.PUBLIC) + private final Map> pluginsInfoMap = new HashMap<>(); + private final Groups groups; + @Getter(AccessLevel.PUBLIC) + private UpdateManager updateManager; + private final boolean safeMode; + + @Inject + public ExternalPluginManager( + @Named("safeMode") final boolean safeMode, + net.runelite.client.plugins.PluginManager pluginManager, + OpenOSRSConfig openOSRSConfig, + EventBus eventBus, + ExecutorService executorService, + ConfigManager configManager, + Groups groups) + { + this.safeMode = safeMode; + this.runelitePluginManager = pluginManager; + this.openOSRSConfig = openOSRSConfig; + this.eventBus = eventBus; + this.executorService = executorService; + this.configManager = configManager; + this.groups = groups; + + //noinspection ResultOfMethodCallIgnored + EXTERNALPLUGIN_DIR.mkdirs(); + + initPluginManager(); + + groups.getMessageStringSubject() + .subscribe(this::receive); + } + + private void initPluginManager() + { + externalPluginManager = new ExternalPf4jPluginManager(this); + externalPluginManager.setSystemVersion(SYSTEM_VERSION); + } + + public boolean doesGhRepoExist(String owner, String name) + { + return doesRepoExist("gh:" + owner + "/" + name); + } + + /** + * Note that {@link UpdateManager#addRepository} checks if the repo exists, however it throws an exception which is bad + */ + public boolean doesRepoExist(String id) + { + return repositories.stream().anyMatch((repo) -> repo.getId().equals(id)); + } + + private static URL toRepositoryUrl(String owner, String name) throws MalformedURLException + { + return new URL("https://raw.githubusercontent.com/" + owner + "/" + name + "/master/"); + } + + public static boolean testGHRepository(String owner, String name) + { + try + { + return testRepository(toRepositoryUrl(owner, name)); + } + catch (MalformedURLException e) + { + e.printStackTrace(); + } + return false; + } + + public static boolean testRepository(URL url) + { + final List repositories = new ArrayList<>(); + repositories.add(new DefaultUpdateRepository("repository-testing", url)); + DefaultPluginManager testPluginManager = new DefaultPluginManager(EXTERNALPLUGIN_DIR.toPath()); + UpdateManager updateManager = new UpdateManager(testPluginManager, repositories); + + return updateManager.getPlugins().size() <= 0; + } + + public static Predicate not(Predicate t) + { + return t.negate(); + } + + public void startExternalPluginManager() + { + try + { + externalPluginManager.loadPlugins(); + } + catch (Exception ex) + { + if (ex instanceof DependencyResolver.DependenciesNotFoundException) + { + List deps = ((DependencyResolver.DependenciesNotFoundException) ex).getDependencies(); + + log.error("The following dependencies are missing: {}", deps); + + for (String dep : deps) + { + updateManager.installPlugin(dep, null); + } + + startExternalPluginManager(); + } + else + { + log.error("Could not load plugins", ex); + } + } + } + + public void startExternalUpdateManager() + { + if (!tryLoadNewFormat()) + { + log.debug("Load new format failed."); + loadOldFormat(); + } + + updateManager = new UpdateManager(externalPluginManager, repositories); + saveConfig(); + } + + public boolean tryLoadNewFormat() + { + try + { + duplicateCheck(); + log.debug("Trying to load new format: {}", openOSRSConfig.getExternalRepositories()); + repositories.clear(); + + for (String keyval : openOSRSConfig.getExternalRepositories().split(";")) + { + String[] split = keyval.split("\\|"); + if (split.length != 2) + { + log.debug("Split length invalid: {}", keyval); + repositories.clear(); + return false; + } + String id = split[0]; + String url = split[1]; + if (!url.endsWith("/")) + { + url = url.concat("/"); + } + + if (id.contains("https://raw.githubusercontent.com/")) + { + id = "gh:" + id.substring(id.indexOf("https://raw.githubusercontent.com/")).replace("/master", "") + .replace("https://raw.githubusercontent.com/", ""); + + if (id.endsWith("/")) + { + id = id.substring(0, id.lastIndexOf("/")); + } + } + + repositories.add(new DefaultUpdateRepository(id, new URL(url))); + } + } + catch (ArrayIndexOutOfBoundsException | MalformedURLException e) + { + log.error("Error in new format", e); + repositories.clear(); + return false; + } + return true; + } + + public void loadOldFormat() + { + try + { + log.debug("Loading old format."); + repositories.clear(); + + for (String keyval : openOSRSConfig.getExternalRepositories().split(";")) + { + log.debug("KeyVal: {}", keyval); + String id = keyval.substring(0, keyval.lastIndexOf(":https")); + String url = keyval.substring(keyval.lastIndexOf("https")); + + DefaultUpdateRepository defaultRepo = new DefaultUpdateRepository(id, new URL(url)); + repositories.add(defaultRepo); + log.debug("Added Repo: {}", defaultRepo.getUrl()); + } + } + catch (MalformedURLException e) + { + log.error("Old repository format contained malformed url", e); + } + catch (StringIndexOutOfBoundsException e) + { + log.error("Error loading external repositories. They have been reset."); + openOSRSConfig.setExternalRepositories(DEFAULT_PLUGIN_REPOS); + } + + updateManager = new UpdateManager(externalPluginManager, repositories); + } + + public void addGHRepository(String owner, String name) + { + try + { + addRepository("gh:" + owner + "/" + name, toRepositoryUrl(owner, name)); + } + catch (MalformedURLException e) + { + log.error("GitHub repostitory could not be added (owner={}, name={})", owner, name, e); + } + } + + public void addRepository(String key, URL url) + { + DefaultUpdateRepository respository = new DefaultUpdateRepository(key, url); + updateManager.addRepository(respository); + eventBus.post(new ExternalRepositoryChanged(key, true)); + saveConfig(); + } + + public void removeRepository(String owner) + { + updateManager.removeRepository(owner); + eventBus.post(new ExternalRepositoryChanged(owner, false)); + saveConfig(); + } + + private void saveConfig() + { + StringBuilder config = new StringBuilder(); + + for (UpdateRepository repository : updateManager.getRepositories()) + { + config.append(repository.getId()); + config.append("|"); + config.append(MiscUtils.urlToStringEncoded(repository.getUrl())); + config.append(";"); + } + config.deleteCharAt(config.lastIndexOf(";")); + openOSRSConfig.setExternalRepositories(config.toString()); + } + + public void setWarning(boolean val) + { + configManager.setConfiguration("openosrs", "warning", val); + } + + public boolean getWarning() + { + return openOSRSConfig.warning(); + } + + /** + * This method is a fail safe to ensure that no duplicate + * repositories end up getting saved to the config. + *

+ * Configs that had duplicate repos prior to this should + * be updated and set correctly. + */ + private void duplicateCheck() + { + String[] split = openOSRSConfig.getExternalRepositories().split(";"); + + if (split.length <= 0) + { + return; + } + + Set strings = new HashSet<>(); + boolean duplicates = false; + + for (String s : split) + { + if (strings.contains(s)) + { + log.error("Duplicate Repo: {}", s); + duplicates = true; + continue; + } + strings.add(s); + } + + if (!duplicates) + { + log.info("No duplicates found."); + return; + } + + StringBuilder sb = new StringBuilder(); + + for (String string : strings) + { + sb.append(string); + sb.append(";"); + } + + sb.deleteCharAt(sb.lastIndexOf(";")); + String duplicateFix = sb.toString(); + + log.info("Duplicate Repos detected, setting them to: {}", duplicateFix); + openOSRSConfig.setExternalRepositories(duplicateFix); + } + + private void scanAndInstantiate(List plugins, boolean init, boolean initConfig) + { + OpenOSRSSplashScreen.stage(.66, "Loading external plugins"); + MutableGraph> graph = GraphBuilder + .directed() + .build(); + + for (Plugin plugin : plugins) + { + Class clazz = plugin.getClass(); + PluginDescriptor pluginDescriptor = clazz.getAnnotation(PluginDescriptor.class); + + try + { + if (pluginDescriptor == null) + { + if (Plugin.class.isAssignableFrom(clazz)) + { + log.warn("Class {} is a plugin, but has no plugin descriptor", clazz); + } + continue; + } + else if (!Plugin.class.isAssignableFrom(clazz)) + { + log.warn("Class {} has plugin descriptor, but is not a plugin", clazz); + continue; + } + } + catch (EnumConstantNotPresentException e) + { + log.warn("{} has an invalid plugin type of {}", clazz, e.getMessage()); + continue; + } + + if (safeMode && !pluginDescriptor.loadInSafeMode()) + { + log.debug("Disabling {} due to safe mode", clazz); + // also disable the plugin from autostarting later + configManager.unsetConfiguration(RuneLiteConfig.GROUP_NAME, clazz.getSimpleName().toLowerCase()); + continue; + } + + @SuppressWarnings("unchecked") Class pluginClass = (Class) clazz; + graph.addNode(pluginClass); + } + + // Build plugin graph + for (Class pluginClazz : graph.nodes()) + { + net.runelite.client.plugins.PluginDependency[] pluginDependencies = pluginClazz.getAnnotationsByType(net.runelite.client.plugins.PluginDependency.class); + + for (net.runelite.client.plugins.PluginDependency pluginDependency : pluginDependencies) + { + if (graph.nodes().contains(pluginDependency.value())) + { + graph.putEdge(pluginClazz, (Class) pluginDependency.value()); + } + } + } + + if (Graphs.hasCycle(graph)) + { + throw new RuntimeException("Plugin dependency graph contains a cycle!"); + } + + List>> sortedPlugins = PluginManager.topologicalGroupSort(graph); + sortedPlugins = Lists.reverse(sortedPlugins); + AtomicInteger loaded = new AtomicInteger(); + + final long start = System.currentTimeMillis(); + + List scannedPlugins = new CopyOnWriteArrayList<>(); + sortedPlugins.forEach(group -> + { + List> curGroup = new ArrayList<>(); + group.forEach(pluginClazz -> + curGroup.add(executorService.submit(() -> + { + Plugin plugininst; + try + { + //noinspection unchecked + plugininst = instantiate(scannedPlugins, (Class) pluginClazz, init, initConfig); + if (plugininst == null) + { + return; + } + + scannedPlugins.add(plugininst); + } + catch (PluginInstantiationException e) + { + log.warn("Error instantiating plugin!", e); + return; + } + + loaded.getAndIncrement(); + + OpenOSRSSplashScreen.stage(.67, .75, "Loading external plugins", loaded.get(), scannedPlugins.size()); + }))); + curGroup.forEach(future -> + { + try + { + future.get(); + } + catch (InterruptedException | ExecutionException e) + { + log.warn("Could not instantiate external plugin", e); + } + }); + }); + + log.info("External plugin instantiation took {}ms", System.currentTimeMillis() - start); + } + + @SuppressWarnings("unchecked") + private Plugin instantiate(List scannedPlugins, Class clazz, boolean init, boolean initConfig) + throws PluginInstantiationException + { + net.runelite.client.plugins.PluginDependency[] pluginDependencies = + clazz.getAnnotationsByType(net.runelite.client.plugins.PluginDependency.class); + List deps = new ArrayList<>(); + for (net.runelite.client.plugins.PluginDependency pluginDependency : pluginDependencies) + { + Optional dependency = + Stream.concat(runelitePluginManager.getOprsPlugins().stream(), scannedPlugins.stream()) + .filter(p -> p.getClass() == pluginDependency.value()).findFirst(); + if (dependency.isEmpty()) + { + throw new PluginInstantiationException( + "Unmet dependency for " + clazz.getSimpleName() + ": " + pluginDependency.value().getSimpleName()); + } + deps.add(dependency.get()); + } + + log.info("Loading plugin {}", clazz.getSimpleName()); + Plugin plugin; + try + { + plugin = clazz.getDeclaredConstructor().newInstance(); + } + catch (ThreadDeath e) + { + throw e; + } + catch (Throwable ex) + { + throw new PluginInstantiationException(ex); + } + + try + { + Injector parent = RuneLite.getInjector(); + + if (deps.size() > 1) + { + List modules = new ArrayList<>(deps.size()); + for (Plugin p : deps) + { + // Create a module for each dependency + Module module = (Binder binder) -> + { + binder.bind((Class) p.getClass()).toInstance(p); + binder.install(p); + }; + modules.add(module); + } + + // Create a parent injector containing all of the dependencies + parent = parent.createChildInjector(modules); + } + else if (!deps.isEmpty()) + { + // With only one dependency we can simply use its injector + parent = deps.get(0).injector; + } + + // Create injector for the module + Module pluginModule = (Binder binder) -> + { + // Since the plugin itself is a module, it won't bind itself, so we'll bind it here + binder.bind(clazz).toInstance(plugin); + binder.install(plugin); + }; + Injector pluginInjector = parent.createChildInjector(pluginModule); + pluginInjector.injectMembers(plugin); + plugin.injector = pluginInjector; + + if (initConfig) + { + for (Key key : pluginInjector.getBindings().keySet()) + { + Class type = key.getTypeLiteral().getRawType(); + if (Config.class.isAssignableFrom(type)) + { + if (type.getPackageName().startsWith(plugin.getClass().getPackageName())) + { + Config config = (Config) pluginInjector.getInstance(key); + configManager.setDefaultConfiguration(config, false); + } + } + } + } + + if (init) + { + try + { + SwingUtil.syncExec(() -> + { + try + { + runelitePluginManager.add(plugin); + runelitePluginManager.startPlugin(plugin); + eventBus.post(new ExternalPluginChanged(pluginsMap.get(plugin.getClass().getSimpleName()), + plugin, true)); + } + catch (PluginInstantiationException e) + { + throw new RuntimeException(e); + } + }); + } + catch (Exception ex) + { + log.warn("unable to start plugin", ex); + } + } + else + { + runelitePluginManager.add(plugin); + } + } + catch (CreationException ex) + { + throw new PluginInstantiationException(ex); + } + catch (NoClassDefFoundError ex) + { + log.error("Plugin {} is outdated", clazz.getSimpleName()); + return null; + } + + log.debug("Loaded plugin {}", clazz.getSimpleName()); + return plugin; + } + + private void checkDepsAndStart(List startedPlugins, List scannedPlugins, PluginWrapper pluginWrapper) + { + boolean depsLoaded = true; + for (PluginDependency dependency : pluginWrapper.getDescriptor().getDependencies()) + { + if (startedPlugins.stream().noneMatch(pl -> pl.getPluginId().equals(dependency.getPluginId()))) + { + depsLoaded = false; + } + } + + if (!depsLoaded) + { + // This should never happen but can crash the client + return; + } + + scannedPlugins.addAll(loadPlugin(pluginWrapper.getPluginId())); + } + + public void loadPlugins() + { + externalPluginManager.startPlugins(); + List startedPlugins = getStartedPlugins(); + List scannedPlugins = new ArrayList<>(); + + for (PluginWrapper plugin : startedPlugins) + { + checkDepsAndStart(startedPlugins, scannedPlugins, plugin); + } + + scanAndInstantiate(scannedPlugins, false, false); + + if (groups.getInstanceCount() > 1) + { + for (String pluginId : getDisabledPlugins()) + { + groups.sendString("STOPEXTERNAL;" + pluginId); + } + } + else + { + for (String pluginId : getDisabledPlugins()) + { + externalPluginManager.enablePlugin(pluginId); + externalPluginManager.deletePlugin(pluginId); + } + } + } + + private List loadPlugin(String pluginId) + { + List scannedPlugins = new ArrayList<>(); + try + { + List extensions = externalPluginManager.getExtensions(Plugin.class, pluginId); + for (Plugin plugin : extensions) + { + pluginClassLoaders.add(plugin.getClass().getClassLoader()); + + pluginsMap.remove(plugin.getClass().getSimpleName()); + pluginsMap.put(plugin.getClass().getSimpleName(), pluginId); + + pluginsInfoMap.remove(plugin.getClass().getSimpleName()); + + AtomicReference support = new AtomicReference<>(""); + + updateManager.getRepositories().forEach(repository -> + repository.getPlugins().forEach((key, value) -> + { + if (key.equals(pluginId)) + { + support.set(value.projectUrl); + } + })); + + pluginsInfoMap.put( + plugin.getClass().getSimpleName(), + new HashMap<>() + {{ + put("version", externalPluginManager.getPlugin(pluginId).getDescriptor().getVersion()); + put("id", externalPluginManager.getPlugin(pluginId).getDescriptor().getPluginId()); + put("provider", externalPluginManager.getPlugin(pluginId).getDescriptor().getProvider()); + put("support", support.get()); + }} + ); + + scannedPlugins.add(plugin); + } + } + catch (Throwable ex) + { + log.error("Plugin {} could not be loaded.", pluginId, ex); + } + + return scannedPlugins; + } + + private Path stopPlugin(String pluginId) + { + List startedPlugins = List.copyOf(getStartedPlugins()); + + for (PluginWrapper pluginWrapper : startedPlugins) + { + if (!pluginId.equals(pluginWrapper.getDescriptor().getPluginId())) + { + continue; + } + + List extensions = externalPluginManager.getExtensions(Plugin.class, pluginId); + + for (Plugin plugin : runelitePluginManager.getOprsPlugins()) + { + if (!extensions.get(0).getClass().getName().equals(plugin.getClass().getName())) + { + continue; + } + + try + { + SwingUtil.syncExec(() -> + { + try + { + runelitePluginManager.stopPlugin(plugin); + } + catch (Exception e2) + { + throw new RuntimeException(e2); + } + }); + runelitePluginManager.remove(plugin); + pluginClassLoaders.remove(plugin.getClass().getClassLoader()); + + eventBus.post(new ExternalPluginChanged(pluginId, plugin, false)); + + return pluginWrapper.getPluginPath(); + } + catch (Exception ex) + { + log.warn("unable to stop plugin", ex); + return null; + } + } + } + + return null; + } + + public boolean install(String pluginId) throws VerifyException + { + if (getDisabledPlugins().contains(pluginId)) + { + externalPluginManager.enablePlugin(pluginId); + externalPluginManager.startPlugin(pluginId); + + groups.broadcastSring("STARTEXTERNAL;" + pluginId); + scanAndInstantiate(loadPlugin(pluginId), true, false); + + return true; + } + + if (getStartedPlugins().stream().anyMatch(ev -> ev.getPluginId().equals(pluginId))) + { + return true; + } + + try + { + PluginInfo.PluginRelease latest = updateManager.getLastPluginRelease(pluginId); + + // Null version returns the last release version of this plugin for given system version + if (latest == null) + { + try + { + SwingUtil.syncExec(() -> + JOptionPane.showMessageDialog(ClientUI.getFrame(), + pluginId + " is outdated and cannot be installed", + "Installation error", + JOptionPane.ERROR_MESSAGE)); + } + catch (InvocationTargetException | InterruptedException ignored) + { + return false; + } + + return true; + } + + updateManager.installPlugin(pluginId, null); + + scanAndInstantiate(loadPlugin(pluginId), true, true); + + groups.broadcastSring("STARTEXTERNAL;" + pluginId); + } + catch (DependencyResolver.DependenciesNotFoundException ex) + { + uninstall(pluginId); + + for (String dep : ex.getDependencies()) + { + install(dep); + } + + install(pluginId); + } + return false; + } + + public boolean uninstall(String pluginId) + { + return uninstall(pluginId, false); + } + + public boolean uninstall(String pluginId, boolean skip) + { + Path pluginPath = stopPlugin(pluginId); + + if (pluginPath == null) + { + return false; + } + + externalPluginManager.stopPlugin(pluginId); + + if (skip) + { + return true; + } + + if (groups.getInstanceCount() > 1) + { + groups.sendString("STOPEXTERNAL;" + pluginId); + } + else + { + externalPluginManager.deletePlugin(pluginId); + } + + return true; + } + + public void update() + { + if (groups.getInstanceCount() > 1) + { + // Do not update when there is more than one client open -> api might contain changes + log.info("Not updating external plugins since there is more than 1 client open"); + return; + } + + OpenOSRSSplashScreen.stage(.59, "Updating external plugins"); + + boolean error = false; + + if (updateManager.hasUpdates()) + { + List updates = updateManager.getUpdates(); + for (PluginInfo plugin : updates) + { + PluginInfo.PluginRelease lastRelease = updateManager.getLastPluginRelease(plugin.id); + String lastVersion = lastRelease.version; + try + { + + OpenOSRSSplashScreen.stage(.59, "Updating " + plugin.id + " to version " + lastVersion); + boolean updated = updateManager.updatePlugin(plugin.id, lastVersion); + + if (!updated) + { + log.warn("Cannot update plugin '{}'", plugin.id); + error = true; + } + } + catch (PluginRuntimeException ex) + { + // This should never happen but can crash the client + log.warn("Cannot update plugin '{}', the user probably has another client open", plugin.id); + error = true; + break; + } + } + } + + if (error) + { + initPluginManager(); + startExternalUpdateManager(); + startExternalPluginManager(); + } + } + + public Set getDependencies() + { + Set deps = new HashSet<>(); + List startedPlugins = getStartedPlugins(); + + for (PluginWrapper pluginWrapper : startedPlugins) + { + for (PluginDependency pluginDependency : pluginWrapper.getDescriptor().getDependencies()) + { + deps.add(pluginDependency.getPluginId()); + } + } + + return deps; + } + + public List getDisabledPlugins() + { + return externalPluginManager.getResolvedPlugins() + .stream() + .filter(not(externalPluginManager.getStartedPlugins()::contains)) + .map(PluginWrapper::getPluginId) + .collect(Collectors.toList()); + } + + public List getStartedPlugins() + { + return externalPluginManager.getStartedPlugins(); + } + + public Boolean reloadStart(String pluginId) + { + externalPluginManager.loadPlugins(); + externalPluginManager.startPlugin(pluginId); + + List startedPlugins = List.copyOf(getStartedPlugins()); + List scannedPlugins = new ArrayList<>(); + + for (PluginWrapper pluginWrapper : startedPlugins) + { + if (!pluginId.equals(pluginWrapper.getDescriptor().getPluginId())) + { + continue; + } + + checkDepsAndStart(startedPlugins, scannedPlugins, pluginWrapper); + } + + scanAndInstantiate(scannedPlugins, true, false); + + groups.broadcastSring("STARTEXTERNAL;" + pluginId); + + return true; + } + + public void receive(Message message) + { + if (message.getObject() instanceof ConfigChanged) + { + return; + } + + String[] messageObject = ((String) message.getObject()).split(";"); + + if (messageObject.length < 2) + { + return; + } + + String command = messageObject[0]; + String pluginId = messageObject[1]; + + switch (command) + { + case "STARTEXTERNAL": + externalPluginManager.loadPlugins(); + externalPluginManager.startPlugin(pluginId); + + List startedPlugins = List.copyOf(getStartedPlugins()); + List scannedPlugins = new ArrayList<>(); + + for (PluginWrapper pluginWrapper : startedPlugins) + { + if (!pluginId.equals(pluginWrapper.getDescriptor().getPluginId())) + { + continue; + } + + checkDepsAndStart(startedPlugins, scannedPlugins, pluginWrapper); + } + + scanAndInstantiate(scannedPlugins, true, false); + + break; + + case "STOPEXTERNAL": + uninstall(pluginId, true); + externalPluginManager.unloadPlugin(pluginId); + groups.send(message.getSrc(), "STOPPEDEXTERNAL;" + pluginId); + break; + + case "STOPPEDEXTERNAL": + groups.getMessageMap().get(pluginId).remove(message.getSrc()); + + if (groups.getMessageMap().get(pluginId).size() == 0) + { + groups.getMessageMap().remove(pluginId); + externalPluginManager.deletePlugin(pluginId); + } + + break; + } + } + +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/Plugin.java b/runelite-client/src/main/java/com/openosrs/client/plugins/Plugin.java new file mode 100644 index 0000000000..81e661de09 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/Plugin.java @@ -0,0 +1,9 @@ +package com.openosrs.client.plugins; + +import com.google.inject.Injector; +import org.pf4j.ExtensionPoint; + +public class Plugin extends net.runelite.client.plugins.Plugin implements ExtensionPoint +{ + public Injector injector; +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/OpenOSRSPlugin.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/OpenOSRSPlugin.java new file mode 100644 index 0000000000..cd3d914413 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/OpenOSRSPlugin.java @@ -0,0 +1,289 @@ +/* + * + * Copyright (c) 2019, Zeruth + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ +package com.openosrs.client.plugins.openosrs; + +import ch.qos.logback.classic.Logger; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import static net.runelite.api.ScriptID.BANK_PIN_OP; +import net.runelite.api.events.ScriptCallbackEvent; +import net.runelite.api.widgets.WidgetID; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_1; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_10; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_2; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_3; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_4; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_5; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_6; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_7; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_8; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_9; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_EXIT_BUTTON; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_FIRST_ENTERED; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_FORGOT_BUTTON; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_FOURTH_ENTERED; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_INSTRUCTION_TEXT; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_SECOND_ENTERED; +import static net.runelite.api.widgets.WidgetInfo.BANK_PIN_THIRD_ENTERED; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.Keybind; +import com.openosrs.client.config.OpenOSRSConfig; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.input.KeyListener; +import net.runelite.client.input.KeyManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import com.openosrs.client.plugins.openosrs.externals.ExternalPluginManagerPanel; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; +import net.runelite.client.util.HotkeyListener; +import net.runelite.client.util.ImageUtil; +import org.slf4j.LoggerFactory; + +@PluginDescriptor( + loadWhenOutdated = true, // prevent users from disabling + hidden = true, // prevent users from disabling + name = "OpenOSRS" +) +@Singleton +@Slf4j +public class OpenOSRSPlugin extends Plugin +{ + private final openosrsKeyListener keyListener = new openosrsKeyListener(); + + @Inject + private OpenOSRSConfig config; + + @Inject + private KeyManager keyManager; + + @Inject + private Client client; + + @Inject + private ClientThread clientThread; + + @Inject + private ClientToolbar clientToolbar; + + private NavigationButton navButton; + + private final HotkeyListener hotkeyListener = new HotkeyListener(() -> this.keybind) + { + @Override + public void hotkeyPressed() + { + detach = !detach; + client.setOculusOrbState(detach ? 1 : 0); + client.setOculusOrbNormalSpeed(detach ? 36 : 12); + } + }; + private int entered = -1; + private int enterIdx; + private boolean expectInput; + private boolean detach; + private Keybind keybind; + + @Override + protected void startUp() + { + ExternalPluginManagerPanel panel = injector.getInstance(ExternalPluginManagerPanel.class); + + final BufferedImage icon = ImageUtil.getResourceStreamFromClass(getClass(), "externalmanager_icon.png"); + + navButton = NavigationButton.builder() + .tooltip("External Plugin Manager") + .icon(icon) + .priority(1) + .panel(panel) + .build(); + clientToolbar.addNavigation(navButton); + + entered = -1; + enterIdx = 0; + expectInput = false; + this.keybind = config.detachHotkey(); + keyManager.registerKeyListener(hotkeyListener); + } + + @Override + protected void shutDown() + { + clientToolbar.removeNavigation(navButton); + + entered = 0; + enterIdx = 0; + expectInput = false; + keyManager.unregisterKeyListener(keyListener); + keyManager.unregisterKeyListener(hotkeyListener); + } + + @Subscribe + private void onConfigChanged(ConfigChanged event) + { + if (!event.getGroup().equals("openosrs")) + { + return; + } + + this.keybind = config.detachHotkey(); + + if (!config.keyboardPin()) + { + entered = 0; + enterIdx = 0; + expectInput = false; + keyManager.unregisterKeyListener(keyListener); + } + + if (event.getKey().equals("shareLogs") && !config.shareLogs()) + { + final Logger logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + logger.detachAppender("Sentry"); + } + } + + @Subscribe + private void onScriptCallbackEvent(ScriptCallbackEvent e) + { + if (!config.keyboardPin()) + { + return; + } + + if (e.getEventName().equals("bankpin")) + { + int[] intStack = client.getIntStack(); + int intStackSize = client.getIntStackSize(); + + // This'll be anywhere from -1 to 3 + // 0 = first number, 1 second, etc + // Anything other than 0123 means the bankpin interface closes + int enterIdx = intStack[intStackSize - 1]; + + if (enterIdx < 0 || enterIdx > 3) + { + keyManager.unregisterKeyListener(keyListener); + this.enterIdx = 0; + this.entered = 0; + expectInput = false; + return; + } + else if (enterIdx == 0) + { + keyManager.registerKeyListener(keyListener); + } + + this.enterIdx = enterIdx; + expectInput = true; + } + } + + private void handleKey(char c) + { + if (client.getWidget(WidgetID.BANK_PIN_GROUP_ID, BANK_PIN_INSTRUCTION_TEXT.getChildId()) == null + || !client.getWidget(BANK_PIN_INSTRUCTION_TEXT).getText().equals("First click the FIRST digit.") + && !client.getWidget(BANK_PIN_INSTRUCTION_TEXT).getText().equals("Now click the SECOND digit.") + && !client.getWidget(BANK_PIN_INSTRUCTION_TEXT).getText().equals("Time for the THIRD digit.") + && !client.getWidget(BANK_PIN_INSTRUCTION_TEXT).getText().equals("Finally, the FOURTH digit.")) + + { + entered = 0; + enterIdx = 0; + expectInput = false; + keyManager.unregisterKeyListener(keyListener); + return; + } + + if (!expectInput) + { + return; + } + + int num = Character.getNumericValue(c); + + // We gotta copy this cause enteridx changes while the script is executing + int oldEnterIdx = enterIdx; + + // Script 685 will call 653, which in turn will set expectInput to true + expectInput = false; + client.runScript(BANK_PIN_OP, num, enterIdx, entered, BANK_PIN_EXIT_BUTTON.getId(), BANK_PIN_FORGOT_BUTTON.getId(), BANK_PIN_1.getId(), BANK_PIN_2.getId(), BANK_PIN_3.getId(), BANK_PIN_4.getId(), BANK_PIN_5.getId(), BANK_PIN_6.getId(), BANK_PIN_7.getId(), BANK_PIN_8.getId(), BANK_PIN_9.getId(), BANK_PIN_10.getId(), BANK_PIN_FIRST_ENTERED.getId(), BANK_PIN_SECOND_ENTERED.getId(), BANK_PIN_THIRD_ENTERED.getId(), BANK_PIN_FOURTH_ENTERED.getId(), BANK_PIN_INSTRUCTION_TEXT.getId()); + + if (oldEnterIdx == 0) + { + entered = num * 1000; + } + else if (oldEnterIdx == 1) + { + entered += num * 100; + } + else if (oldEnterIdx == 2) + { + entered += num * 10; + } + } + + private class openosrsKeyListener implements KeyListener + { + private int lastKeyCycle; + + @Override + public void keyTyped(KeyEvent keyEvent) + { + if (!Character.isDigit(keyEvent.getKeyChar())) + { + return; + } + + if (client.getGameCycle() - lastKeyCycle <= 5) + { + keyEvent.consume(); + return; + } + + lastKeyCycle = client.getGameCycle(); + + clientThread.invoke(() -> handleKey(keyEvent.getKeyChar())); + keyEvent.consume(); + } + + @Override + public void keyPressed(KeyEvent keyEvent) + { + } + + @Override + public void keyReleased(KeyEvent keyEvent) + { + } + } +} \ No newline at end of file diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalBox.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalBox.java new file mode 100644 index 0000000000..cdaef1c8cd --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalBox.java @@ -0,0 +1,97 @@ +package com.openosrs.client.plugins.openosrs.externals; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.net.URL; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextArea; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.text.DefaultCaret; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import org.pf4j.update.PluginInfo; + +public class ExternalBox extends JPanel +{ + private static final Font normalFont = FontManager.getRunescapeFont(); + private static final Font smallFont = FontManager.getRunescapeSmallFont(); + + PluginInfo pluginInfo; + JLabel install = new JLabel(); + JMultilineLabel description = new JMultilineLabel(); + + ExternalBox(String name, URL url) + { + this(name, url.toString().replace("https://raw.githubusercontent.com/", "").replace("/master/", "")); + } + + ExternalBox(PluginInfo pluginInfo) + { + this(pluginInfo.name, pluginInfo.description); + } + + ExternalBox(String name, String desc) + { + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARKER_GRAY_COLOR); + + JPanel titleWrapper = new JPanel(new BorderLayout()); + titleWrapper.setBackground(ColorScheme.DARKER_GRAY_COLOR); + titleWrapper.setBorder(new CompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, ColorScheme.DARK_GRAY_COLOR), + BorderFactory.createLineBorder(ColorScheme.DARKER_GRAY_COLOR) + )); + + JLabel title = new JLabel(); + title.setText(name); + title.setFont(normalFont); + title.setBorder(null); + title.setBackground(ColorScheme.DARKER_GRAY_COLOR); + title.setPreferredSize(new Dimension(0, 24)); + title.setForeground(Color.WHITE); + title.setBorder(new EmptyBorder(0, 8, 0, 0)); + + JPanel titleActions = new JPanel(new BorderLayout(3, 0)); + titleActions.setBorder(new EmptyBorder(0, 0, 0, 8)); + titleActions.setBackground(ColorScheme.DARKER_GRAY_COLOR); + + titleActions.add(install, BorderLayout.EAST); + + titleWrapper.add(title, BorderLayout.CENTER); + titleWrapper.add(titleActions, BorderLayout.EAST); + + description.setText(desc); + description.setFont(smallFont); + description.setDisabledTextColor(Color.WHITE); + description.setBackground(ColorScheme.DARKER_GRAY_COLOR); + + add(titleWrapper, BorderLayout.NORTH); + add(description, BorderLayout.CENTER); + } + + public static class JMultilineLabel extends JTextArea + { + private static final long serialVersionUID = 1L; + + public JMultilineLabel() + { + super(); + setEditable(false); + setCursor(null); + setOpaque(false); + setFocusable(false); + setWrapStyleWord(true); + setLineWrap(true); + setBorder(new EmptyBorder(0, 8, 0, 8)); + setAlignmentY(JLabel.CENTER_ALIGNMENT); + + DefaultCaret caret = (DefaultCaret) getCaret(); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + } + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalPluginManagerPanel.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalPluginManagerPanel.java new file mode 100644 index 0000000000..da49d567b9 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/ExternalPluginManagerPanel.java @@ -0,0 +1,294 @@ +package com.openosrs.client.plugins.openosrs.externals; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.ScheduledExecutorService; +import javax.inject.Inject; +import javax.swing.ImageIcon; +import javax.swing.JCheckBox; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.JTextField; +import javax.swing.border.EmptyBorder; +import net.runelite.client.eventbus.EventBus; +import com.openosrs.client.plugins.ExternalPluginManager; +import net.runelite.client.ui.ClientUI; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.ImageUtil; + +public class ExternalPluginManagerPanel extends PluginPanel +{ + private static final ImageIcon ADD_ICON_RAW; + private static final ImageIcon ADD_HOVER_ICON_RAW; + private static final ImageIcon ADD_ICON_GH; + private static final ImageIcon ADD_HOVER_ICON_GH; + + static + { + final BufferedImage addIconRaw = + ImageUtil.getResourceStreamFromClass(ExternalPluginManagerPanel.class, "add_raw_icon.png"); + final BufferedImage addIconGh = ImageUtil + .resizeImage(ImageUtil.getResourceStreamFromClass(ExternalPluginManagerPanel.class, "gh_icon.png"), 14, 14); + ADD_ICON_RAW = new ImageIcon(addIconRaw); + ADD_HOVER_ICON_RAW = new ImageIcon(ImageUtil.alphaOffset(addIconRaw, 0.53f)); + ADD_ICON_GH = new ImageIcon(addIconGh); + ADD_HOVER_ICON_GH = new ImageIcon(ImageUtil.alphaOffset(addIconGh, 0.53f)); + } + + private final ExternalPluginManager externalPluginManager; + private final ScheduledExecutorService executor; + private final EventBus eventBus; + + @Inject + private ExternalPluginManagerPanel(ExternalPluginManager externalPluginManager, ScheduledExecutorService executor, EventBus eventBus) + { + super(false); + + this.externalPluginManager = externalPluginManager; + this.executor = executor; + this.eventBus = eventBus; + + buildPanel(); + } + + private void buildPanel() + { + removeAll(); + + setLayout(new BorderLayout(0, 10)); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + add(titleBar(), BorderLayout.NORTH); + add(tabbedPane(), BorderLayout.CENTER); + + revalidate(); + repaint(); + } + + private JPanel titleBar() + { + JPanel titlePanel = new JPanel(new BorderLayout()); + titlePanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + JLabel title = new JLabel(); + JLabel addGHRepo = new JLabel(ADD_ICON_GH); + JLabel addRawRepo = new JLabel(ADD_ICON_RAW); + + JPanel buttonHolder = new JPanel(new BorderLayout()); + buttonHolder.setBorder(new EmptyBorder(0, 0, 0, 0)); + + title.setText("External Plugin Manager"); + title.setForeground(Color.WHITE); + + addGHRepo.setToolTipText("Add new GitHub repository"); + addGHRepo.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent mouseEvent) + { + if (externalPluginManager.getWarning()) + { + JCheckBox checkbox = new JCheckBox("Don't show again."); + int answer = showWarningDialog(checkbox); + + if (answer == 1) + { + return; + } + + if (checkbox.isSelected()) + { + externalPluginManager.setWarning(false); + } + } + + JTextField owner = new JTextField(); + JTextField name = new JTextField(); + Object[] message = { + "Github Repository owner:", owner, + "Github Repository name:", name + }; + + int option = + JOptionPane.showConfirmDialog(ClientUI.getFrame(), message, "Add repository", JOptionPane.OK_CANCEL_OPTION); + if (option != JOptionPane.OK_OPTION || owner.getText().equals("") || name.getText().equals("")) + { + return; + } + + if (externalPluginManager.doesGhRepoExist(owner.getText(), name.getText())) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "This repository already exists.", "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + if (ExternalPluginManager.testGHRepository(owner.getText(), name.getText())) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "This doesn't appear to be a valid repository.", "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + externalPluginManager.addGHRepository(owner.getText(), name.getText()); + } + + @Override + public void mouseEntered(MouseEvent mouseEvent) + { + addGHRepo.setIcon(ADD_HOVER_ICON_GH); + } + + @Override + public void mouseExited(MouseEvent mouseEvent) + { + addGHRepo.setIcon(ADD_ICON_GH); + } + }); + addGHRepo.setBorder(new EmptyBorder(0, 3, 0, 0)); + + addRawRepo.setToolTipText("Add new raw repository"); + addRawRepo.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent mouseEvent) + { + if (externalPluginManager.getWarning()) + { + JCheckBox checkbox = new JCheckBox("Don't show again."); + int answer = showWarningDialog(checkbox); + + if (answer == 1) + { + return; + } + + if (checkbox.isSelected()) + { + externalPluginManager.setWarning(false); + } + } + + JTextField id = new JTextField(); + JTextField url = new JTextField(); + Object[] message = { + "Repository ID:", id, + "Repository URL:", url + }; + + int option = + JOptionPane.showConfirmDialog(ClientUI.getFrame(), message, "Add repository", JOptionPane.OK_CANCEL_OPTION); + if (option != JOptionPane.OK_OPTION || id.getText().equals("") || url.getText().equals("")) + { + return; + } + + if (id.getText().startsWith("gh:") || id.getText().contains("|")) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), + "Repository id cannot begin with \"gh:\"\nor contain the pipe character '|'.", "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + if (externalPluginManager.doesRepoExist(id.getText())) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), + String.format("The repository with id %s already exists.", id.getText()), "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + URL urlActual; + try + { + urlActual = new URL(url.getText()); + } + catch (MalformedURLException e) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "This doesn't appear to be a valid repository.", "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + if (ExternalPluginManager.testRepository(urlActual)) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "This doesn't appear to be a valid repository.", "Error!", + JOptionPane.ERROR_MESSAGE); + return; + } + + externalPluginManager.addRepository(id.getText(), urlActual); + } + + @Override + public void mouseEntered(MouseEvent mouseEvent) + { + addRawRepo.setIcon(ADD_HOVER_ICON_RAW); + } + + @Override + public void mouseExited(MouseEvent mouseEvent) + { + addRawRepo.setIcon(ADD_ICON_RAW); + } + }); + addRawRepo.setBorder(new EmptyBorder(0, 0, 0, 3)); + + titlePanel.add(title, BorderLayout.WEST); + buttonHolder.add(addRawRepo, BorderLayout.WEST); + buttonHolder.add(addGHRepo, BorderLayout.EAST); + titlePanel.add(buttonHolder, BorderLayout.EAST); + + return titlePanel; + } + + private JTabbedPane tabbedPane() + { + JTabbedPane mainTabPane = new JTabbedPane(); + + PluginsPanel pluginPanel = new PluginsPanel(this.externalPluginManager, this.executor, this.eventBus); + JScrollPane repositoryPanel = wrapContainer(new RepositoryPanel(this.externalPluginManager, this.eventBus)); + + mainTabPane.add("Plugins", pluginPanel); + mainTabPane.add("Repositories", repositoryPanel); + + return mainTabPane; + } + + private int showWarningDialog(JCheckBox checkbox) + { + Object[] options = {"Okay, I accept the risk", "Never mind, turn back", checkbox}; + return JOptionPane.showOptionDialog(new JFrame(), + "Adding plugins from unverified sources may put your account, or personal information at risk! \n", + "Account security warning", + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + options, + options[0]); + } + + static JScrollPane wrapContainer(final JPanel container) + { + final JPanel wrapped = new JPanel(new BorderLayout()); + wrapped.add(container, BorderLayout.NORTH); + + final JScrollPane scroller = new JScrollPane(wrapped); + scroller.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + scroller.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0)); + + return scroller; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/PluginsPanel.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/PluginsPanel.java new file mode 100644 index 0000000000..be1a350243 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/PluginsPanel.java @@ -0,0 +1,592 @@ +package com.openosrs.client.plugins.openosrs.externals; + +import com.google.gson.JsonSyntaxException; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import javax.swing.ImageIcon; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; +import javax.swing.SwingConstants; +import javax.swing.SwingWorker; +import javax.swing.border.EmptyBorder; +import lombok.extern.slf4j.Slf4j; +import static net.runelite.api.util.Text.DISTANCE; +import net.runelite.client.eventbus.EventBus; +import com.openosrs.client.events.ExternalPluginChanged; +import com.openosrs.client.events.ExternalRepositoryChanged; +import com.openosrs.client.plugins.ExternalPluginManager; +import static com.openosrs.client.plugins.openosrs.externals.ExternalPluginManagerPanel.wrapContainer; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.ui.ClientUI; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.ui.components.IconTextField; +import net.runelite.client.ui.components.shadowlabel.JShadowedLabel; +import com.openosrs.client.util.DeferredDocumentChangedListener; +import com.openosrs.client.util.ImageUtil; +import com.openosrs.client.util.SwingUtil; +import org.pf4j.update.PluginInfo; +import org.pf4j.update.UpdateManager; +import org.pf4j.update.UpdateRepository; +import org.pf4j.update.VerifyException; + +@Slf4j +public class PluginsPanel extends JPanel +{ + private static final ImageIcon ADD_ICON; + private static final ImageIcon ADD_HOVER_ICON; + private static final ImageIcon DELETE_ICON; + private static final ImageIcon DELETE_HOVER_ICON; + private static final ImageIcon DELETE_ICON_GRAY; + private static final ImageIcon DELETE_HOVER_ICON_GRAY; + + static + { + final BufferedImage addIcon = + ImageUtil.recolorImage( + ImageUtil.getResourceStreamFromClass(PluginsPanel.class, "add_icon.png"), ColorScheme.BRAND_BLUE + ); + ADD_ICON = new ImageIcon(addIcon); + ADD_HOVER_ICON = new ImageIcon(ImageUtil.alphaOffset(addIcon, 0.53f)); + + final BufferedImage deleteImg = + ImageUtil.recolorImage( + ImageUtil.resizeCanvas( + ImageUtil.getResourceStreamFromClass(PluginsPanel.class, "delete_icon.png"), 14, 14 + ), ColorScheme.BRAND_BLUE + ); + DELETE_ICON = new ImageIcon(deleteImg); + DELETE_HOVER_ICON = new ImageIcon(ImageUtil.alphaOffset(deleteImg, 0.53f)); + + DELETE_ICON_GRAY = new ImageIcon(ImageUtil.grayscaleImage(deleteImg)); + DELETE_HOVER_ICON_GRAY = new ImageIcon(ImageUtil.alphaOffset(ImageUtil.grayscaleImage(deleteImg), 0.53f)); + } + + private final ExternalPluginManager externalPluginManager; + private final UpdateManager updateManager; + private final ScheduledExecutorService executor; + private final EventBus eventBus; + + private final IconTextField searchBar = new IconTextField(); + private final JPanel filterwrapper = new JPanel(new BorderLayout(0, 10)); + private final List installedPluginsList = new ArrayList<>(); + private final List availablePluginsList = new ArrayList<>(); + private final JPanel installedPluginsPanel = new JPanel(new GridBagLayout()); + private final JPanel availablePluginsPanel = new JPanel(new GridBagLayout()); + + private JComboBox filterComboBox; + private Set deps; + + PluginsPanel(ExternalPluginManager externalPluginManager, ScheduledExecutorService executor, EventBus eventBus) + { + this.externalPluginManager = externalPluginManager; + this.updateManager = externalPluginManager.getUpdateManager(); + this.executor = executor; + this.eventBus = eventBus; + + setLayout(new BorderLayout(0, 10)); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + buildFilter(); + + JTabbedPane mainTabPane = new JTabbedPane(); + + mainTabPane.add("Installed", wrapContainer(installedPluginsPanel())); + mainTabPane.add("Available", wrapContainer(availablePluginsPanel())); + + add(filterwrapper, BorderLayout.NORTH); + add(mainTabPane, BorderLayout.CENTER); + + eventBus.register(this); + + reloadPlugins(); + } + + @Subscribe + public void onExternalRepositoryChanged(ExternalRepositoryChanged event) + { + buildFilter(); + reloadPlugins(); + repaint(); + } + + private void buildFilter() + { + filterwrapper.removeAll(); + + DeferredDocumentChangedListener listener = new DeferredDocumentChangedListener(); + listener.addChangeListener(e -> + { + installedPlugins(); + availablePlugins(); + }); + + filterwrapper.setBorder(new EmptyBorder(10, 10, 0, 10)); + + List repositories = getRepositories(); + filterComboBox = new JComboBox<>(repositories.toArray(new String[0])); + filterComboBox.setPreferredSize(new Dimension(PluginPanel.PANEL_WIDTH - 20, 30)); + filterComboBox.addActionListener(e -> { + installedPlugins(); + availablePlugins(); + }); + + if (repositories.size() > 2) + { + filterwrapper.add(filterComboBox, BorderLayout.NORTH); + } + + searchBar.setIcon(IconTextField.Icon.SEARCH); + searchBar.setPreferredSize(new Dimension(PluginPanel.PANEL_WIDTH - 20, 30)); + searchBar.setBackground(ColorScheme.DARKER_GRAY_COLOR); + searchBar.setHoverBackgroundColor(ColorScheme.DARK_GRAY_HOVER_COLOR); + searchBar.getDocument().addDocumentListener(listener); + + filterwrapper.add(searchBar, BorderLayout.CENTER); + } + + private List getRepositories() + { + List repositories = new ArrayList<>(); + repositories.add("All"); + for (UpdateRepository updateRepository : this.updateManager.getRepositories()) + { + repositories.add(updateRepository.getUrl().toString().replace("https://raw.githubusercontent.com/", "").replace("/master/", "")); + } + + return repositories; + } + + private JLabel titleLabel(String text) + { + JLabel title = new JShadowedLabel(); + + title.setFont(FontManager.getRunescapeSmallFont()); + title.setForeground(Color.WHITE); + title.setHorizontalAlignment(SwingConstants.CENTER); + title.setText("" + text + ""); + + return title; + } + + private JPanel installedPluginsPanel() + { + JPanel installedPluginsContainer = new JPanel(); + installedPluginsContainer.setLayout(new BorderLayout(0, 5)); + installedPluginsContainer.setBorder(new EmptyBorder(0, 10, 10, 10)); + installedPluginsContainer.add(installedPluginsPanel, BorderLayout.CENTER); + + return installedPluginsContainer; + } + + private JPanel availablePluginsPanel() + { + JPanel availablePluginsContainer = new JPanel(); + availablePluginsContainer.setLayout(new BorderLayout(0, 5)); + availablePluginsContainer.setBorder(new EmptyBorder(0, 10, 10, 10)); + availablePluginsContainer.add(availablePluginsPanel, BorderLayout.CENTER); + + return availablePluginsContainer; + } + + static boolean mismatchesSearchTerms(String search, PluginInfo pluginInfo) + { + final String[] searchTerms = search.toLowerCase().split(" "); + final String[] pluginTerms = (pluginInfo.name + " " + pluginInfo.description).toLowerCase().split("[/\\s]"); + for (String term : searchTerms) + { + if (Arrays.stream(pluginTerms).noneMatch((t) -> t.contains(term) || + DISTANCE.apply(t, term) > 0.9)) + { + return true; + } + } + return false; + } + + private void reloadPlugins() + { + fetchPlugins(); + + try + { + SwingUtil.syncExec(() -> { + this.installedPlugins(); + this.availablePlugins(); + }); + + } + catch (InvocationTargetException | InterruptedException e) + { + e.printStackTrace(); + } + } + + private void fetchPlugins() + { + List availablePlugins = null; + List plugins = null; + List disabledPlugins = externalPluginManager.getDisabledPlugins(); + + try + { + availablePlugins = updateManager.getAvailablePlugins(); + plugins = updateManager.getPlugins(); + } + catch (JsonSyntaxException ex) + { + log.error(String.valueOf(ex)); + } + + if (availablePlugins == null || plugins == null) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "The external plugin list could not be loaded.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + availablePluginsList.clear(); + installedPluginsList.clear(); + + deps = externalPluginManager.getDependencies(); + + for (PluginInfo pluginInfo : plugins) + { + if (availablePlugins.contains(pluginInfo) || disabledPlugins.contains(pluginInfo.id)) + { + availablePluginsList.add(pluginInfo); + } + else + { + installedPluginsList.add(pluginInfo); + } + } + } + + @Subscribe + private void onExternalPluginChanged(ExternalPluginChanged externalPluginChanged) + { + String pluginId = externalPluginChanged.getPluginId(); + Optional externalBox; + + if (externalPluginChanged.isAdded()) + { + externalBox = Arrays.stream( + availablePluginsPanel.getComponents() + ).filter(extBox -> + extBox instanceof ExternalBox && ((ExternalBox) extBox).pluginInfo.id.equals(pluginId) + ).findFirst(); + } + else + { + externalBox = Arrays.stream( + installedPluginsPanel.getComponents() + ).filter(extBox -> + extBox instanceof ExternalBox && ((ExternalBox) extBox).pluginInfo.id.equals(pluginId) + ).findFirst(); + } + + if (externalBox.isEmpty()) + { + log.info("EXTERNALBOX IS EMPTY: {}", pluginId); + return; + } + + ExternalBox extBox = (ExternalBox) externalBox.get(); + deps = externalPluginManager.getDependencies(); + + try + { + SwingUtil.syncExec(() -> + { + if (externalPluginChanged.isAdded()) + { + availablePluginsPanel.remove(externalBox.get()); + availablePluginsList.remove(extBox.pluginInfo); + + installedPluginsList.add(extBox.pluginInfo); + installedPluginsList.sort(Comparator.naturalOrder()); + + installedPlugins(); + + pluginInstallButton(extBox.install, extBox.pluginInfo, true, deps.contains(extBox.pluginInfo.id)); + } + else + { + installedPluginsPanel.remove(externalBox.get()); + installedPluginsList.remove(extBox.pluginInfo); + + availablePluginsList.add(extBox.pluginInfo); + availablePluginsList.sort(Comparator.naturalOrder()); + + availablePlugins(); + + pluginInstallButton(extBox.install, extBox.pluginInfo, false, false); + } + }); + } + catch (InvocationTargetException | InterruptedException e) + { + e.printStackTrace(); + } + } + + private void installedPlugins() + { + GridBagConstraints c = new GridBagConstraints(); + + installedPluginsPanel.removeAll(); + String search = searchBar.getText(); + + for (PluginInfo pluginInfo : installedPluginsList) + { + if (!search.equals("") && mismatchesSearchTerms(search, pluginInfo)) + { + continue; + } + + if (filterComboBox.getSelectedIndex() != 0) + { + boolean filtered = true; + String filter = String.valueOf(filterComboBox.getSelectedItem()); + for (UpdateRepository updateRepository : updateManager.getRepositories()) + { + if (filter.equals(updateRepository.getUrl().toString().replace("https://raw.githubusercontent.com/", "").replace("/master/", "")) && + pluginInfo.getRepositoryId().equals(updateRepository.getId())) + { + filtered = false; + } + } + + if (filtered) + { + continue; + } + } + + ExternalBox pluginBox = new ExternalBox(pluginInfo); + pluginBox.pluginInfo = pluginInfo; + + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1.0; + c.gridy += 1; + c.insets = new Insets(5, 0, 0, 0); + + pluginInstallButton(pluginBox.install, pluginInfo, true, deps.contains(pluginInfo.id)); + installedPluginsPanel.add(pluginBox, c); + } + + if (installedPluginsPanel.getComponents().length < 1) + { + installedPluginsPanel.add(titleLabel("No plugins found")); + } + } + + private void availablePlugins() + { + GridBagConstraints c = new GridBagConstraints(); + + availablePluginsPanel.removeAll(); + String search = searchBar.getText(); + + for (PluginInfo pluginInfo : availablePluginsList) + { + if (!search.equals("") && mismatchesSearchTerms(search, pluginInfo)) + { + continue; + } + + if (filterComboBox.getSelectedIndex() != 0) + { + boolean filtered = true; + String filter = String.valueOf(filterComboBox.getSelectedItem()); + for (UpdateRepository updateRepository : updateManager.getRepositories()) + { + if (filter.equals(updateRepository.getUrl().toString().replace("https://raw.githubusercontent.com/", "").replace("/master/", "")) && + pluginInfo.getRepositoryId().equals(updateRepository.getId())) + { + filtered = false; + } + } + + if (filtered) + { + continue; + } + } + + ExternalBox pluginBox = new ExternalBox(pluginInfo); + pluginBox.pluginInfo = pluginInfo; + + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1.0; + c.gridy += 1; + c.insets = new Insets(5, 0, 0, 0); + + pluginInstallButton(pluginBox.install, pluginInfo, false, false); + availablePluginsPanel.add(pluginBox, c); + } + + if (availablePluginsPanel.getComponents().length < 1) + { + availablePluginsPanel.add(titleLabel("No plugins found")); + } + } + + + private void pluginInstallButton(JLabel install, PluginInfo pluginInfo, boolean installed, boolean hideAction) + { + install.setIcon(installed ? hideAction ? DELETE_ICON_GRAY : DELETE_ICON : ADD_ICON); + install.setText(""); + + if (!hideAction) + { + install.setToolTipText(installed ? "Uninstall" : "Install"); + } + install.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + if (installed) + { + if (hideAction) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), "This plugin can't be uninstalled because one or more other plugins have a dependency on it.", "Error!", JOptionPane.ERROR_MESSAGE); + } + else + { + install.setIcon(null); + install.setText("Uninstalling"); + + SwingWorker worker = new SwingWorker<>() + { + @Override + protected Boolean doInBackground() + { + return externalPluginManager.uninstall(pluginInfo.id); + } + + @Override + protected void done() + { + + boolean status = false; + try + { + status = get(); + } + catch (InterruptedException | ExecutionException e) + { + } + + if (!status) + { + pluginInstallButton(install, pluginInfo, installed, hideAction); + } + } + }; + worker.execute(); + } + } + else + { + install.setIcon(null); + install.setText("Installing"); + + SwingWorker worker = new SwingWorker<>() + { + @Override + protected Boolean doInBackground() + { + return installPlugin(pluginInfo); + } + + @Override + protected void done() + { + + boolean status = false; + try + { + status = get(); + } + catch (InterruptedException | ExecutionException e) + { + } + + if (!status) + { + pluginInstallButton(install, pluginInfo, installed, hideAction); + } + } + }; + worker.execute(); + } + } + + @Override + public void mouseEntered(MouseEvent e) + { + if (install.getText().toLowerCase().contains("installing")) + { + return; + } + + install.setIcon(installed ? hideAction ? DELETE_HOVER_ICON_GRAY : DELETE_HOVER_ICON : ADD_HOVER_ICON); + } + + @Override + public void mouseExited(MouseEvent e) + { + if (install.getText().toLowerCase().contains("installing")) + { + return; + } + + install.setIcon(installed ? hideAction ? DELETE_ICON_GRAY : DELETE_ICON : ADD_ICON); + } + }); + } + + private boolean installPlugin(PluginInfo pluginInfo) + { + try + { + return externalPluginManager.install(pluginInfo.id); + } + catch (VerifyException ex) + { + try + { + SwingUtil.syncExec(() -> + JOptionPane.showMessageDialog(ClientUI.getFrame(), pluginInfo.name + " could not be installed, the hash could not be verified.", "Error!", JOptionPane.ERROR_MESSAGE)); + } + catch (InvocationTargetException | InterruptedException ignored) + { + } + } + + return false; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryBox.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryBox.java new file mode 100644 index 0000000000..13727ec01d --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryBox.java @@ -0,0 +1,179 @@ +package com.openosrs.client.plugins.openosrs.externals; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.util.Optional; +import javax.swing.BorderFactory; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import com.openosrs.client.plugins.ExternalPluginManager; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import com.openosrs.client.ui.JMultilineLabel; +import com.openosrs.client.util.ImageUtil; +import net.runelite.client.util.LinkBrowser; +import org.pf4j.update.PluginInfo; +import org.pf4j.update.UpdateRepository; + +public class RepositoryBox extends JPanel +{ + private static final Font normalFont = FontManager.getRunescapeFont(); + private static final Font smallFont = FontManager.getRunescapeSmallFont(); + private static final ImageIcon DELETE_ICON; + private static final ImageIcon DELETE_HOVER_ICON; + private static final ImageIcon DISCORD_ICON; + private static final ImageIcon DISCORD_HOVER_ICON; + + static + { + final BufferedImage deleteImg = + ImageUtil.recolorImage( + ImageUtil.resizeCanvas( + ImageUtil.getResourceStreamFromClass(ExternalPluginManagerPanel.class, "delete_icon.png"), 14, 14 + ), ColorScheme.BRAND_BLUE + ); + DELETE_ICON = new ImageIcon(deleteImg); + DELETE_HOVER_ICON = new ImageIcon(ImageUtil.alphaOffset(deleteImg, 0.53f)); + + final BufferedImage discordImg = + ImageUtil.recolorImage( + ImageUtil.resizeCanvas( + ImageUtil.getResourceStreamFromClass(ExternalPluginManagerPanel.class, "discord_icon.png"), 14, 14 + ), Color.WHITE + ); + DISCORD_ICON = new ImageIcon(discordImg); + DISCORD_HOVER_ICON = new ImageIcon(ImageUtil.alphaOffset(discordImg, 0.53f)); + } + + RepositoryBox(ExternalPluginManager externalPluginManager, UpdateRepository updateRepository) + { + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARKER_GRAY_COLOR); + + String name = updateRepository.getId(); + String urlString = updateRepository.getUrl().toString(); + if (urlString.startsWith("/")) + { + urlString = urlString.substring(1); + } + + JPanel titleWrapper = new JPanel(new BorderLayout()); + titleWrapper.setBackground(ColorScheme.DARKER_GRAY_COLOR); + titleWrapper.setBorder(new CompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, ColorScheme.DARK_GRAY_COLOR), + BorderFactory.createLineBorder(ColorScheme.DARKER_GRAY_COLOR) + )); + + JLabel title = new JLabel(); + title.setText(name); + title.setFont(normalFont); + title.setBorder(null); + title.setBackground(ColorScheme.DARKER_GRAY_COLOR); + title.setPreferredSize(new Dimension(0, 24)); + title.setForeground(Color.WHITE); + title.setBorder(new EmptyBorder(0, 8, 0, 0)); + + JPanel titleActions = new JPanel(new BorderLayout(3, 0)); + titleActions.setBorder(new EmptyBorder(0, 0, 0, 8)); + titleActions.setBackground(ColorScheme.DARKER_GRAY_COLOR); + + Optional firstPlugin = updateRepository.getPlugins().values().stream().findFirst(); + + if (firstPlugin.isPresent() && !firstPlugin.get().projectUrl.equals("")) + { + JLabel support = new JLabel(); + support.setIcon(DISCORD_ICON); + support.setToolTipText("Support"); + support.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + LinkBrowser.browse(firstPlugin.get().projectUrl); + } + + @Override + public void mouseEntered(MouseEvent e) + { + support.setIcon(DISCORD_HOVER_ICON); + } + + @Override + public void mouseExited(MouseEvent e) + { + support.setIcon(DISCORD_ICON); + } + }); + + titleActions.add(support, BorderLayout.WEST); + } + + if (!name.equals("OpenOSRS") && !name.equals("Plugin-Hub")) + { + JLabel install = new JLabel(); + install.setIcon(DELETE_ICON); + install.setToolTipText("Remove"); + install.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + externalPluginManager.removeRepository(updateRepository.getId()); + } + + @Override + public void mouseEntered(MouseEvent e) + { + install.setIcon(DELETE_HOVER_ICON); + } + + @Override + public void mouseExited(MouseEvent e) + { + install.setIcon(DELETE_ICON); + } + }); + + titleActions.add(install, BorderLayout.EAST); + } + + titleWrapper.add(title, BorderLayout.CENTER); + titleWrapper.add(titleActions, BorderLayout.EAST); + + JMultilineLabel repository = new JMultilineLabel(); + repository.setText(formatURL(urlString)); + repository.setFont(smallFont); + repository.setDisabledTextColor(Color.WHITE); + + String finalUrlString = urlString; + repository.addMouseListener(new MouseAdapter() + { + @Override + public void mouseClicked(MouseEvent e) + { + LinkBrowser.browse(formatURL(finalUrlString)); + } + }); + + add(titleWrapper, BorderLayout.NORTH); + add(repository, BorderLayout.CENTER); + } + + private String formatURL(String url) + { + if (url.contains("githubusercontent")) + { + url = url.replace("raw.githubusercontent", "github").replace("/master/", ""); + } + + return url; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryPanel.java b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryPanel.java new file mode 100644 index 0000000000..4009194b83 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/plugins/openosrs/externals/RepositoryPanel.java @@ -0,0 +1,55 @@ +package com.openosrs.client.plugins.openosrs.externals; + +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import javax.inject.Inject; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; +import net.runelite.client.eventbus.EventBus; +import com.openosrs.client.events.ExternalRepositoryChanged; +import com.openosrs.client.plugins.ExternalPluginManager; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.ui.ColorScheme; +import org.pf4j.update.UpdateRepository; + +public class RepositoryPanel extends JPanel +{ + @Inject + public EventBus eventBus; + + private final ExternalPluginManager externalPluginManager; + + private final GridBagConstraints c = new GridBagConstraints(); + + RepositoryPanel(ExternalPluginManager externalPluginManager, EventBus eventBus) + { + this.externalPluginManager = externalPluginManager; + + setLayout(new GridBagLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + setBorder(new EmptyBorder(0, 10, 0, 10)); + + onExternalRepositoryChanged(null); + + eventBus.register(this); + } + + @Subscribe + private void onExternalRepositoryChanged(ExternalRepositoryChanged event) + { + removeAll(); + + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + c.gridy = 0; + c.insets = new Insets(5, 0, 0, 0); + + for (UpdateRepository repository : externalPluginManager.getRepositories()) + { + final RepositoryBox p = new RepositoryBox(externalPluginManager, repository); + add(p, c); + c.gridy++; + } + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/ui/JMultilineLabel.java b/runelite-client/src/main/java/com/openosrs/client/ui/JMultilineLabel.java new file mode 100644 index 0000000000..92c65358b9 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/ui/JMultilineLabel.java @@ -0,0 +1,27 @@ +package com.openosrs.client.ui; + +import javax.swing.JLabel; +import javax.swing.JTextArea; +import javax.swing.border.EmptyBorder; +import javax.swing.text.DefaultCaret; + +public class JMultilineLabel extends JTextArea +{ + private static final long serialVersionUID = 1L; + + public JMultilineLabel() + { + super(); + setEditable(false); + setCursor(null); + setOpaque(false); + setFocusable(false); + setWrapStyleWord(true); + setLineWrap(true); + setBorder(new EmptyBorder(0, 8, 0, 8)); + setAlignmentY(JLabel.CENTER_ALIGNMENT); + + DefaultCaret caret = (DefaultCaret) getCaret(); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/DeferredDocumentChangedListener.java b/runelite-client/src/main/java/com/openosrs/client/util/DeferredDocumentChangedListener.java new file mode 100644 index 0000000000..b9728e03be --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/DeferredDocumentChangedListener.java @@ -0,0 +1,58 @@ +package com.openosrs.client.util; + +import java.util.ArrayList; +import java.util.List; +import javax.swing.Timer; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +public class DeferredDocumentChangedListener implements DocumentListener +{ + private final Timer timer; + private final List listeners; + + public DeferredDocumentChangedListener() + { + listeners = new ArrayList<>(25); + timer = new Timer(200, e -> fireStateChanged()); + timer.setRepeats(false); + } + + public void addChangeListener(ChangeListener listener) + { + listeners.add(listener); + } + + private void fireStateChanged() + { + if (!listeners.isEmpty()) + { + ChangeEvent evt = new ChangeEvent(this); + for (ChangeListener listener : listeners) + { + listener.stateChanged(evt); + } + } + } + + @Override + public void insertUpdate(DocumentEvent e) + { + timer.restart(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + timer.restart(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + timer.restart(); + } + +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/Groups.java b/runelite-client/src/main/java/com/openosrs/client/util/Groups.java new file mode 100644 index 0000000000..6cd128979f --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/Groups.java @@ -0,0 +1,178 @@ +package com.openosrs.client.util; + +import com.openosrs.client.OpenOSRS; +import io.reactivex.rxjava3.subjects.PublishSubject; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.RuneLite; +import com.openosrs.client.config.OpenOSRSConfig; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ClientShutdown; +import net.runelite.client.events.ConfigChanged; +import com.openosrs.client.ui.OpenOSRSSplashScreen; +import org.jgroups.Address; +import org.jgroups.JChannel; +import org.jgroups.Message; +import org.jgroups.ObjectMessage; +import org.jgroups.Receiver; +import org.jgroups.View; +import org.jgroups.util.Util; + +@Slf4j +@Singleton +public class Groups implements Receiver +{ + private final OpenOSRSConfig openOSRSConfig; + private final JChannel channel; + + @Getter(AccessLevel.PUBLIC) + private int instanceCount; + @Getter(AccessLevel.PUBLIC) + private List

members; + @Getter(AccessLevel.PUBLIC) + private final Map> messageMap = new HashMap<>(); + @Getter(AccessLevel.PUBLIC) + private final PublishSubject messageStringSubject = PublishSubject.create(); + @Getter(AccessLevel.PUBLIC) + private final PublishSubject messageObjectSubject = PublishSubject.create(); + + @Inject + public Groups(OpenOSRSConfig openOSRSConfig, EventBus eventBus) throws Exception + { + this.openOSRSConfig = openOSRSConfig; + + try (final InputStream is = RuneLite.class.getResourceAsStream("/udp-openosrs.xml")) + { + this.channel = new JChannel(is) + .setName(OpenOSRS.uuid) + .setReceiver(this) + .setDiscardOwnMessages(true) + .connect("openosrs"); + } + + eventBus.register(this); + } + + @Subscribe + public void onClientShutdown(ClientShutdown event) + { + Future f = close(); + event.waitFor(f); + } + + public void broadcastSring(String command) + { + send(null, command); + } + + public void sendConfig(Address destination, ConfigChanged configChanged) + { + if (!openOSRSConfig.localSync() || OpenOSRSSplashScreen.showing() || instanceCount < 2) + { + return; + } + + try + { + byte[] buffer = Util.objectToByteBuffer(configChanged); + Message message = new ObjectMessage() + .setDest(destination) + .setObject(buffer); + + channel.send(message); + } + catch (Exception e) + { + e.printStackTrace(); + } + } + + public void sendString(String command) + { + String[] messageObject = command.split(";"); + String pluginId = messageObject[1]; + + messageMap.put(pluginId, new ArrayList<>()); + + for (Address member : channel.getView().getMembers()) + { + if (member.toString().equals(OpenOSRS.uuid)) + { + continue; + } + + messageMap.get(pluginId).add(member); + send(member, command); + } + } + + public void send(Address destination, String command) + { + if (!openOSRSConfig.localSync() || OpenOSRSSplashScreen.showing() || instanceCount < 2) + { + return; + } + + try + { + channel.send(new ObjectMessage(destination, command)); + } + catch (Exception e) + { + e.printStackTrace(); + } + } + + @Override + public void viewAccepted(View view) + { + members = view.getMembers(); + instanceCount = members.size(); + } + + @Override + public void receive(Message message) + { + if (OpenOSRSSplashScreen.showing()) + { + return; + } + + if (message.getObject() instanceof String) + { + messageStringSubject.onNext(message); + } + else + { + messageObjectSubject.onNext(message); + } + + } + + private CompletableFuture close() + { + CompletableFuture future = new CompletableFuture<>(); + try + { + channel.close(); + future.complete(null); + } + catch (Exception ex) + { + future.completeExceptionally(ex); + } + + return future; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/ImageUtil.java b/runelite-client/src/main/java/com/openosrs/client/util/ImageUtil.java new file mode 100644 index 0000000000..3282e61c98 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/ImageUtil.java @@ -0,0 +1,59 @@ +package com.openosrs.client.util; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.awt.image.WritableRaster; +import java.util.function.Predicate; + +public class ImageUtil extends net.runelite.client.util.ImageUtil +{ + /** + * Recolors pixels of the given image with the given color based on a given recolor condition + * predicate. + * + * @param image The image which should have its non-transparent pixels recolored. + * @param color The color with which to recolor pixels. + * @param recolorCondition The condition on which to recolor pixels with the given color. + * @return The given image with all pixels fulfilling the recolor condition predicate + * set to the given color. + */ + public static BufferedImage recolorImage(final BufferedImage image, final Color color, final Predicate recolorCondition) + { + final BufferedImage recoloredImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB); + for (int x = 0; x < recoloredImage.getWidth(); x++) + { + for (int y = 0; y < recoloredImage.getHeight(); y++) + { + final Color pixelColor = new Color(image.getRGB(x, y), true); + if (!recolorCondition.test(pixelColor)) + { + recoloredImage.setRGB(x, y, image.getRGB(x, y)); + continue; + } + + recoloredImage.setRGB(x, y, color.getRGB()); + } + } + return recoloredImage; + } + + public static BufferedImage recolorImage(BufferedImage image, final Color color) + { + int width = image.getWidth(); + int height = image.getHeight(); + WritableRaster raster = image.getRaster(); + + for (int xx = 0; xx < width; xx++) + { + for (int yy = 0; yy < height; yy++) + { + int[] pixels = raster.getPixel(xx, yy, (int[]) null); + pixels[0] = color.getRed(); + pixels[1] = color.getGreen(); + pixels[2] = color.getBlue(); + raster.setPixel(xx, yy, pixels); + } + } + return image; + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/MiscUtils.java b/runelite-client/src/main/java/com/openosrs/client/util/MiscUtils.java new file mode 100644 index 0000000000..d5bc562121 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/MiscUtils.java @@ -0,0 +1,202 @@ +package com.openosrs.client.util; + +import java.awt.Polygon; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.WorldType; +import net.runelite.api.coords.WorldPoint; + +public class MiscUtils +{ + private static final int[] abovePointsX = {2944, 3392, 3392, 2944}; + private static final int[] abovePointsY = {3523, 3523, 3971, 3971}; + private static final int[] belowPointsX = {2944, 2944, 3264, 3264}; + private static final int[] belowPointsY = {9918, 10360, 10360, 9918}; + + private static final Polygon abovePoly = new Polygon(abovePointsX, abovePointsY, abovePointsX.length); + private static final Polygon belowPoly = new Polygon(belowPointsX, belowPointsY, belowPointsX.length); + + private static final ChronoUnit[] ORDERED_CHRONOS = new ChronoUnit[] + { + ChronoUnit.YEARS, + ChronoUnit.MONTHS, + ChronoUnit.WEEKS, + ChronoUnit.DAYS, + ChronoUnit.HOURS, + ChronoUnit.MINUTES, + ChronoUnit.SECONDS + }; + + //test replacement so private for now + private static boolean inWildy(WorldPoint point) + { + if (point == null) + { + return false; + } + + return abovePoly.contains(point.getX(), point.getY()) || belowPoly.contains(point.getX(), point.getY()); + } + + public static int getWildernessLevelFrom(Client client, WorldPoint point) + { + if (client == null) + { + return 0; + } + + if (point == null) + { + return 0; + } + + int x = point.getX(); + + if (point.getPlane() == 0 && (x < 2940 || x > 3391)) + { + return 0; + } + + int y = point.getY(); + //v underground //v above ground + int wildernessLevel = clamp(y > 6400 ? ((y - 9920) / 8) + 1 : ((y - 3520) / 8) + 1, 0, 56); + + if (point.getPlane() > 0 && y < 9920) + { + wildernessLevel = 0; + } + + if (client.getWorldType().stream().anyMatch(worldType -> worldType == WorldType.PVP || worldType == WorldType.HIGH_RISK)) + { + wildernessLevel += 15; + } + + return Math.max(0, wildernessLevel); + } + + public static int clamp(int val, int min, int max) + { + return Math.max(min, Math.min(max, val)); + } + + public static float clamp(float val, float min, float max) + { + return Math.max(min, Math.min(max, val)); + } + + public static boolean inWilderness(Client client) + { + Player localPlayer = client.getLocalPlayer(); + + if (localPlayer == null) + { + return false; + } + + return inWildy(localPlayer.getWorldLocation()); + + //return getWildernessLevelFrom(client, localPlayer.getWorldLocation()) > 0; + } + + public static String formatTimeAgo(Duration dur) + { + long dA = 0, dB = 0, rm; + ChronoUnit cA = null, cB = null; + for (int i = 0; i < ORDERED_CHRONOS.length; i++) + { + cA = ORDERED_CHRONOS[i]; + dA = dur.getSeconds() / cA.getDuration().getSeconds(); + rm = dur.getSeconds() % cA.getDuration().getSeconds(); + if (dA <= 0) + { + cA = null; + continue; + } + + if (i + 1 < ORDERED_CHRONOS.length) + { + cB = ORDERED_CHRONOS[i + 1]; + dB = rm / cB.getDuration().getSeconds(); + + if (dB <= 0) + { + cB = null; + } + } + + break; + } + + if (cA == null) + { + return "just now."; + } + + String str = formatUnit(cA, dA); + + if (cB != null) + { + str += " and " + formatUnit(cB, dB); + } + + return str + " ago."; + } + + private static String formatUnit(ChronoUnit chrono, long val) + { + boolean multiple = val != 1; + String str; + if (multiple) + { + str = val + " "; + } + else + { + str = "a" + (chrono == ChronoUnit.HOURS ? "n " : " "); + } + str += chrono.name().toLowerCase(); + if (!multiple) + { + if (str.charAt(str.length() - 1) == 's') + { + str = str.substring(0, str.length() - 1); + } + } + else if (str.charAt(str.length() - 1) != 's') + { + str += "s"; + } + return str; + } + + /** + * Mostly stolen from {@link java.net.URLStreamHandler#toExternalForm(URL)} + * + * @param url URL to encode + * @return URL, with path, query and ref encoded + */ + public static String urlToStringEncoded(URL url) + { + String s; + String path = url.getPath() != null ? Stream.of(url.getPath().split("/")) + .map(s2 -> URLEncoder.encode(s2, StandardCharsets.UTF_8)).collect(Collectors.joining("/")) : ""; + return url.getProtocol() + + ':' + + (((s = url.getAuthority()) != null && s.length() > 0) ? "//" + s : "") + + (path) + + (((s = url.getQuery()) != null) ? '?' + urlEncode(s) : "") + + (((s = url.getRef()) != null) ? '#' + urlEncode(s) : ""); + } + + private static String urlEncode(String s) + { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/NonScheduledExecutorServiceExceptionLogger.java b/runelite-client/src/main/java/com/openosrs/client/util/NonScheduledExecutorServiceExceptionLogger.java new file mode 100644 index 0000000000..08436116a5 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/NonScheduledExecutorServiceExceptionLogger.java @@ -0,0 +1,110 @@ +package com.openosrs.client.util; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import net.runelite.client.util.CallableExceptionLogger; +import static net.runelite.client.util.RunnableExceptionLogger.wrap; +import org.jetbrains.annotations.NotNull; + +// Awkward name because plugins already referenced the ExecutorServiceExceptionLogger +// (which only handles ScheduledExecutorServices) before this class was introduced +public class NonScheduledExecutorServiceExceptionLogger implements ExecutorService +{ + private final ExecutorService service; + + public NonScheduledExecutorServiceExceptionLogger(ExecutorService service) + { + this.service = service; + } + + @Override + public void shutdown() + { + service.shutdown(); + } + + @NotNull + @Override + public List shutdownNow() + { + return service.shutdownNow(); + } + + @Override + public boolean isShutdown() + { + return service.isShutdown(); + } + + @Override + public boolean isTerminated() + { + return service.isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, @NotNull TimeUnit unit) throws InterruptedException + { + return service.awaitTermination(timeout, unit); + } + + @Override + public void execute(@NotNull Runnable command) + { + service.execute(wrap(command)); + } + + @NotNull + @Override + public Future submit(@NotNull Callable task) + { + return service.submit(CallableExceptionLogger.wrap(task)); + } + + @NotNull + @Override + public Future submit(@NotNull Runnable task, T result) + { + return service.submit(wrap(task), result); + } + + @NotNull + @Override + public Future submit(@NotNull Runnable task) + { + return service.submit(wrap(task)); + } + + @NotNull + @Override + public List> invokeAll(@NotNull Collection> tasks) throws InterruptedException + { + return service.invokeAll(tasks); + } + + @NotNull + @Override + public List> invokeAll(@NotNull Collection> tasks, long timeout, @NotNull TimeUnit unit) throws InterruptedException + { + return service.invokeAll(tasks, timeout, unit); + } + + @NotNull + @Override + public T invokeAny(@NotNull Collection> tasks) throws InterruptedException, ExecutionException + { + return service.invokeAny(tasks); + } + + @Override + public T invokeAny(@NotNull Collection> tasks, long timeout, @NotNull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException + { + return service.invokeAny(tasks, timeout, unit); + } +} diff --git a/runelite-client/src/main/java/com/openosrs/client/util/SwingUtil.java b/runelite-client/src/main/java/com/openosrs/client/util/SwingUtil.java new file mode 100644 index 0000000000..799b67bfa4 --- /dev/null +++ b/runelite-client/src/main/java/com/openosrs/client/util/SwingUtil.java @@ -0,0 +1,19 @@ +package com.openosrs.client.util; + +import java.awt.EventQueue; +import java.lang.reflect.InvocationTargetException; + +public class SwingUtil extends net.runelite.client.util.SwingUtil +{ + public static void syncExec(final Runnable r) throws InvocationTargetException, InterruptedException + { + if (EventQueue.isDispatchThread()) + { + r.run(); + } + else + { + EventQueue.invokeAndWait(r); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/RuneLite.java b/runelite-client/src/main/java/net/runelite/client/RuneLite.java index cb20c2699d..5eabee491c 100644 --- a/runelite-client/src/main/java/net/runelite/client/RuneLite.java +++ b/runelite-client/src/main/java/net/runelite/client/RuneLite.java @@ -30,7 +30,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.inject.Guice; import com.google.inject.Inject; import com.google.inject.Injector; -import com.openosrs.client.PluginManager; +import com.openosrs.client.plugins.BuiltInPluginManager; import java.io.File; import java.lang.management.ManagementFactory; import java.lang.management.RuntimeMXBean; @@ -112,6 +112,9 @@ public class RuneLite @Inject private ExternalPluginManager externalPluginManager; + @Inject + private com.openosrs.client.plugins.ExternalPluginManager oprsExternalPluginManager; + @Inject private EventBus eventBus; @@ -322,9 +325,19 @@ public class RuneLite // Tell the plugin manager if client is outdated or not pluginManager.setOutdated(isOutdated); + // Load external plugin manager + oprsExternalPluginManager.startExternalUpdateManager(); + oprsExternalPluginManager.startExternalPluginManager(); + + // Update external plugins + //oprsExternalPluginManager.update(); //TODO: Re-enable after fixing actions for new repo + // Load the plugins, but does not start them yet. // This will initialize configuration pluginManager.loadCorePlugins(); + + oprsExternalPluginManager.loadPlugins(); + externalPluginManager.loadExternalPlugins(); SplashScreen.stage(.70, null, "Finalizing configuration"); @@ -377,8 +390,8 @@ public class RuneLite overlayManager.add(tooltipOverlay.get()); } - //Load OPRS plugins - PluginManager.loadPlugins(); + //Load built-in OPRS plugins + BuiltInPluginManager.loadPlugins(); // Start plugins pluginManager.startPlugins(); diff --git a/runelite-client/src/main/java/net/runelite/client/RuneLiteModule.java b/runelite-client/src/main/java/net/runelite/client/RuneLiteModule.java index cebf34d6d7..381d945a34 100644 --- a/runelite-client/src/main/java/net/runelite/client/RuneLiteModule.java +++ b/runelite-client/src/main/java/net/runelite/client/RuneLiteModule.java @@ -24,13 +24,20 @@ */ package net.runelite.client; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.name.Names; +import com.openosrs.client.config.OpenOSRSConfig; +import com.openosrs.client.util.NonScheduledExecutorServiceExceptionLogger; import java.applet.Applet; import java.io.File; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import javax.annotation.Nullable; import javax.inject.Singleton; @@ -129,4 +136,28 @@ public class RuneLiteModule extends AbstractModule { return new ChatClient(okHttpClient); } + + @Provides + @Singleton + OpenOSRSConfig provideOpenOSRSConfig(ConfigManager configManager) + { + return configManager.getConfig(OpenOSRSConfig.class); + } + + @Provides + @Singleton + ExecutorService provideExecutorService() + { + int poolSize = 2 * Runtime.getRuntime().availableProcessors(); + + // Will start up to poolSize threads (because of allowCoreThreadTimeOut) as necessary, and times out + // unused threads after 1 minute + ThreadPoolExecutor executor = new ThreadPoolExecutor(poolSize, poolSize, + 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + new ThreadFactoryBuilder().setNameFormat("worker-%d").build()); + executor.allowCoreThreadTimeOut(true); + + return new NonScheduledExecutorServiceExceptionLogger(executor); + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/PluginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/PluginManager.java index 5b77ac6c12..268ad365ce 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/PluginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/PluginManager.java @@ -47,8 +47,10 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; @@ -97,6 +99,7 @@ public class PluginManager @Setter boolean isOutdated; + private Collection oprsPlugins; @Inject @VisibleForTesting @@ -191,7 +194,26 @@ public class PluginManager injectors.add(RuneLite.getInjector()); plugins = getPlugins(); } - plugins.forEach(pl -> injectors.add(pl.getInjector())); + plugins.forEach(pl -> + { + //TODO: Not sure why this is necessary but it is. The Injector isn't null when its handed off from our ExternalPluginManager. + // Hopefully we can figure out the root cause of the underlying issue. + if (pl.injector == null) + { + // Create injector for the module + Module pluginModule = (Binder binder) -> + { + // Since the plugin itself is a module, it won't bind itself, so we'll bind it here + binder.bind(com.openosrs.client.plugins.Plugin.class).toInstance((com.openosrs.client.plugins.Plugin)pl); + binder.install(pl); + }; + Injector pluginInjector = RuneLite.getInjector().createChildInjector(pluginModule); + pluginInjector.injectMembers(pl); + pl.injector = pluginInjector; + } + + injectors.add(pl.getInjector()); + }); List list = new ArrayList<>(); for (Injector injector : injectors) @@ -547,6 +569,64 @@ public class PluginManager return plugins; } + public Collection getOprsPlugins() + { + return oprsPlugins; + } + + /** + * Topologically sort a graph into separate groups. + * Each group represents the dependency level of the plugins. + * Plugins in group (index) 0 has no dependents. + * Plugins in group 1 has dependents in group 0. + * Plugins in group 2 has dependents in group 1, etc. + * This allows for loading dependent groups serially, starting from the last group, + * while loading plugins within each group in parallel. + * + * @param graph + * @param + * @return + */ + public static List> topologicalGroupSort(Graph graph) + { + final Set root = graph.nodes().stream() + .filter(node -> graph.inDegree(node) == 0) + .collect(Collectors.toSet()); + final Map dependencyCount = new HashMap<>(); + + root.forEach(n -> dependencyCount.put(n, 0)); + root.forEach(n -> graph.successors(n) + .forEach(m -> incrementChildren(graph, dependencyCount, m, dependencyCount.get(n) + 1))); + + // create list dependency grouping + final List> dependencyGroups = new ArrayList<>(); + final int[] curGroup = {-1}; + + dependencyCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .forEach(entry -> + { + if (entry.getValue() != curGroup[0]) + { + curGroup[0] = entry.getValue(); + dependencyGroups.add(new ArrayList<>()); + } + dependencyGroups.get(dependencyGroups.size() - 1).add(entry.getKey()); + }); + + return dependencyGroups; + } + + private static void incrementChildren(Graph graph, Map dependencyCount, T n, int val) + { + if (!dependencyCount.containsKey(n) || dependencyCount.get(n) < val) + { + dependencyCount.put(n, val); + graph.successors(n).forEach(m -> + incrementChildren(graph, dependencyCount, m, val + 1)); + } + } + private void schedule(Plugin plugin) { for (Method method : plugin.getClass().getMethods()) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java index 8894b0fcc9..382282b92e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java @@ -76,6 +76,7 @@ class PluginListPanel extends PluginPanel private static final String RUNELITE_GROUP_NAME = RuneLiteConfig.class.getAnnotation(ConfigGroup.class).value(); private static final String PINNED_PLUGINS_CONFIG_KEY = "pinnedPlugins"; private static final ImmutableList CATEGORY_TAGS = ImmutableList.of( + "OpenOSRS", "Combat", "Chat", "Item", diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index fdde73c995..c839316c36 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -128,7 +128,8 @@ public class ClientUI private boolean withTitleBar; private BufferedImage sidebarOpenIcon; private BufferedImage sidebarClosedIcon; - private ContainableFrame frame; + @Getter + private static ContainableFrame frame; private JPanel navContainer; private PluginPanel pluginPanel; private ClientPluginToolbar pluginToolbar; diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externalmanager_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externalmanager_icon.png new file mode 100644 index 0000000000..7cb7e2502c Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externalmanager_icon.png differ diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_icon.png new file mode 100644 index 0000000000..343c3dce0c Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_icon.png differ diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_raw_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_raw_icon.png new file mode 100644 index 0000000000..7f1afebf35 Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/add_raw_icon.png differ diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/delete_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/delete_icon.png new file mode 100644 index 0000000000..3f4915d041 Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/delete_icon.png differ diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/discord_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/discord_icon.png new file mode 100644 index 0000000000..6b7a6988f9 Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/discord_icon.png differ diff --git a/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/gh_icon.png b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/gh_icon.png new file mode 100644 index 0000000000..4d8e66215e Binary files /dev/null and b/runelite-client/src/main/resources/com/openosrs/client/plugins/openosrs/externals/gh_icon.png differ