Class PluginInstallUtils

java.lang.Object
com.ultikits.ultitools.utils.PluginInstallUtils

public class PluginInstallUtils extends Object
Utility class for plugin installation and management operations. Provides methods to download, install, and uninstall UltiTools plugins from the remote plugin repository.
Since:
6.0.0
Author:
wisdomme
  • Field Details

    • LOGGER

      private static final Logger LOGGER
    • DEFAULT_MODULE_CODE_SOURCE

      static final Function<UltiToolsPlugin,File> DEFAULT_MODULE_CODE_SOURCE
      How the uninstall asks a loaded module which JAR it came from; a test passes its own.
    • GSON

      private static final com.google.gson.Gson GSON
    • baseUrl

      private static volatile String baseUrl
    • customBaseUrl

      private static String customBaseUrl
  • Constructor Details

    • PluginInstallUtils

      public PluginInstallUtils()
  • Method Details

    • getBaseUrl

      static String getBaseUrl()
      Get the base URL for API calls. Uses lazy initialization to avoid requiring UltiTools at class loading time.
      Returns:
      the base URL
    • setBaseUrlForTesting

      static void setBaseUrlForTesting(String url)
      Set a custom base URL for testing purposes. This should only be used in unit tests.
      Parameters:
      url - the custom base URL, or null to reset to default
    • resetBaseUrl

      static void resetBaseUrl()
      Reset the base URL to force re-initialization. This should only be used in unit tests.
    • normalizeIdentifyString

      private static String normalizeIdentifyString(String idString)
    • encodeQueryValue

      private static String encodeQueryValue(String value)
    • encodePathSegment

      private static String encodePathSegment(String value)
    • installedJarName

      static String installedJarName(String idString, String version)
    • unwrapData

      private static <T> T unwrapData(String body, Class<T> clazz)
      Extract the "data" field from a backend API envelope response. The backend wraps responses as {"code":"200","msg":"Success","data":...}.
      Type Parameters:
      T - the target type
      Parameters:
      body - the raw JSON response body
      clazz - the class to deserialize the data field into
      Returns:
      the deserialized data, or null if absent or response indicates error
    • unwrapData

      private static <T> T unwrapData(String body, Type type)
      Extract the "data" field from a backend API envelope response (generic type version).
      Type Parameters:
      T - the target type
      Parameters:
      body - the raw JSON response body
      type - the Type to deserialize the data field into
      Returns:
      the deserialized data, or null if absent or response indicates error
    • unwrapStringData

      private static String unwrapStringData(String body)
      Extract a String value from the "data" field of a backend API envelope response. Handles both quoted JSON strings and primitive values.
      Parameters:
      body - the raw JSON response body
      Returns:
      the data as a String, or null if absent or response indicates error
    • getPluginList

      public static List<PluginEntity> getPluginList(int page, int pageSize)
      Get plugin list online.
      Parameters:
      page - the page number
      pageSize - the number of items per page
      Returns:
      the plugin list
    • getPluginVersionDownloadLink

      public static String getPluginVersionDownloadLink(String idString, String version)
      Get plugin download link.
      Parameters:
      idString - the plugin ID
      version - the version
      Returns:
      the plugin download link
    • getPluginVersions

      public static List<String> getPluginVersions(String idString)
      Get plugin versions.
      Parameters:
      idString - the plugin ID
      Returns:
      the plugin version list
    • getPluginLatestVersion

      public static String getPluginLatestVersion(String idString)
      Get plugin latest version.
      Parameters:
      idString - the plugin ID
      Returns:
      the plugin's latest version
    • getPluginLatestDownloadLink

      public static String getPluginLatestDownloadLink(String idString)
      Get plugin latest version download link.
      Parameters:
      idString - Plugin identify string
      Returns:
      Download link for latest version
    • getPlugin

      public static PluginEntity getPlugin(String idString)
      Get plugin information by identify string.
      Parameters:
      idString - Plugin identify string
      Returns:
      Plugin entity or null if not found
    • installLatestPlugin

      public static boolean installLatestPlugin(String idString)
      Install latest plugin.
      Parameters:
      idString - the plugin ID
      Returns:
      whether the install succeeded
    • installPlugin

      public static boolean installPlugin(String idString, String version)
      Install plugin.
      Parameters:
      idString - the plugin ID
      version - the version
      Returns:
      whether the install succeeded
    • findPluginJar

      public static File findPluginJar(File pluginsFolder, String identifyString)
      Find the JAR file for a plugin by its identify-string. Scans all JARs in the given directory, reads plugin.yml from each.
      Parameters:
      pluginsFolder - the folder to scan
      identifyString - the identify-string to match
      Returns:
      the matching JAR file, or null if not found
    • updatePlugin

      public static boolean updatePlugin(String identifyString)
      Update a plugin module: download latest version and delete old JAR.
      Parameters:
      identifyString - the plugin identify string
      Returns:
      true if update succeeded
    • probe

      private static PluginInstallUtils.Presence probe(File file)
      What is at file, without following links, distinguishing "not there" from "could not be determined".
      Parameters:
      file - the path to probe
      Returns:
      what is there
    • probeFollowingLinks

      private static PluginInstallUtils.Presence probeFollowingLinks(File file)
      What is at file with links followed: the question "is this one of the files the module loader would open?", which is a different question from "is anything at this path?".

      PluginManager#init opens each entry through new JarFile(file), which follows links, so a link to a real JAR is a file it loads from. The access question keeps NOFOLLOW_LINKS, because that is what separates absence from a denial; this one must match the loader.

      Parameters:
      file - the path to probe
      Returns:
      what is at the end of it
    • probe

      private static PluginInstallUtils.Presence probe(File file, LinkOption... options)
      probe(File) with the link options to use.
      Parameters:
      file - the path to probe
      options - how to treat a symbolic link
      Returns:
      what is there
    • codeSourceJarOf

      static File codeSourceJarOf(Class<?> moduleClass)
      The JAR a module class was loaded from, or null when it was not loaded from one.

      A module that is loaded does not have to be identified by metadata at all: UltiTools modules are recognised by @UltiToolsModule, and PluginManager selects a module class with UltiToolsPlugin.class.isAssignableFrom and instantiates it through getDeclaredConstructor().newInstance() -- no plugin.yml is consulted. So the framework asks the instance instead, exactly as UltiToolsPlugin itself does when it reads its own embedded resources.

      A code source that is a directory is a development checkout rather than an installed JAR and answers nothing about which file to delete. One that cannot be probed is still named: the module was loaded from it, and a probe that cannot answer is not an answer of "no".

      Parameters:
      moduleClass - the loaded module's class
      Returns:
      the JAR, or null when there is none to name
    • codeSourceLocationOf

      static File codeSourceLocationOf(Class<?> moduleClass)
      Whether which JAR a module was loaded from could be determined at all.

      Three answers through one value, because the difference matters: a file is the JAR the module came from; a directory is an exploded development checkout, which is no JAR at all and says so; and null is "could not be determined" -- a code source a policy refuses to reveal, or a class that has none. Unknown is not "none": while it holds, any entry matched only by a name might be that running module's own JAR.

      Parameters:
      moduleClass - the loaded module's class
      Returns:
      the code source's location, or null when it could not be determined
    • resolveCodeSourceFile

      private static File resolveCodeSourceFile(URL location)
      A code source URL as a file, whatever escaping it carries.
    • readArchive

      private static PluginInstallUtils.ArchiveIdentity readArchive(File file)
      Reads what an archive declares about itself.

      The one place a plugin.yml is opened. Four outcomes, each a positive answer: it declares a name; it opened and declares none (no plugin.yml, or one without a name: key -- which say the same thing, since a module needs no metadata at all); it could not be opened or parsed; or the entry is one the module loader itself would never load.

      Parameters:
      file - the entry
      Returns:
      what it says about itself
    • readDeclaredName

      private static PluginInstallUtils.ArchiveIdentity readDeclaredName(JarFile jarFile, JarEntry entry, File file) throws IOException
      The name a plugin.yml declares, if it declares one.
      Parameters:
      jarFile - the archive
      entry - its plugin.yml
      file - the archive's path, for the log line
      Returns:
      what it says about itself
      Throws:
      IOException - when the entry cannot be read
    • canonicalPathOf

      private static String canonicalPathOf(File file)
      file's canonical path, or its absolute path when it cannot be canonicalised.
    • carrying

      private static <T extends Throwable> T carrying(T failure, List<String> entries)
      Attaches the undetermined entries to a failure on its way out, when there are any.
      Type Parameters:
      T - the failure's type
      Parameters:
      failure - the failure leaving the uninstall
      entries - the absolute paths of the entries nothing could identify
      Returns:
      failure
    • uninstallPlugin

      public static boolean uninstallPlugin(String name) throws IOException
      Uninstalls a module: unloads every loaded instance of it and deletes its JARs.

      Unloading goes through PluginManager.unregister(UltiToolsPlugin), the framework's one full unload path (#503). Calling plugin.unregisterSelf() directly, as this method used to, skipped everything unregister does first -- cancelling the module's @Scheduled tasks, releasing its @PlayerCache beans, its tab-completion completers, its EventBus handlers and its conditional-bean records, then closing its context. A module "uninstalled" that way kept running its repeating tasks until the next restart.

      What it reports is what happened on disk (#501). The old implementation ignored File.delete()'s result and returned true whether or not the JAR was removed, and it stopped at the first matching JAR, so a second JAR of the same module loaded it again on restart. Now every entry the modules folder holds is placed in exactly one PluginInstallUtils.EntryState, and what the method returns or throws says which of those it met.

      Parameters:
      name - the module's runtime name -- which need not be what its JAR's plugin.yml declares, since UltiToolsPlugin(String pluginName, ...) takes it as an argument and reads no metadata
      Returns:
      true when every entry identified as this module's was deleted; false when none was and no loaded module of that name was unloaded either -- the "check the spelling" case
      Throws:
      AccessDeniedException - when the modules folder exists but could not be listed, so nothing can be concluded about what it holds
      FileSystemException - when an entry identified as this module's could not be deleted, naming every one still on disk
      NoSuchFileException - when a loaded module was unloaded but nothing in the folder could be identified as its JAR, which is not a spelling mistake
      IllegalStateException - when the module's own unload threw. The module is still removed from the loaded modules and its JARs are still deleted: unregister has closed its context by then, and keeping the JAR would bring back on restart a module the operator asked to remove. A JAR failure is attached as suppressed.
      IOException - if the modules folder cannot be read
    • uninstallPluginReporting

      @Internal public static PluginInstallUtils.UninstallReport uninstallPluginReporting(String name) throws IOException
      uninstallPlugin(String), also reporting the entries whose identity could not be determined -- the ones a caller has to tell the operator about, because one of them may be a copy of this module that loads again at the next start.
      Parameters:
      name - the module's runtime name
      Returns:
      what the uninstall did and what it could not determine
      Throws:
      IOException - exactly as uninstallPlugin(String) documents
    • uninstallPluginReporting

      @Internal static PluginInstallUtils.UninstallReport uninstallPluginReporting(String name, Function<UltiToolsPlugin,File> codeSource) throws IOException
      uninstallPluginReporting(String) with the code-source resolver to ask, which is how a test substitutes one without production code carrying a writable field (gate 1, IN-02).
      Parameters:
      name - the module's runtime name, or a name a JAR of it declares
      codeSource - how to ask a loaded module which JAR it came from
      Returns:
      what the uninstall did and what it could not determine
      Throws:
      IOException - exactly as uninstallPlugin(String) documents
    • chooseTargets

      private static void chooseTargets(String requested, List<UltiToolsPlugin> loaded, List<UltiToolsPlugin> others, Map<UltiToolsPlugin,String> declared)
      Moves the instances the argument names from others into loaded.

      The two namespaces are asked in order, never together. A runtime-name match answers the question outright: if any loaded module answers to the name that was typed, that is the answer and no declared-name lookup runs beside it -- asking both at once made one module's runtime name and another's declared name equally good, and unloaded both.

      Only when nothing answers by runtime name is the other namespace consulted, because naming a module by what its JAR declares must still work (gate 1, BL-01). If more than one module answers that way, the uninstall refuses and names the candidates rather than choosing: this deletes files, and a destructive command with two possible targets must stop.

      Parameters:
      requested - the operator's argument
      loaded - collects the instances it names
      others - every loaded instance; the chosen ones are moved out of it
      declared - what each instance's JAR declares
    • refuseIfAmbiguous

      private static void refuseIfAmbiguous(String requested, List<UltiToolsPlugin> candidates)
      Refuses when more than one module answers to a declared name.
      Parameters:
      requested - the operator's argument
      candidates - the loaded modules whose JARs declare it
    • resolveIdentity

      private static PluginInstallUtils.ModuleIdentity resolveIdentity(String requested, PluginManager pluginManager, Function<UltiToolsPlugin,File> codeSource)
      Resolves the operator's argument into the one identity every later decision reads.

      Argument to loaded instances: an instance matches when its runtime name is the argument, or when the JAR it was loaded from declares that name -- the second half is gate 1's BL-01, where naming a module by what its JAR declares deleted the JAR of a module that was left running.

      Instances to keys: what their own JARs declare, plus the argument. A key another loaded module owns -- its runtime name, or what its own JAR declares -- is dropped, which is gate 1's BL-02: a name lifted off one module's JAR is not safe as a global key. Those modules' JARs are also remembered, because no decision may end in deleting one of them.

      With nothing loaded, the argument is the only key there is, and that is recorded.

      Parameters:
      requested - the operator's argument
      pluginManager - the manager that owns the plugin list
      codeSource - how to ask a loaded module which JAR it came from
      Returns:
      the resolved identity
    • readLoadedModules

      private static void readLoadedModules(PluginManager pluginManager, Function<UltiToolsPlugin,File> codeSource, List<UltiToolsPlugin> others, Map<UltiToolsPlugin,File> jars, Map<UltiToolsPlugin,String> declared, AtomicBoolean someUnknown)
      Reads every loaded module once: which JAR it came from, and what that JAR declares.

      Read before anything is unloaded: afterwards the instance and its class loader may be gone, and with them the only authoritative answer to "which JAR is this module's". Which of them the argument names is decided afterwards, by chooseTargets(java.lang.String, java.util.List<com.ultikits.ultitools.abstracts.UltiToolsPlugin>, java.util.List<com.ultikits.ultitools.abstracts.UltiToolsPlugin>, java.util.Map<com.ultikits.ultitools.abstracts.UltiToolsPlugin, java.lang.String>).

      Parameters:
      pluginManager - the manager that owns the plugin list
      codeSource - how to ask a loaded module which JAR it came from
      others - collects every loaded instance, before any is chosen as a target
      jars - collects each instance's code-source JAR
      declared - collects what each instance's JAR declares
      someUnknown - set when some instance's code source could not be determined at all
    • unloadEvery

      private static Throwable unloadEvery(PluginInstallUtils.ModuleIdentity identity, PluginManager pluginManager)
      Unloads every matching module through the framework's full unload path and delists it.

      A module that throws while unloading is collected rather than propagated: it is already unloaded and its context already closed, so the rest of the uninstall goes ahead and the failure is reported together with the JAR outcome. It leaves the plugin list either way, because a listed module whose context is closed would be reported as still loaded.

      Parameters:
      identity - the resolved identity, whose loaded instances are unloaded
      pluginManager - the manager that owns the plugin list
      Returns:
      the first unload failure with any later one attached, or null
    • firstOf

      private static Throwable firstOf(Throwable first, Throwable next)
      The first failure, with any later one attached to it.

      Combining two failures has more ways to fail than it looks. addSuppressed rejects a throwable suppressing itself, which a module reusing a static sentinel produces from two instances -- and that IllegalArgumentException would escape past every handler, abandoning the uninstall with the modules delisted and their JARs still on disk. It also rejects null. Attaching the same object twice is not an error but says nothing, so it is skipped too.

      Parameters:
      first - the failure collected so far, or null
      next - the failure to attach
      Returns:
      the failure to carry on with
    • alreadyAttached

      private static boolean alreadyAttached(Throwable failure, Throwable candidate)
      Whether candidate is already among failure's suppressed throwables.
    • deleteModuleJars

      private static PluginInstallUtils.UninstallReport deleteModuleJars(PluginInstallUtils.ModuleIdentity identity) throws IOException
      The JAR half of uninstallPlugin(String): deletes every entry identified as this module's -- not only the first one listed, since a second JAR loads the module again on restart -- and reports the entries nothing could be read from.
      Parameters:
      identity - the resolved identity: which instances were loaded, which JARs they came from, which keys other entries are matched against, and which JARs belong to other loaded modules
      Returns:
      what was deleted and what could not be identified
      Throws:
      IOException - as documented on uninstallPlugin(String)
    • refuseIfAnotherModuleSharesTheJar

      private static void refuseIfAnotherModuleSharesTheJar(PluginInstallUtils.ModuleIdentity identity)
      Refuses before anything is unloaded when a JAR this uninstall would act on is also the JAR another running module was loaded from.

      Two module classes can be packaged in one archive and registered separately, so one physical JAR can be two loaded modules' code source. Deleting it would take a module the operator did not name, and unloading the target first would change the loaded state for an uninstall whose outcome is already known to be undefined -- so nothing happens at all. The invariant at the delete stays as well, for whatever future path reaches one by another route.

      Parameters:
      identity - the resolved identity
    • refuseToTouchAnotherModulesJar

      private static void refuseToTouchAnotherModulesJar(PluginInstallUtils.ModuleIdentity identity, List<File> toDelete)
      The invariant, checked immediately before anything is deleted: no JAR another loaded module was loaded from may be removed, whatever decided to get here.

      Gate 1's BL-02 was a bystander module losing its JAR because a name lifted off the target's JAR became the match key. Resolution no longer produces such a key, and this is the guard at the point of destruction for every other way it could happen. It fires before the first delete, so nothing is half-done when it does.

      Parameters:
      identity - the resolved identity, carrying the other loaded modules' JARs
      toDelete - the entries about to be deleted
    • modulesFolder

      private static File modulesFolder()
      The folder the uninstall acts on.

      PluginManager#init scans System.getProperty("user.dir") + /plugins/UltiTools/plugins while this reads the framework's own data folder. On a server started from its own root the two are one path and can diverge when it is not; that they are computed twice is gate 1's IN-05, filed as its own issue. This side uses the data folder because every other file the uninstall touches is relative to it.

      Returns:
      the modules folder
    • modulesFolderUnlistable

      private static AccessDeniedException modulesFolderUnlistable(File folder)
      The state-D failure: the folder is there, and what it holds is unknown.
    • classify

      private static PluginInstallUtils.EntryState classify(File file, PluginInstallUtils.ModuleIdentity identity)
      Which PluginInstallUtils.EntryState an entry of the modules folder is in.

      The loaded instance's own code-source JAR is state A whatever it declares -- the module itself said where it came from. Otherwise the archive is read once (readArchive(java.io.File)): an entry the module loader would never load, and one declaring a module that is not this one, are state B; one that declares a key this identity owns is state A; and one that says nothing about itself -- would not open, cannot be parsed, carries no plugin.yml or no name: -- is state C.

      Parameters:
      file - the entry
      identity - the resolved identity, whose keys and own JARs decide this
      Returns:
      the state it is in
    • absolutePathsOf

      private static List<String> absolutePathsOf(List<File> files)
      The absolute paths of files, in the order given.
    • deleteAllOrThrow

      private static void deleteAllOrThrow(List<File> files) throws FileSystemException
      Deletes every file, and reports the ones that are still there.
      Parameters:
      files - the JARs to delete
      Throws:
      FileSystemException - naming the first JAR that survived, with any other attached as suppressed -- each one of them loads the module again on restart
    • noJarFound

      private static boolean noJarFound(File folder, PluginInstallUtils.ModuleIdentity identity, List<String> undetermined) throws NoSuchFileException
      What "nothing here could be identified as this module's" means, which depends on whether one was loaded.
      Parameters:
      folder - the modules folder
      identity - the resolved identity
      undetermined - the entries nothing could identify, attached to the failure
      Returns:
      false, the "no such module" answer, when nothing was unloaded either
      Throws:
      NoSuchFileException - when a module was unloaded and its JAR is missing, with one suppressed entry per undetermined file
    • unloadFailed

      private static PluginInstallUtils.ModuleUnloadFailedException unloadFailed(String name, Throwable unloadFailure, IOException jarFailure)
      The failure raised when a module's own unload threw, after the uninstall went ahead anyway.
      Parameters:
      name - the module's runtime name
      unloadFailure - what its unload threw
      jarFailure - the JAR outcome to attach, or null when the JARs were deleted
      Returns:
      the exception to throw