From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Paul Sauve Date: Sun, 20 Dec 2020 14:08:24 -0600 Subject: [PATCH] Airplane Profiler Airplane Copyright (C) 2020 Technove LLC This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . diff --git a/pom.xml b/pom.xml index a148af69be216d2869538d402d9a0d12c81b5df6..c917f825378dd16a329105b4e7fcc8882755bc5a 100644 --- a/pom.xml +++ b/pom.xml @@ -173,6 +173,13 @@ fe3dbb4420 compile + + + com.github.technove + Flare + master-SNAPSHOT + compile + diff --git a/src/main/java/gg/airplane/AirplaneConfig.java b/src/main/java/gg/airplane/AirplaneConfig.java index 0e452ea9c5d098326d22a20aa67e423f85843db7..7ec84ef1d1cbb1fabf4c590a2f2c1da3cc181010 100644 --- a/src/main/java/gg/airplane/AirplaneConfig.java +++ b/src/main/java/gg/airplane/AirplaneConfig.java @@ -79,4 +79,27 @@ public class AirplaneConfig { dynamicHoglinBehavior = config.getBoolean("behavior-activation.hoglin", true); } + + public static String profileWebUrl; + + private static void profilerOptions() { + config.setComment("flare", "Configures Flare, the built-in profiler"); + + profileWebUrl = config.getString("flare.url", "https://flare.airplane.gg", "Sets the server to use for profiles."); + } + + + public static String accessToken; + + private static void airplaneWebServices() { + config.setComment("web-services", "Options for connecting to Airplane's online utilities"); + + accessToken = config.getString("web-services.token", ""); + // todo lookup token (off-thread) and let users know if their token is valid + if (accessToken.length() > 0) { + gg.airplane.flare.FlareSetup.init(); // Airplane + } + } + + } diff --git a/src/main/java/gg/airplane/AirplaneLogger.java b/src/main/java/gg/airplane/AirplaneLogger.java new file mode 100644 index 0000000000000000000000000000000000000000..1a9d71739019d12772bec6076b195552ff6299f9 --- /dev/null +++ b/src/main/java/gg/airplane/AirplaneLogger.java @@ -0,0 +1,17 @@ +package gg.airplane; + +import org.bukkit.Bukkit; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class AirplaneLogger extends Logger { + public static final AirplaneLogger LOGGER = new AirplaneLogger(); + + private AirplaneLogger() { + super("Airplane", null); + + setParent(Bukkit.getLogger()); + setLevel(Level.ALL); + } +} diff --git a/src/main/java/gg/airplane/commands/AirplaneCommands.java b/src/main/java/gg/airplane/commands/AirplaneCommands.java index 807cf274619b8f7be839e249cb62b9817876ca04..66b20250a26d005427601b1cdee43bdd9eba70cc 100644 --- a/src/main/java/gg/airplane/commands/AirplaneCommands.java +++ b/src/main/java/gg/airplane/commands/AirplaneCommands.java @@ -1,10 +1,12 @@ package gg.airplane.commands; import gg.airplane.AirplaneCommand; +import gg.airplane.flare.FlareCommand; import net.minecraft.server.MinecraftServer; public class AirplaneCommands { public static void init() { MinecraftServer.getServer().server.getCommandMap().register("airplane", "Airplane", new AirplaneCommand()); + MinecraftServer.getServer().server.getCommandMap().register("flare", "Airplane", new FlareCommand()); } } diff --git a/src/main/java/gg/airplane/compat/ServerConfigurations.java b/src/main/java/gg/airplane/compat/ServerConfigurations.java new file mode 100644 index 0000000000000000000000000000000000000000..f4976428bc721319d2926e97cbe0f64c6e9e503c --- /dev/null +++ b/src/main/java/gg/airplane/compat/ServerConfigurations.java @@ -0,0 +1,77 @@ +package gg.airplane.compat; + +import co.aikar.timings.TimingsManager; +import com.google.common.io.Files; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; + +public class ServerConfigurations { + + public static final String[] configurationFiles = new String[]{ + "server.properties", + "bukkit.yml", + "spigot.yml", + "paper.yml", + "tuinity.yml", + "airplane.air" + }; + + public static String getCleanCopy(String configName) throws IOException { + File file = new File(configName); + List hiddenConfigs = TimingsManager.hiddenConfigs; + + if (configName.equals("airplane.air")) { + return Files.readLines(file, StandardCharsets.UTF_8) + .stream() + .filter(line -> !line.trim().startsWith("#")) + .map(line -> line.contains("token") ? " token = **" : line) + .collect(Collectors.joining("\n")); + } + + switch (Files.getFileExtension(configName)) { + case "properties": { + Properties properties = new Properties(); + try (FileInputStream inputStream = new FileInputStream(file)) { + properties.load(inputStream); + } + for (String hiddenConfig : hiddenConfigs) { + properties.remove(hiddenConfig); + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + properties.store(outputStream, ""); + return Arrays.stream(outputStream.toString() + .split("\n")) + .filter(line -> !line.startsWith("#")) + .collect(Collectors.joining("\n")); + } + case "yml": { + YamlConfiguration configuration = new YamlConfiguration(); + try { + configuration.load(file); + } catch (InvalidConfigurationException e) { + throw new IOException(e); + } + configuration.options().header(null); + for (String key : configuration.getKeys(true)) { + if (hiddenConfigs.contains(key)) { + configuration.set(key, null); + } + } + return configuration.saveToString(); + } + default: + throw new IllegalArgumentException("Bad file type " + configName); + } + } + +} diff --git a/src/main/java/gg/airplane/flare/FlareCommand.java b/src/main/java/gg/airplane/flare/FlareCommand.java new file mode 100644 index 0000000000000000000000000000000000000000..ddc90f1589e683f452c5a74d9d2408803edea029 --- /dev/null +++ b/src/main/java/gg/airplane/flare/FlareCommand.java @@ -0,0 +1,149 @@ +package gg.airplane.flare; + +import com.google.common.collect.ImmutableList; +import gg.airplane.AirplaneConfig; +import gg.airplane.flare.exceptions.UserReportableException; +import gg.airplane.flare.profiling.AsyncProfilerIntegration; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.craftbukkit.scheduler.MinecraftInternalPlugin; +import org.bukkit.util.StringUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class FlareCommand extends Command { + + private static final String BASE_URL = "https://blog.airplane.gg/flare-tutorial/#setting-the-access-token"; + private static final TextColor HEX = TextColor.fromHexString("#e3eaea"); + private static final Component PREFIX = Component.text() + .append(Component.text("Flare ✈") + .color(TextColor.fromHexString("#6a7eda")) + .decoration(TextDecoration.BOLD, true) + .append(Component.text(" ", HEX) + .decoration(TextDecoration.BOLD, false))) + .asComponent(); + + public FlareCommand() { + super("flare", "Profile your server with Flare", "/flare", Collections.singletonList("profile")); + this.setPermission("airplane.flare"); + } + + @Override + public boolean execute(CommandSender sender, String commandLabel, String[] args) { + if (!testPermission(sender)) return true; + if (AirplaneConfig.accessToken.length() == 0) { + Component clickable = Component.text(BASE_URL, HEX, TextDecoration.UNDERLINED).clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, BASE_URL)); + + sender.sendMessage(PREFIX.append(Component.text("Flare currently requires an access token to use. To learn more, visit ").color(HEX).append(clickable))); + return true; + } + + if (AsyncProfilerIntegration.doesNotSupportProfiling()) { + sender.sendMessage(PREFIX.append( + Component.text("Profiling is not supported in this environment, reason: " + AsyncProfilerIntegration.getDisabledReason(), NamedTextColor.RED))); + return true; + } + if (ProfilingManager.isProfiling()) { + if (args.length == 1 && args[0].equalsIgnoreCase("status")) { + sender.sendMessage(PREFIX.append(Component.text("Status: " + AsyncProfilerIntegration.status(), HEX))); + return true; + } + if (ProfilingManager.stop()) { + if (!(sender instanceof ConsoleCommandSender)) { + sender.sendMessage(PREFIX.append(Component.text("Profiling has been stopped.", HEX))); + } + } else { + sender.sendMessage(PREFIX.append(Component.text("Profiling has already been stopped.", HEX))); + } + } else { + ProfileType profileType = null; + if (args.length > 0) { + try { + profileType = ProfileType.valueOf(args[0].toUpperCase()); + } catch (Exception e) { + sender.sendMessage(PREFIX.append(Component + .text("Invalid profile type ", HEX) + .append(Component.text(args[0], HEX, TextDecoration.BOLD) + .append(Component.text("!", HEX))) + )); + } + } + int interval = 5; + if (args.length > 1) { + try { + interval = Integer.parseInt(args[1]); + } catch (Exception e) { + sender.sendMessage(PREFIX.append(Component + .text("Invalid time in milliseconds ", HEX) + .append(Component.text(args[1], HEX, TextDecoration.BOLD) + .append(Component.text("!", HEX))) + )); + return true; + } + } + int finalInterval = interval; + ProfileType finalProfileType = profileType; + Bukkit.getScheduler().runTaskAsynchronously(new MinecraftInternalPlugin(), () -> { + try { + if (ProfilingManager.start(finalProfileType, finalInterval)) { + if (!(sender instanceof ConsoleCommandSender)) { + sender.sendMessage(PREFIX.append(Component + .text("Flare has been started: " + ProfilingManager.getProfilingUrl().get(), HEX) + .clickEvent(ClickEvent.openUrl(ProfilingManager.getProfilingUrl().get())) + )); + sender.sendMessage(PREFIX.append(Component.text(" Run /" + commandLabel + " to stop the Flare.", HEX))); + } + } else { + sender.sendMessage(PREFIX.append(Component + .text("Flare has already been started: " + ProfilingManager.getProfilingUrl().get(), HEX) + .clickEvent(ClickEvent.openUrl(ProfilingManager.getProfilingUrl().get())) + )); + } + } catch (UserReportableException e) { + sender.sendMessage(Component.text("Flare failed to start: " + e.getUserError(), NamedTextColor.RED)); + e.printStackTrace(); + } + }); + } + return true; + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) throws IllegalArgumentException { + if (AsyncProfilerIntegration.doesNotSupportProfiling()) { + return ImmutableList.of(); + } + + List list = new ArrayList<>(); + if (AsyncProfilerIntegration.isProfiling()) { + if (args.length == 1) { + String lastWord = args[0]; + if (StringUtil.startsWithIgnoreCase("status", lastWord)) { + list.add("status"); + } + if (StringUtil.startsWithIgnoreCase("stop", lastWord)) { + list.add("stop"); + } + } + } else { + if (args.length <= 1) { + String lastWord = args.length == 0 ? "" : args[0]; + for (ProfileType value : ProfileType.values()) { + if (StringUtil.startsWithIgnoreCase(value.getInternalName(), lastWord)) { + list.add(value.name().toLowerCase()); + } + } + } + } + return list; + } +} diff --git a/src/main/java/gg/airplane/flare/FlareSetup.java b/src/main/java/gg/airplane/flare/FlareSetup.java new file mode 100644 index 0000000000000000000000000000000000000000..27ac32779e700494aeca8b425edb2871d3ec29cc --- /dev/null +++ b/src/main/java/gg/airplane/flare/FlareSetup.java @@ -0,0 +1,146 @@ +package gg.airplane.flare; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import gg.airplane.AirplaneConfig; +import gg.airplane.AirplaneLogger; +import gg.airplane.compat.ServerConfigurations; +import gg.airplane.flare.profiling.AsyncProfilerIntegration; +import net.minecraft.server.MinecraftServer; +import org.bukkit.Bukkit; +import org.bukkit.craftbukkit.scheduler.MinecraftInternalPlugin; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.PluginClassLoader; +import org.bukkit.scheduler.BukkitTask; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +public class FlareSetup { + + public static void init() { + ServerConnector.connector = new ServerConnector() { + + private final Cache pluginNameCache = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.MINUTES) + .maximumSize(1024) + .build(); + + @Override + public String getPluginForClass(String name) { + if (name.contains(".") && name.charAt(0) != '/') { + if (name.startsWith("net.minecraft") || name.startsWith("java.") || name.startsWith("com.mojang") || name.startsWith("com.google") || name.startsWith("it.unimi") || name.startsWith("sun")) { + return null; + } + + String className = name.substring(0, name.lastIndexOf(".")); + String existing = pluginNameCache.getIfPresent(name); + if (existing != null) { + return existing.isEmpty() ? null : existing; + } + + String newValue = ""; + + for (Plugin plugin : Bukkit.getPluginManager().getPlugins()) { + ClassLoader classLoader = plugin.getClass().getClassLoader(); + if (classLoader instanceof PluginClassLoader) { + try { + Class aClass = ((PluginClassLoader) classLoader)._airplane_findClass(className); + if (aClass != null) { + newValue = plugin.getName(); + } + } catch (ClassNotFoundException | IllegalAccessError e) { + } + } + } + + pluginNameCache.put(name, newValue); + } + return null; + } + + @Override + public Thread getMainThread() { + return MinecraftServer.getServer().serverThread; + } + + @Override + public Map getConfigurations() { + Map map = new LinkedHashMap<>(); + for (String configurationFile : ServerConfigurations.configurationFiles) { + try { + map.put(configurationFile, ServerConfigurations.getCleanCopy(configurationFile)); + } catch (IOException e) { + this.log(Level.WARNING, "Failed to load config file " + configurationFile, e); + } + } + return map; + } + + @Override + public void log(Level level, String s) { + AirplaneLogger.LOGGER.log(level, s); + } + + @Override + public void log(Level level, String s, Throwable throwable) { + AirplaneLogger.LOGGER.log(level, s, throwable); + } + + @Override + public String getPrimaryVersion() { + return Bukkit.getVersion(); + } + + @Override + public String getApiVersion() { + return "bukkit:" + Bukkit.getBukkitVersion(); + } + + @Override + public String getMcVersion() { + return Bukkit.getMinecraftVersion(); + } + + private final Map scheduledRunnables = new HashMap<>(); + + @Override + public void schedule(Runnable runnable, long l, long l1) { + BukkitTask task = Bukkit.getScheduler().runTaskTimer(new MinecraftInternalPlugin(), runnable, l, l1); + this.scheduledRunnables.put(runnable, task); + } + + @Override + public void scheduleAsync(Runnable runnable, long l, long l1) { + BukkitTask task = Bukkit.getScheduler().runTaskTimerAsynchronously(new MinecraftInternalPlugin(), runnable, l, l1); + this.scheduledRunnables.put(runnable, task); + } + + @Override + public void cancel(Runnable runnable) { + this.scheduledRunnables.get(runnable).cancel(); + } + + @Override + public void runAsync(Runnable runnable) { + Bukkit.getScheduler().runTaskAsynchronously(new MinecraftInternalPlugin(), runnable); + } + + @Override + public String getWebUrl() { + return AirplaneConfig.profileWebUrl; + } + + @Override + public String getToken() { + return AirplaneConfig.accessToken; + } + }; + AsyncProfilerIntegration.init(); + } + +} diff --git a/src/main/java/gg/airplane/flare/ProfilingManager.java b/src/main/java/gg/airplane/flare/ProfilingManager.java new file mode 100644 index 0000000000000000000000000000000000000000..86d6650d174a7794a7ebe793cad033b42215c321 --- /dev/null +++ b/src/main/java/gg/airplane/flare/ProfilingManager.java @@ -0,0 +1,65 @@ +package gg.airplane.flare; + +import gg.airplane.AirplaneConfig; +import gg.airplane.AirplaneLogger; +import gg.airplane.flare.exceptions.UserReportableException; +import gg.airplane.flare.profiling.ProfileController; +import org.bukkit.Bukkit; +import org.bukkit.craftbukkit.scheduler.MinecraftInternalPlugin; +import org.bukkit.scheduler.BukkitTask; + +import javax.annotation.Nullable; +import java.util.Optional; +import java.util.logging.Level; + +public class ProfilingManager { + + private static ProfileController currentController; + private static BukkitTask currentTask = null; + + public static synchronized boolean isProfiling() { + return currentController != null; + } + + public static synchronized Optional getProfilingUrl() { + if (!isProfiling()) { + return Optional.empty(); + } + return Optional.of(AirplaneConfig.profileWebUrl + "/" + currentController.getId()); + } + + public static synchronized boolean start(@Nullable ProfileType type, int interval) throws UserReportableException { + if (isProfiling()) { + return false; + } + if (Bukkit.isPrimaryThread()) { + throw new UserReportableException("Profiles should be started off-thread"); + } + currentController = new ProfileController(type, Math.max(interval, 1)); // don't allow lower than 20ms: https://bugzilla.redhat.com/show_bug.cgi?id=645528 + currentTask = Bukkit.getScheduler().runTaskLater(new MinecraftInternalPlugin(), ProfilingManager::stop, 20 * 60 * 15); + AirplaneLogger.LOGGER.log(Level.INFO, "Flare has been started: " + getProfilingUrl().orElse("An error occurred retrieving the Flare URL.")); + return true; + } + + public static synchronized boolean stop() { + if (!isProfiling()) { + return false; + } + AirplaneLogger.LOGGER.log(Level.INFO, "Flare has been stopped: " + getProfilingUrl().orElse("An error occurred retrieving the Flare URL.")); + try { + currentController.cancel(); + } catch (Throwable t) { + AirplaneLogger.LOGGER.log(Level.WARNING, "Error occurred stopping Flare", t); + } + currentController = null; + + try { + currentTask.cancel(); + } catch (Throwable t) { + AirplaneLogger.LOGGER.log(Level.WARNING, "Error occurred stopping Flare", t); + } + currentTask = null; + return true; + } + +} diff --git a/src/main/java/org/bukkit/craftbukkit/scheduler/MinecraftInternalPlugin.java b/src/main/java/org/bukkit/craftbukkit/scheduler/MinecraftInternalPlugin.java index 49dc0c441b9dd7e7745cf15ced67f383ebee1f99..b343d8ee7435312929558efdaf127334d8e2fff6 100644 --- a/src/main/java/org/bukkit/craftbukkit/scheduler/MinecraftInternalPlugin.java +++ b/src/main/java/org/bukkit/craftbukkit/scheduler/MinecraftInternalPlugin.java @@ -19,7 +19,8 @@ public class MinecraftInternalPlugin extends PluginBase { private boolean enabled = true; private final String pluginName; - private PluginDescriptionFile pdf; + private org.bukkit.plugin.PluginLogger logger; + private PluginDescriptionFile pdf; // Airplane public MinecraftInternalPlugin() { this.pluginName = "Minecraft"; @@ -72,7 +73,12 @@ public class MinecraftInternalPlugin extends PluginBase { @Override public PluginLogger getLogger() { - throw new UnsupportedOperationException("Not supported."); + // Airplane start + if (this.logger == null) { + this.logger = new org.bukkit.plugin.PluginLogger(this); // Airplane + } + return this.logger; + // Airplane end } @Override @@ -82,7 +88,7 @@ public class MinecraftInternalPlugin extends PluginBase { @Override public Server getServer() { - throw new UnsupportedOperationException("Not supported."); + return org.bukkit.Bukkit.getServer(); // Airplane - impl } @Override