Class PluginInitiationUtils

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

public class PluginInitiationUtils extends Object
Utility class for plugin initialization and WebSocket communication. Handles account login, WebSocket connection, and message processing for UltiPanel integration.
Since:
6.0.0
Author:
wisdomme
  • Field Details

    • SERVER_PROPERTIES_FILE

      private static final String SERVER_PROPERTIES_FILE
      server_properties is handled by its own dedicated manager — it is not a real config file path.
      See Also:
    • INBOUND_HANDLERS

      private static final Map<String,PluginInitiationUtils.InboundHandlerEntry> INBOUND_HANDLERS
      The inbound-message dispatch table: message type string to the PluginInitiationUtils.InboundHandlerEntry that serves it.

      Replaces what used to be a 24-case switch inside handleInboundMessage(com.google.gson.JsonObject) (NPath complexity 1514 against a threshold of 200 — see issue #234's coupled complexity finding). A switch multiplies independent path counts by the number of branches; a lookup does not, so the paths through handleInboundMessage(com.google.gson.JsonObject) are now bounded by its guards rather than by how many message types exist. Built once, statically, and never mutated after construction — see buildInboundHandlers().

      Not module-visible and never will be: this is framework-internal routing for the fixed set of panel protocol messages. Module-facing panel messaging is a separate, deliberately narrower surface (EventBus broadcast plus a single-owner request/response responder) that a later phase owns. A second module-visible dispatch mechanism grown out of this table would repeat a mistake this repository already has twice, in its command-executor and GUI generations.

    • SLOW_PANEL_EVENT_HANDLER_THRESHOLD_MILLIS

      private static final long SLOW_PANEL_EVENT_HANDLER_THRESHOLD_MILLIS
      The elapsed-time threshold above which a PanelMessageEvent publish is considered slow enough to warn about, in milliseconds. Set below one server tick (50ms at the nominal 20 TPS) so a subscriber costing a visible fraction of the tick budget is named before players feel it — this constant is the runtime half of D-24's mitigation; PanelMessageEvent's javadoc is the other half, stating the contract a reader sees before ever hitting this warning at runtime.
      See Also:
  • Constructor Details

    • PluginInitiationUtils

      public PluginInitiationUtils()
  • Method Details

    • currentWebSocketClient

      private static UltiPanelWebSocketClient currentWebSocketClient()
      The WebSocket client belonging to CloudSession.current(), or null if none is connected.

      Before plan 16-08 Task 2 this was a static field (panelWS) set once by initWebsocket() and read everywhere a handler needed to send a response. It is now session-owned state (D-16): a session's WebSocket client dies with the session (CloudSession.invalidate() disconnects and clears it), so "which client is current" and "which session is current" can never disagree. Every call site that used to read the bare panelWS field now calls this method instead — same read, same nullability, just sourced from the session that actually owns the client.

    • setWebSocketClientForTesting

      static UltiPanelWebSocketClient setWebSocketClientForTesting(UltiPanelWebSocketClient client)
      Test-only: installs client as CloudSession.current()'s WebSocket client and returns whatever was there before. Package-private, reached via reflection from CapabilityGateIndependenceTest (a different package, manager) -- the same idiom that test class already uses to reach initializeManagers() and onWebSocketOpened(UltiPanelWebSocketClient). Exists because CloudSession itself is package-private and cannot be named from outside utils; this method is the seam a cross-package test needs instead of reflecting into CloudSession's own field directly (which it could not even compile a reference to).
      Parameters:
      client - the client to install, or null
      Returns:
      the client that was previously installed, or null
    • resumeSavedCredentialOnStartup

      public static boolean resumeSavedCredentialOnStartup()
      Attempts to resume a previously-saved UltiCloud credential at startup: loads it from disk on the current session, and -- unless the startup login is currently rate-limited -- activates cloud features with it via loginWithToken(TokenEntity). Replaces what used to be UltiTools.attemptCloudLogin()'s own inline body; moved here (plan 16-09, D-18) because loginWithToken(TokenEntity) itself had to become package-private (it accepted a TokenEntity and was public), and UltiTools is a different package.
      Returns:
      true if a saved credential was found and successfully activated
    • startTokenRefreshScheduler

      public static void startTokenRefreshScheduler()
      Starts the token-refresh scheduler on the current session. Replaces the direct CloudAuthManager.startTokenRefreshScheduler() call UltiTools.onEnable() used to make (plan 16-09, D-17/D-18) -- CloudSession is package-private, so a different package cannot reach it by name; this is the public seam instead.
    • stopCredentialSchedulers

      public static void stopCredentialSchedulers()
      Stops the current session's token-refresh scheduler and magic-link poller. Replaces the two direct CloudAuthManager.stopTokenRefreshScheduler()/stopPolling() calls UltiTools.onDisable() used to make (plan 16-09, D-17/D-18).
    • loginWithToken

      static boolean loginWithToken(TokenEntity existingToken) throws IOException
      Login to UltiPanel using an existing token (from magic-link or saved token). Registers or updates the server without needing username/password.

      Package-private as of plan 16-09 (D-18) -- a public static method accepting a TokenEntity is exactly the static bypass D-18's structural invariant forbids. Reached from a different package only through resumeSavedCredentialOnStartup(); within this package, CloudSession.completeMagicLinkLogin calls it directly.

      Parameters:
      existingToken - the pre-authenticated token
      Returns:
      true if server registration/update succeeded
      Throws:
      IOException - if an I/O error occurs
    • initWebsocket

      public static void initWebsocket() throws IOException
      Initialize websocket on the current session.
      Throws:
      IOException
    • initWebsocket

      static void initWebsocket(CloudSession session) throws IOException
      Initialize websocket on a specific session.

      The callbacks registered below (onConnect, onReconnectExhausted) capture session lexically, not CloudSession.current() read fresh when they eventually fire -- an asynchronous handshake or a reconnect-exhaustion event must be checked against the session that actually started it, not against whichever session happens to be current when the callback runs (D-16). A logout landing in between must make this session's own gates refuse, which they will, regardless of what has replaced it as current by then.

      Parameters:
      session - the session this WebSocket client belongs to
      Throws:
      IOException
    • onWebSocketOpened

      static void onWebSocketOpened(UltiPanelWebSocketClient client)
      The wiring performed once a handshake succeeds.

      The parameter is this handshake's own client; the method body never re-reads CloudSession.current()'s client reference for the calls before initializeManagers(). onOpen is an asynchronous callback: by the time it runs, disableCloud() may already have closed and cleared the owning session's client (/ulticloud logout, or the reconnect budget being exhausted — the latter runs on the WebSocket thread), or reinitWebSocket may already have swapped it for a different instance. Re-reading the session's client reference here would make subscribeToServer / uploadConfig / uploadServerProperties all silently hit a stale or null reference — initializeManagers() itself is guarded by a lock-held re-check (now the session's own monitor), but the code around it here is not.

      Sending a message on an already-disconnected client is safe: sendMessage logs one WARNING and returns when not connected. The real danger is a null reference, so what this fixes is reference stability, not connection state.

      Package-private rather than private — only so it can be tested. Triggering it otherwise would require a real authenticated token and a real WebSocket handshake; same treatment as handleInboundMessage(com.google.gson.JsonObject).

      Compatibility overload (WR-07, 16-REVIEW-cloud.md): the real production registration (initWebsocket(CloudSession)) calls onWebSocketOpened(CloudSession, UltiPanelWebSocketClient) directly with the session it lexically captured at registration time. This single-argument overload exists only so CloudReconnectStateMachineTest#lateHandshakeDoesNotDereferenceClearedClient and CapabilityGateIndependenceTest keep reaching this exact reflected signature (16-08's own decision to preserve it) — it falls back to CloudSession.current(), which is exactly the re-read WR-07 flags as unsafe in production, but is harmless here since nothing calls this overload from a real handshake path any more.

    • onWebSocketOpened

      static void onWebSocketOpened(CloudSession session, UltiPanelWebSocketClient client)
      The real implementation behind onWebSocketOpened(UltiPanelWebSocketClient).
      Parameters:
      session - the session whose client actually connected -- captured lexically by the caller, never re-read from CloudSession.current() (WR-07)
      client - this handshake's own client (see this method's parent javadoc for why the client parameter itself must not be re-read from the session either)
    • buildInboundHandlers

      private static Map<String,PluginInitiationUtils.InboundHandlerEntry> buildInboundHandlers()
      Builds INBOUND_HANDLERS. Each entry invokes exactly the same target its former case label invoked — this method is the byte-for-byte routing record of the switch it replaces, not a redesign of it. log_stream and log_stream_control share one BiConsumer instance, preserving the fall-through the two case labels used to express.
      Returns:
      a table from message type to the PluginInitiationUtils.InboundHandlerEntry that serves it
    • resolveFileOperationCapability

      private static Capability resolveFileOperationCapability(com.google.gson.JsonObject data)
      Resolves file_operation's required capability from the message's operation field (D-09, D-10's Pitfall 4). Delegates to resolveFileOperationCapability(String).
      Parameters:
      data - the message's data object, possibly null
      Returns:
      the required capability
    • resolveUploadConfigCapability

      private static Capability resolveUploadConfigCapability(com.google.gson.JsonObject data)
      Resolves upload_config's required capability from the message's own shape (Gate-2 finding, round 11) -- upload_config carries traffic in BOTH directions on the same type (see handleConfigUpload(com.google.gson.JsonObject)'s own javadoc): uploadConfig(com.ultikits.ultitools.websocket.UltiPanelWebSocketClient) sends the server's own aggregated config UP on every reconnect, and the Worker's generic response.type = message.type echo sends the acknowledgement of that push back down this SAME inbound type. Only the genuine write-request direction needs Capability.FILE_WRITE; the acknowledgement is a protocol echo carrying no operator-facing policy and resolves to Capability.NONE, same as this class's other echo/acknowledgement entries (server_properties_result, auth_complete).

      Uses the SAME presence-only heuristic handleConfigUpload(com.google.gson.JsonObject) itself uses to recognise the acknowledgement (data.has("message")) -- deliberately the one heuristic, not two independently-maintained copies of it; see that method's own javadoc for the heuristic's documented fragility against a future second real producer.

      Parameters:
      data - the message's data object, possibly null
      Returns:
      Capability.NONE for the acknowledgement shape, Capability.FILE_WRITE otherwise
    • resolveFileOperationCapability

      public static Capability resolveFileOperationCapability(String operation)
      The single operation name to Capability mapping, shared between this class's D-10 dispatch-table resolver above and FileOperationManager's own action-log recording (D-22, Plan 06-04 Task 1) — so the two mappings cannot drift apart. list resolves to Capability.FILE_READ — listing is reading. An unrecognised or absent operation also resolves to Capability.FILE_READ, the most-permitted of the three, so an unknown verb reaching the dispatch table is still gated and still reaches handleFileOperation's own unsupported-operation branch.
      Parameters:
      operation - the operation field's value, possibly null
      Returns:
      the required capability
    • inboundDispatchTable

      static Map<String,PluginInitiationUtils.InboundHandlerEntry> inboundDispatchTable()
      Package-private accessor for INBOUND_HANDLERS, exposed only so PluginInitiationUtilsDispatchTableTest can assert the table's key set and entry identities without duplicating buildInboundHandlers()'s routing record in a second place. Not a registration point — the returned map is already unmodifiable.
      Returns:
      the unmodifiable inbound dispatch table
    • isFrameworkOwnedType

      public static boolean isFrameworkOwnedType(String messageType)
      Whether messageType is one of the framework's own INBOUND_HANDLERS entries — the single source PanelResponderRegistry.registerResponder consults before letting a module claim a message type (D-26, WIRE-16, Plan 06-08 Task 1). Deliberately a separate, narrower predicate rather than widening inboundDispatchTable()'s visibility: that accessor's own javadoc says it is test-only and not a registration point, and a boolean membership check is exactly the narrower thing a registration point actually needs — it cannot read, mutate, or iterate the table itself.

      Exact String key membership, matching INBOUND_HANDLERS's own HashMap key semantics: no case folding, no Unicode normalization.

      Parameters:
      messageType - the message type to check, possibly null
      Returns:
      true if the framework's own dispatch table already serves this exact type
    • handleInboundMessage

      static void handleInboundMessage(com.google.gson.JsonObject message)
      Handles an inbound WebSocket message dispatched by the panel.

      Extracted out of initWebsocket()'s lambda for the sole purpose of letting it be called directly by a unit test: it used to be an anonymous lambda passed to setMessageHandler, and constructing that required a real authenticated token and a real WebSocket client, so this path's malformed-input handling had no test coverage at all. See issue #234.

      Package-private rather than public — it is not a public API, only testable this way.

      Parameters:
      message - the message dispatched by the panel, may be null
    • dispatchToResponder

      private static void dispatchToResponder(String type, com.google.gson.JsonObject data, PanelResponderRegistry registry)
      Dispatches type to its registered responder and, once the registry's returned future settles, sends exactly one reply through the same client accessor the other outbound helpers use — panelWS.sendMessage(...), matching sendCapabilityRefusal's and FileOperationManager#sendFileOperationResult's existing pattern (WIRE-16, D-27).

      No Bukkit main-thread hop here, deliberately: unlike PanelMessageEvent's publish (which must run on the main thread because a subscriber may touch Bukkit API), sending a reply over the WebSocket client is plain network I/O — the same off-main-thread pattern CommandExecutionManager/FileOperationManager already use for their own outbound replies.

      The reply always carries the message type and the request's requestId (echoed from data), and either the responder's resolved JsonObject or an error member naming the failure — PanelResponderRegistry.dispatch(java.lang.String, com.google.gson.JsonObject, java.lang.String) guarantees its returned future always settles one way or the other, so this method never has to guess. When the request carried no requestId, the reply is logged rather than sent: the panel has no way to correlate an uncorrelated reply, matching sendCapabilityRefusal(java.lang.String, com.google.gson.JsonObject, com.ultikits.ultitools.entities.Capability)'s same reasoning for commandId/operationId.

      Parameters:
      type - the message type, already confirmed to have a registered responder
      data - the message's data object, possibly null
      registry - the registry to dispatch through
    • rootCauseMessage

      private static String rootCauseMessage(Throwable throwable)
      The deepest non-null message on throwable's cause chain, falling back to the throwable's own class name when every message is null — a bare NullPointerException carries no message at all, and an empty error field would tell the panel operator nothing.
      Parameters:
      throwable - the throwable to describe
      Returns:
      a human-readable description, never null
    • publishPanelMessageEvent

      private static void publishPanelMessageEvent(String type, com.google.gson.JsonObject message, com.google.gson.JsonObject data)
      Bridges an inbound panel message the framework has already handled onto the module-facing EventBus (WIRE-16). This is the single publish site — see the dispatch-table call site in handleInboundMessage(com.google.gson.JsonObject) for the only place this is invoked.

      EventBus.publishAsync(com.ultikits.ultitools.events.ModuleEvent) was considered and rejected: it submits to an async worker pool and never reaches the main thread, so it does not address Paper's AsyncCatcher at all — it only keeps the WebSocket I/O thread unblocked. A Minecraft module's handler touches Bukkit API by definition, so the real choice here was main-thread versus not-main-thread, not sync-dispatch versus async-dispatch; only Bukkit.getScheduler().runTask(...) puts a handler on the main thread. The whole helper body is wrapped in a catch so a missing scheduler (no Bukkit server booted, as in a plain unit test) or a missing EventBus can never break the inbound message path — both are logged no-ops.

      Parameters:
      type - the resolved message type
      message - the full inbound envelope
      data - the message's data object, possibly null
    • dispatchWithCapabilityGate

      private static boolean dispatchWithCapabilityGate(String type, com.google.gson.JsonObject message, com.google.gson.JsonObject data, PluginInitiationUtils.InboundHandlerEntry entry)
      The single enforcement point for every inbound capability (D-10). Resolves the entry's required capability against data; Capability.NONE runs the handler with no check and no action-log entry. Otherwise: enabled runs the handler and, unless the entry's own handler already records its verdict (PluginInitiationUtils.InboundHandlerEntry.recordsOwnVerdict(), CR-01), records one RemoteActionLog.Verdict.ALLOWED entry; disabled sends one capability_denied reply and records one RemoteActionLog.Verdict.DENIED entry — the handler is never invoked on the denied path, so there is only ever one writer there.

      execute_command and file_operation are the two entries whose handler performs its own, finer-grained AccessDecision check and records its own verdict from that check — recording a second, blanket ALLOWED entry here for those two would produce a contradictory second log line (see 06-REVIEW.md CR-01) for every blocklisted command and every credential/out-of-root file request.

      Parameters:
      type - the message type, used for the action-log action and the refusal payload
      message - the full inbound message
      data - the message's data object, possibly null
      entry - the dispatch-table entry that serves this type
      Returns:
      whether the message should also reach PanelMessageEvent subscribers — true for Capability.NONE and for an enabled capability, false for a denied one. The caller carries this straight into the publish decision so the gate and the publish can never disagree (see handleInboundMessage(com.google.gson.JsonObject)).
    • sendCapabilityRefusal

      private static void sendCapabilityRefusal(String type, com.google.gson.JsonObject data, Capability capability)
      Sends one capability_denied outbound message naming the config key, the config file, the refusal reason, and echoing whichever correlation id the inbound message carried (commandId, operationId or requestId) so the panel can correlate the refusal with the request that caused it. Not reusing the existing error message type — see handleInboundMessage(com.google.gson.JsonObject)'s own comment on why unsolicited error replies are avoided on this path. A logged no-op when no client is connected.
      Parameters:
      type - the inbound message type that was refused
      data - the message's data object, possibly null
      capability - the capability that refused it
    • copyIfPresent

      private static void copyIfPresent(com.google.gson.JsonObject source, com.google.gson.JsonObject target, String field)
      Copies field from source to target only when present and non-null.
    • recordAction

      private static void recordAction(Capability capability, String type, com.google.gson.JsonObject data, RemoteActionLog.Verdict verdict, String reason)
      Records one action-log entry for a capability-gated inbound message. A null UltiTools.getInstance().getRemoteActionLog() is a silent no-op — the existing inbound-message tests mock UltiTools and return null for it.
    • resolveActionLogAction

      private static String resolveActionLogAction(String type, com.google.gson.JsonObject data)
      The action-log action field — the message type, extended with the resolved sub-operation for file_operation.
    • resolveActionLogTarget

      private static String resolveActionLogTarget(String type, com.google.gson.JsonObject data)
      The action-log target field — the command text, file path, or message type otherwise.
    • resolveActor

      private static String resolveActor(com.google.gson.JsonObject data)
      The action-log actor field — the inbound executor field verbatim, or the literal "panel" when absent. The framework cannot attribute a remote command to an individual panel operator today (see RemoteActionLog.Entry's javadoc), so this never invents a per-operator identity.
    • initializeManagers

      static void initializeManagers()
      Wires all WebSocket managers up to the current connection, on CloudSession.current().

      This method hangs off onConnectHandler, and an in-flight handshake can still land after /ulticloud logout. Without a guard, the listeners disableCloud() just tore down would be reinstalled verbatim by this late-arriving onOpen — the exact same "no one owns the decision" defect from #181/#223, resurfacing in a different place.

      As of 16-08 Task 2 the actual wiring and its re-check-while-locked guard both live on CloudSession itself (its own intrinsic lock replaces the former global the former global lifecycle lock — see CloudSession.initializeManagers()). This method is now a one-line delegator, kept package-private (not private) only so it can still be reached by name via reflection from tests in other packages (see the two review rounds on PR #264 for why the guard exists at all).

    • logSkippedCapability

      static void logSkippedCapability(Capability capability)
      Logs one INFO entry for a skipped outbound capability, naming which capability and which config key caused the skip.

      This log line matters especially for Capability.MONITORING: sendBatchUpdate firing every 5 seconds is the panel's sole basis for deciding "is the server online" — turning monitoring off makes an upgraded server show as offline on the panel, which is the worst shape a failure can take, because the symptom points operators in the wrong direction (they go check the network and the token, not the config). D-08 already set monitoring's out-of-the-box default to enabled as the first layer of mitigation; this log line is the second.

      Package-private (not private) as of 16-08 Task 2 -- CloudSession's own wireManagers() calls this too, now that the wiring logic lives there.

      Parameters:
      capability - the capability that was skipped
    • handleConfigUpdate

      static void handleConfigUpdate(com.google.gson.JsonObject data)
      Handles a config update.

      This path used to disagree with the panel in three places, and each failure was silent (issue #236): the panel sends content in data.configData, and this read data.config; the panel names the file with data.fileName, and this never read it except for server_properties; the panel does not send requestId, and this used whether requestId was present as the test for "is this a request" — when it was missing, the code just logged one Level.FINE line and dropped the message, and FINE does not print under the default log configuration. So the panel got an HTTP 200, nothing changed on the server, and neither side reported an error.

      The test is now "is there config content": no content means this is an echo/acknowledgement, content means it is a request, and a missing requestId is still applied — it just logs a WARNING explaining that the result cannot be reported back.

      Package-private rather than private — only so it can be tested.

    • readString

      private static String readString(com.google.gson.JsonObject data, String field)
      Reads a string field that may be missing, or may be a JSON null.
    • readConfigContent

      private static String readConfigContent(com.google.gson.JsonObject data)
      Reads the config content, preferring data.configData.

      data.config used to be the only field this method read, but no producer of it can be found anywhere in the tree — the panel has always sent configData. It is kept only for compatibility with a possible third-party panel; reading it logs a deprecation warning.

    • applyConfigUpdate

      private static void applyConfigUpdate(String fileName, String configContent) throws IOException
      Decides where to write based on fileName.

      The three branches correspond to three payload shapes — exactly what the former "fileName is never read" covered up: server_properties is a flat property table handed to its own dedicated manager; a named file is that one config file's own {key: value} map; and no file name at all means the full nested structure from ConfigManager.toJson().

      Throws:
      IOException
    • sendConfigUpdateResponse

      private static void sendConfigUpdateResponse(String requestId, boolean success, String error)
      Sends back one config_update_response.

      The payload sits in data, matching every other plugin-to-Worker message (see CommandExecutionManager.sendCommandResult). This one used to be a flat write with fields hanging directly off the top level; the Worker side reads both shapes (ultipanel-api-worker#30), so this change did not need to ship simultaneously with the panel.

    • handlePing

      private static void handlePing(com.google.gson.JsonObject message)
      Handles a ping message
    • handlePong

      private static void handlePong(com.google.gson.JsonObject data)
      Handles a pong message
    • handleSubscribe

      private static void handleSubscribe(com.google.gson.JsonObject data)
      Handles a subscribe message
    • handleUnsubscribe

      private static void handleUnsubscribe(com.google.gson.JsonObject data)
      Handles an unsubscribe message
    • handleNotification

      private static void handleNotification(com.google.gson.JsonObject data)
      Handles a notification message
    • handleError

      private static void handleError(com.google.gson.JsonObject data)
      Handles an error message
    • handlePlayerEvent

      private static void handlePlayerEvent(com.google.gson.JsonObject data)
      Handles a player event
    • handleCommandResult

      private static void handleCommandResult(com.google.gson.JsonObject data)
      Handles a command execution result
    • handleFileOperationResult

      private static void handleFileOperationResult(com.google.gson.JsonObject data)
      Handles a file operation result
    • handleBackupOperation

      private static void handleBackupOperation(com.google.gson.JsonObject data)
      Handles a backup operation
    • handleBackupProgress

      private static void handleBackupProgress(com.google.gson.JsonObject data)
      Handles backup progress
    • handleConfigUpload

      private static void handleConfigUpload(com.google.gson.JsonObject data)
      Handles a config upload.

      upload_config carries traffic in both directions on the same message type: uploadConfig(UltiPanelWebSocketClient) sends the server's own aggregated config up on every reconnect, and the Worker's own message handler echoes that request's type straight back as its acknowledgement (websocket-server.ts:454, response.type = message.type) — so the acknowledgement lands right back in this same inbound handler. Its payload is always {message, serverId, configType}, distinguishable only by the message field; recognize it there and stop (issue #359). The former data.has("requestId") gate is deleted rather than fixed — nothing anywhere in the system (panel, frontend, or Worker route) ever populates that field on an upload_config payload, so no code path here reads it any more.

    • handleConfigUploadLogic

      private static void handleConfigUploadLogic(com.google.gson.JsonObject data) throws Exception
      Handles the config upload logic.

      upload_config accepts plugin_config only (#435, D-13). server_properties is rejected naming the message that actually handles it — the dedicated server_properties message routed to ServerPropertiesManager.handleServerProperties(com.google.gson.JsonObject). permissions is defined nowhere in the system, so it is not given a meaning here — it falls to the pre-existing fail-closed default, whose wording already says exactly what a dedicated branch would.

      Throws:
      Exception
    • sendErrorResponse

      private static void sendErrorResponse(String errorMessage)
      Sends an error response
    • handlePluginListRequest

      private static void handlePluginListRequest(com.google.gson.JsonObject data)
      Handles a plugin list request
    • handleServerStatusRequest

      private static void handleServerStatusRequest(com.google.gson.JsonObject data)
      Handles a server status request
    • handleMetricsRequest

      private static void handleMetricsRequest(com.google.gson.JsonObject data)
      Handles a metrics data request
    • uploadConfig

      private static void uploadConfig(UltiPanelWebSocketClient client)
      Uploads the local config to the server
    • uploadServerProperties

      private static void uploadServerProperties(UltiPanelWebSocketClient client)
      Upload server.properties safe keys to cloud for panel editing.
    • reinitWebSocket

      public static void reinitWebSocket()
      Re-initialize the WebSocket connection on the current session, with a fresh token if needed.
    • reinitWebSocket

      static void reinitWebSocket(CloudSession session)
      Re-initialize the WebSocket connection with a fresh token, on a specific session. Disconnects the old client (if any), refreshes the token if needed, and creates a new WebSocket client -- all against session, never against whatever CloudSession.current() happens to be when this runs (D-16). The reconnect-exhausted handler that invokes this captures its own session lexically at registration time (see initWebsocket(CloudSession)); the no-arg reinitWebSocket() overload above is a convenience for callers that are always operating on the current session (tests, mostly), where the two coincide.
      Parameters:
      session - the session whose WebSocket client is being re-initialized
    • disableCloud

      public static void disableCloud()
      Closes the cloud connection and invalidates the current session, putting the reconnection state machine into an explicit disabled state.

      Called by /ulticloud logout. The difference from stopWebsocket() is that the latter only disconnects the current client, and the reconnection chain will bring it back up; this method invalidates CloudSession.current() first, so reinitWebSocket(CloudSession) returns immediately afterward and the state machine does not resurrect itself.

      Also strips the log handler and the transport thread off the root logger (as of plan 16-10, WR-01, this now happens INSIDE CloudSession.invalidate() rather than as a separate, unlocked step before it -- see that method's own javadoc), and stops the server monitor and the player-event listener — all part of what makes the statement "cloud features are disabled" true. As of 16-08 Task 2 this no longer needs its own the former global lifecycle lock: CloudSession.invalidate() and CloudSession.initializeManagers() both synchronize on the session instance itself, so wiring and teardown are mutually exclusive on that lock without a separate global one. See the two review rounds on PR #264 for the original race this replaces.

      CR-01 (16-REVIEW-cloud.md): disableCloud(CloudSession) -- the overload internal callers use -- returns the exact session that overload tore down, captured by the caller before any teardown step runs. CloudAuthManager.logout() reads its disk-clear decision off that return value rather than a second, independent call to CloudSession.current() taken after teardown returns -- eliminating the specific race where a concurrent login() installs a new session in the gap between those two reads (see CloudAuthManager#logout()'s own javadoc for the full account). This particular overload keeps its original void return -- disableCloud() was public in the 6.2.5 release, and changing a released public method's return type is a genuine binary incompatibility japicmp correctly rejects; there is no same-release exception available for a signature that already shipped.

    • disableCloud

      static CloudSession disableCloud(CloudSession session)
      The session-targeted, CloudSession-returning form of disableCloud() (CR-01/WR-02, 16-REVIEW-cloud.md) -- package-private, since it is new-in-6.3.0 and reached only by other members of this package (CloudAuthManager#logout(), reinitWebSocket(CloudSession)'s budget-exhaustion branch).

      reinitWebSocket(CloudSession)'s budget-exhaustion branch calls this on the specific session whose backoff ran out, rather than the no-arg disableCloud() (which always targets whatever CloudSession.current() happens to be at the moment it runs). A fresh login racing in between that branch's own currency check and this call would otherwise let a stale exhaustion event for an old session tear down an unrelated, brand-new one. Likewise, CloudAuthManager#logout() calls this with CloudSession.current() captured at its own call site -- immediately before teardown begins -- and acts on the returned reference afterward rather than re-reading CloudSession.current() a second, independent time.

      Parameters:
      session - the session to invalidate and tear down
      Returns:
      session, unchanged -- returned so the caller can act on exactly what was torn down without a second read of CloudSession.current()
    • doDisableCloud

      private static CloudSession doDisableCloud(CloudSession session)
      The actual teardown performed by disableCloud() / disableCloud(CloudSession).

      session is the caller's own choice of target, captured before this method does anything else -- CloudSession.invalidate() below acts on exactly this reference, never a fresh read of CloudSession.current() taken partway through teardown (CR-01).

      Round-1 external review finding, second pass (16-10, PR #464): the two steps below act on GLOBAL, session-independent singletons (exactly one server monitor and one player-event manager for the whole plugin, not one per session) -- but "session-independent" does not mean "safe to run unconditionally". If session has already been superseded by a newer one by the time this runs (the same narrow window WR-02 already accounts for on the invalidation half), and that newer session's own handshake has already wired these same managers up, an unconditional stop here would tear down wiring that belongs to the session that is actually current now -- with nothing left to ever restart it, since no further handshake is coming. Gating both steps on session still being current closes that: a stale teardown call for an already-superseded session leaves whatever the current session has wired alone.

      Parameters:
      session - the session to invalidate
      Returns:
      session
    • teardownStep

      private static void teardownStep(String what, Runnable action)
      Runs one teardown step; a failure is only logged at FINE, never thrown out.

      Every teardown step must run to the best of its ability: any step throwing would skip every step after it, and those steps are exactly what makes the statement "cloud features are disabled" true. This used to be six identical try/catch blocks; extracting it only states that invariant once — the behaviour is unchanged.

      Parameters:
      what - the action description written to the log on failure
      action - the teardown action
    • onWebSocketConnected

      static void onWebSocketConnected()
      Compatibility overload (WR-07, 16-REVIEW-cloud.md): falls back to CloudSession.current(), preserved only because CloudReconnectStateMachineTest calls this exact no-arg signature directly to exercise the budget-reset behaviour in isolation. No production handshake path calls this overload any more -- onWebSocketOpened(CloudSession, UltiPanelWebSocketClient) calls onWebSocketConnected(CloudSession) with its own captured session instead.
    • onWebSocketConnected

      static void onWebSocketConnected(CloudSession session)
      Called when a reconnection succeeds: resets session's own outer budget.

      Only a genuinely successful handshake is entitled to reset the budget. Resetting it inside reinitWebSocket instead would treat "a client was built" as success, the budget would never run out, and the gate would amount to nothing added.

      WR-07 (16-REVIEW-cloud.md): takes the session explicitly rather than re-reading CloudSession.current() -- every other callback registered by initWebsocket(CloudSession) captures its session lexically at registration time specifically so a late-firing callback acts upon the session that started it, not whichever session happens to be current by the time the callback fires. This method used to be the one place in this diff that broke that discipline.

      Parameters:
      session - the session whose handshake actually succeeded
    • activateCloudIfCurrent

      static boolean activateCloudIfCurrent(CloudSession session) throws IOException
      Atomically, inside the session's own lock: "re-check that session is still current → connect → start the refresh schedule."

      Making only the credential-write step atomic against logout is not enough: after committing a credential, magic-link polling still has to do initWebsocket() + session.startTokenRefreshScheduler(), and that sequence is what actually connects the server back. If logout lands in the gap between "commit succeeded" and "activation started," teardown tears down a connection that has not been built yet, and the polling thread goes ahead and builds it anyway — undoing the logout.

      This method synchronizes on session itself, the same monitor CloudSession.invalidate() uses, so the two can only ever happen as a whole, one after the other: either activation completes first and is then torn down (clean), or teardown happens first and this method, re-checking session's own currency while holding the lock, sees it is no longer current and returns false directly (also clean). Checking session.isCurrent() rather than comparing against a shared counter is exactly what makes this re-check ordering-independent (D-16/D-18): the session this call was handed either still is what it was, or it is not, regardless of how many other sessions have come and gone in between. As of 16-08 Task 2 there is no separate "enable" step: a session that passes this check is, by definition, the thing "enabled" now means.

      Deliberately does not call loginWithToken() while holding the lock — that is an HTTP round trip, and doing it under the lock would block /ulticloud logout on the main thread for several seconds. It only registers the server with the panel and does not change local state, so running it again outside the lock is harmless.

      Parameters:
      session - the session the caller recorded when this login/activation started
      Returns:
      true if activated; false if the session had already been invalidated and activation was abandoned
      Throws:
      IOException - if establishing the connection fails
    • enableCloud

      public static void enableCloud()
      Ensures the current session is usable, replacing it with a fresh one if it has been invalidated.

      Only an explicit action should call this: cloud login at server startup, and after a successful /ulticloud login. initWebsocket() deliberately does not call it — it is also reused by reinitWebSocket(), and calling it there would let an in-flight reconnection resurrect a state machine that had just been turned off by logout.

      As of 16-08 Task 2 there is no separate enabled flag to flip: "enabled" simply means CloudSession.current() is not invalidated. If it already is not (the common case -- most callers reach this with a perfectly good session already installed), the session itself is not replaced; doing so for no reason would gratuitously drop its token and its WebSocket client. Only a session that CloudSession.invalidate() already ran on (typically disableCloud(), without a following /ulticloud login) gets replaced.

      16-10 gap-closure addendum (round-12 review, PR #464): CloudSession.getBackoff() is reset unconditionally below, regardless of whether the session itself needed replacing -- unlike the token and the WebSocket client, reconnect-attempt history is NOT worth preserving across a call to this method. Both of this method's own documented call sites -- cloud login at server startup, and after a successful /ulticloud login -- are, by definition, a fresh start of the connection-attempt cycle, matching the pre-6.3.0 global backoff's own behaviour (every enableCloud() call reset it). Without this, UltiTools#onDisable() stops the current session's schedulers and WebSocket client but never invalidates the session itself (a deliberate choice -- a full teardown at JVM shutdown is pointless work), so a plugin disabled and re-enabled in the same classloader (e.g. /reload) resumes with whatever backoff attempt count it had accumulated before the disable, exhausting its retry budget prematurely instead of getting the fresh budget a restart implies.

    • isCloudEnabled

      static boolean isCloudEnabled()
      Lets a test assert whether the state machine is currently enabled -- i.e. whether the current session is still current.
    • stopWebsocket

      public static void stopWebsocket()
      Disconnects the current session's WebSocket client, if any, without invalidating the session or touching the reconnection state machine -- the reconnect chain will bring the connection back up on its own. Used by UltiTools.onDisable(), where a full disableCloud() teardown is unnecessary (the JVM is going away regardless).
    • getPanelWebsocketClient

      private static UltiPanelWebSocketClient getPanelWebsocketClient(TokenEntity token) throws IOException
      Throws:
      IOException
    • safeGetString

      private static String safeGetString(com.google.gson.JsonObject obj, String key)
    • safeGetBoolean

      private static boolean safeGetBoolean(com.google.gson.JsonObject obj, String key, boolean defaultValue)
    • safeGetLong

      private static long safeGetLong(com.google.gson.JsonObject obj, String key, long defaultValue)
    • safeGetDouble

      private static double safeGetDouble(com.google.gson.JsonObject obj, String key, double defaultValue)