Class PluginInitiationUtils
- Since:
- 6.0.0
- Author:
- wisdomme
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescription(package private) static final classA dispatch-table entry pairing a handler with theCapabilitythat must be enabled before it runs (D-10), and with which side records that decision's verdict in theRemoteActionLog(CR-01, 06-REVIEW.md).(package private) static enumWhich side of a capability-gated dispatch records theRemoteActionLogverdict for the enabled branch (CR-01, 06-REVIEW.md). -
Field Summary
FieldsModifier and TypeFieldDescriptionprivate static final Map<String, PluginInitiationUtils.InboundHandlerEntry> The inbound-message dispatch table: messagetypestring to thePluginInitiationUtils.InboundHandlerEntrythat serves it.private static final Stringserver_propertiesis handled by its own dedicated manager — it is not a real config file path.private static final longThe elapsed-time threshold above which aPanelMessageEventpublish is considered slow enough to warn about, in milliseconds. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescription(package private) static booleanactivateCloudIfCurrent(CloudSession session) Atomically, inside the session's own lock: "re-check thatsessionis still current → connect → start the refresh schedule."private static voidapplyConfigUpdate(String fileName, String configContent) Decides where to write based onfileName.private static Map<String, PluginInitiationUtils.InboundHandlerEntry> BuildsINBOUND_HANDLERS.private static voidcopyIfPresent(com.google.gson.JsonObject source, com.google.gson.JsonObject target, String field) Copiesfieldfromsourcetotargetonly when present and non-null.private static UltiPanelWebSocketClientThe WebSocket client belonging toCloudSession.current(), ornullif none is connected.static voidCloses the cloud connection and invalidates the current session, putting the reconnection state machine into an explicit disabled state.(package private) static CloudSessiondisableCloud(CloudSession session) Thesession-targeted,CloudSession-returning form ofdisableCloud()(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).private static voiddispatchToResponder(String type, com.google.gson.JsonObject data, PanelResponderRegistry registry) Dispatchestypeto 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(...), matchingsendCapabilityRefusal's andFileOperationManager#sendFileOperationResult's existing pattern (WIRE-16, D-27).private static booleandispatchWithCapabilityGate(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).private static CloudSessiondoDisableCloud(CloudSession session) The actual teardown performed bydisableCloud()/disableCloud(CloudSession).static voidEnsures the current session is usable, replacing it with a fresh one if it has been invalidated.private static UltiPanelWebSocketClientprivate static voidhandleBackupOperation(com.google.gson.JsonObject data) Handles a backup operationprivate static voidhandleBackupProgress(com.google.gson.JsonObject data) Handles backup progressprivate static voidhandleCommandResult(com.google.gson.JsonObject data) Handles a command execution result(package private) static voidhandleConfigUpdate(com.google.gson.JsonObject data) Handles a config update.private static voidhandleConfigUpload(com.google.gson.JsonObject data) Handles a config upload.private static voidhandleConfigUploadLogic(com.google.gson.JsonObject data) Handles the config upload logic.private static voidhandleError(com.google.gson.JsonObject data) Handles an error messageprivate static voidhandleFileOperationResult(com.google.gson.JsonObject data) Handles a file operation result(package private) static voidhandleInboundMessage(com.google.gson.JsonObject message) Handles an inbound WebSocket message dispatched by the panel.private static voidhandleMetricsRequest(com.google.gson.JsonObject data) Handles a metrics data requestprivate static voidhandleNotification(com.google.gson.JsonObject data) Handles a notification messageprivate static voidhandlePing(com.google.gson.JsonObject message) Handles a ping messageprivate static voidhandlePlayerEvent(com.google.gson.JsonObject data) Handles a player eventprivate static voidhandlePluginListRequest(com.google.gson.JsonObject data) Handles a plugin list requestprivate static voidhandlePong(com.google.gson.JsonObject data) Handles a pong messageprivate static voidhandleServerStatusRequest(com.google.gson.JsonObject data) Handles a server status requestprivate static voidhandleSubscribe(com.google.gson.JsonObject data) Handles a subscribe messageprivate static voidhandleUnsubscribe(com.google.gson.JsonObject data) Handles an unsubscribe message(package private) static Map<String, PluginInitiationUtils.InboundHandlerEntry> Package-private accessor forINBOUND_HANDLERS, exposed only soPluginInitiationUtilsDispatchTableTestcan assert the table's key set and entry identities without duplicatingbuildInboundHandlers()'s routing record in a second place.(package private) static voidWires all WebSocket managers up to the current connection, onCloudSession.current().static voidInitialize websocket on the current session.(package private) static voidinitWebsocket(CloudSession session) Initialize websocket on a specific session.(package private) static booleanLets a test assert whether the state machine is currently enabled -- i.e.static booleanisFrameworkOwnedType(String messageType) WhethermessageTypeis one of the framework's ownINBOUND_HANDLERSentries — the single sourcePanelResponderRegistry.registerResponderconsults before letting a module claim a message type (D-26, WIRE-16, Plan 06-08 Task 1).(package private) static booleanloginWithToken(TokenEntity existingToken) Login to UltiPanel using an existing token (from magic-link or saved token).(package private) static voidlogSkippedCapability(Capability capability) Logs one INFO entry for a skipped outbound capability, naming which capability and which config key caused the skip.(package private) static voidCompatibility overload (WR-07, 16-REVIEW-cloud.md): falls back toCloudSession.current(), preserved only becauseCloudReconnectStateMachineTestcalls this exact no-arg signature directly to exercise the budget-reset behaviour in isolation.(package private) static voidonWebSocketConnected(CloudSession session) Called when a reconnection succeeds: resetssession's own outer budget.(package private) static voidonWebSocketOpened(CloudSession session, UltiPanelWebSocketClient client) The real implementation behindonWebSocketOpened(UltiPanelWebSocketClient).(package private) static voidThe wiring performed once a handshake succeeds.private static voidpublishPanelMessageEvent(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-facingEventBus(WIRE-16).private static StringreadConfigContent(com.google.gson.JsonObject data) Reads the config content, preferringdata.configData.private static StringreadString(com.google.gson.JsonObject data, String field) Reads a string field that may be missing, or may be a JSON null.private static voidrecordAction(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.static voidRe-initialize the WebSocket connection on the current session, with a fresh token if needed.(package private) static voidreinitWebSocket(CloudSession session) Re-initialize the WebSocket connection with a fresh token, on a specific session.private static StringresolveActionLogAction(String type, com.google.gson.JsonObject data) The action-logactionfield — the message type, extended with the resolved sub-operation forfile_operation.private static StringresolveActionLogTarget(String type, com.google.gson.JsonObject data) The action-logtargetfield — the command text, file path, or message type otherwise.private static StringresolveActor(com.google.gson.JsonObject data) The action-logactorfield — the inboundexecutorfield verbatim, or the literal"panel"when absent.private static CapabilityresolveFileOperationCapability(com.google.gson.JsonObject data) Resolvesfile_operation's required capability from the message'soperationfield (D-09, D-10's Pitfall 4).static CapabilityresolveFileOperationCapability(String operation) The singleoperationname toCapabilitymapping, shared between this class's D-10 dispatch-table resolver above andFileOperationManager's own action-log recording (D-22, Plan 06-04 Task 1) — so the two mappings cannot drift apart.private static CapabilityresolveUploadConfigCapability(com.google.gson.JsonObject data) Resolvesupload_config's required capability from the message's own shape (Gate-2 finding, round 11) --upload_configcarries traffic in BOTH directions on the same type (seehandleConfigUpload(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 genericresponse.type = message.typeecho sends the acknowledgement of that push back down this SAME inbound type.static booleanAttempts 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 vialoginWithToken(TokenEntity).private static StringrootCauseMessage(Throwable throwable) The deepest non-null message onthrowable's cause chain, falling back to the throwable's own class name when every message isnull— a bareNullPointerExceptioncarries no message at all, and an emptyerrorfield would tell the panel operator nothing.private static booleansafeGetBoolean(com.google.gson.JsonObject obj, String key, boolean defaultValue) private static doublesafeGetDouble(com.google.gson.JsonObject obj, String key, double defaultValue) private static longsafeGetLong(com.google.gson.JsonObject obj, String key, long defaultValue) private static StringsafeGetString(com.google.gson.JsonObject obj, String key) private static voidsendCapabilityRefusal(String type, com.google.gson.JsonObject data, Capability capability) Sends onecapability_deniedoutbound message naming the config key, the config file, the refusal reason, and echoing whichever correlation id the inbound message carried (commandId,operationIdorrequestId) so the panel can correlate the refusal with the request that caused it.private static voidsendConfigUpdateResponse(String requestId, boolean success, String error) Sends back oneconfig_update_response.private static voidsendErrorResponse(String errorMessage) Sends an error response(package private) static UltiPanelWebSocketClientTest-only: installsclientasCloudSession.current()'s WebSocket client and returns whatever was there before.static voidStarts the token-refresh scheduler on the current session.static voidStops the current session's token-refresh scheduler and magic-link poller.static voidDisconnects 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.private static voidteardownStep(String what, Runnable action) Runs one teardown step; a failure is only logged at FINE, never thrown out.private static voiduploadConfig(UltiPanelWebSocketClient client) Uploads the local config to the serverprivate static voidUpload server.properties safe keys to cloud for panel editing.
-
Field Details
-
SERVER_PROPERTIES_FILE
server_propertiesis handled by its own dedicated manager — it is not a real config file path.- See Also:
-
INBOUND_HANDLERS
The inbound-message dispatch table: messagetypestring to thePluginInitiationUtils.InboundHandlerEntrythat serves it.Replaces what used to be a 24-case
switchinsidehandleInboundMessage(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 throughhandleInboundMessage(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 — seebuildInboundHandlers().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_MILLISThe elapsed-time threshold above which aPanelMessageEventpublish 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
The WebSocket client belonging toCloudSession.current(), ornullif none is connected.Before plan 16-08 Task 2 this was a static field (
panelWS) set once byinitWebsocket()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 barepanelWSfield now calls this method instead — same read, same nullability, just sourced from the session that actually owns the client. -
setWebSocketClientForTesting
Test-only: installsclientasCloudSession.current()'s WebSocket client and returns whatever was there before. Package-private, reached via reflection fromCapabilityGateIndependenceTest(a different package,manager) -- the same idiom that test class already uses to reachinitializeManagers()andonWebSocketOpened(UltiPanelWebSocketClient). Exists becauseCloudSessionitself is package-private and cannot be named from outsideutils; this method is the seam a cross-package test needs instead of reflecting intoCloudSession's own field directly (which it could not even compile a reference to).- Parameters:
client- the client to install, ornull- 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 vialoginWithToken(TokenEntity). Replaces what used to beUltiTools.attemptCloudLogin()'s own inline body; moved here (plan 16-09, D-18) becauseloginWithToken(TokenEntity)itself had to become package-private (it accepted aTokenEntityand was public), andUltiToolsis a different package.- Returns:
trueif a saved credential was found and successfully activated
-
startTokenRefreshScheduler
public static void startTokenRefreshScheduler()Starts the token-refresh scheduler on the current session. Replaces the directCloudAuthManager.startTokenRefreshScheduler()callUltiTools.onEnable()used to make (plan 16-09, D-17/D-18) --CloudSessionis 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 directCloudAuthManager.stopTokenRefreshScheduler()/stopPolling()callsUltiTools.onDisable()used to make (plan 16-09, D-17/D-18). -
loginWithToken
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
TokenEntityis exactly the static bypass D-18's structural invariant forbids. Reached from a different package only throughresumeSavedCredentialOnStartup(); within this package,CloudSession.completeMagicLinkLogincalls it directly.- Parameters:
existingToken- the pre-authenticated token- Returns:
- true if server registration/update succeeded
- Throws:
IOException- if an I/O error occurs
-
initWebsocket
Initialize websocket on the current session.- Throws:
IOException
-
initWebsocket
Initialize websocket on a specific session.The callbacks registered below (onConnect, onReconnectExhausted) capture
sessionlexically, notCloudSession.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
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 beforeinitializeManagers(). 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), orreinitWebSocketmay already have swapped it for a different instance. Re-reading the session's client reference here would makesubscribeToServer/uploadConfig/uploadServerPropertiesall 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:
sendMessagelogs 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)) callsonWebSocketOpened(CloudSession, UltiPanelWebSocketClient)directly with the session it lexically captured at registration time. This single-argument overload exists only soCloudReconnectStateMachineTest#lateHandshakeDoesNotDereferenceClearedClientandCapabilityGateIndependenceTestkeep reaching this exact reflected signature (16-08's own decision to preserve it) — it falls back toCloudSession.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
The real implementation behindonWebSocketOpened(UltiPanelWebSocketClient).- Parameters:
session- the session whose client actually connected -- captured lexically by the caller, never re-read fromCloudSession.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
BuildsINBOUND_HANDLERS. Each entry invokes exactly the same target its formercaselabel invoked — this method is the byte-for-byte routing record of the switch it replaces, not a redesign of it.log_streamandlog_stream_controlshare oneBiConsumerinstance, preserving the fall-through the two case labels used to express.- Returns:
- a table from message
typeto thePluginInitiationUtils.InboundHandlerEntrythat serves it
-
resolveFileOperationCapability
Resolvesfile_operation's required capability from the message'soperationfield (D-09, D-10's Pitfall 4). Delegates toresolveFileOperationCapability(String).- Parameters:
data- the message'sdataobject, possiblynull- Returns:
- the required capability
-
resolveUploadConfigCapability
Resolvesupload_config's required capability from the message's own shape (Gate-2 finding, round 11) --upload_configcarries traffic in BOTH directions on the same type (seehandleConfigUpload(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 genericresponse.type = message.typeecho sends the acknowledgement of that push back down this SAME inbound type. Only the genuine write-request direction needsCapability.FILE_WRITE; the acknowledgement is a protocol echo carrying no operator-facing policy and resolves toCapability.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'sdataobject, possiblynull- Returns:
Capability.NONEfor the acknowledgement shape,Capability.FILE_WRITEotherwise
-
resolveFileOperationCapability
The singleoperationname toCapabilitymapping, shared between this class's D-10 dispatch-table resolver above andFileOperationManager's own action-log recording (D-22, Plan 06-04 Task 1) — so the two mappings cannot drift apart.listresolves toCapability.FILE_READ— listing is reading. An unrecognised or absent operation also resolves toCapability.FILE_READ, the most-permitted of the three, so an unknown verb reaching the dispatch table is still gated and still reacheshandleFileOperation's own unsupported-operation branch.- Parameters:
operation- theoperationfield's value, possiblynull- Returns:
- the required capability
-
inboundDispatchTable
Package-private accessor forINBOUND_HANDLERS, exposed only soPluginInitiationUtilsDispatchTableTestcan assert the table's key set and entry identities without duplicatingbuildInboundHandlers()'s routing record in a second place. Not a registration point — the returned map is already unmodifiable.- Returns:
- the unmodifiable inbound dispatch table
-
isFrameworkOwnedType
WhethermessageTypeis one of the framework's ownINBOUND_HANDLERSentries — the single sourcePanelResponderRegistry.registerResponderconsults before letting a module claim a message type (D-26, WIRE-16, Plan 06-08 Task 1). Deliberately a separate, narrower predicate rather than wideninginboundDispatchTable()'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
Stringkey membership, matchingINBOUND_HANDLERS's ownHashMapkey semantics: no case folding, no Unicode normalization.- Parameters:
messageType- the message type to check, possiblynull- Returns:
trueif 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 tosetMessageHandler, 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 benull
-
dispatchToResponder
private static void dispatchToResponder(String type, com.google.gson.JsonObject data, PanelResponderRegistry registry) Dispatchestypeto 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(...), matchingsendCapabilityRefusal's andFileOperationManager#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 patternCommandExecutionManager/FileOperationManageralready use for their own outbound replies.The reply always carries the message type and the request's
requestId(echoed fromdata), and either the responder's resolvedJsonObjector anerrormember 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 norequestId, the reply is logged rather than sent: the panel has no way to correlate an uncorrelated reply, matchingsendCapabilityRefusal(java.lang.String, com.google.gson.JsonObject, com.ultikits.ultitools.entities.Capability)'s same reasoning forcommandId/operationId.- Parameters:
type- the message type, already confirmed to have a registered responderdata- the message'sdataobject, possiblynullregistry- the registry to dispatch through
-
rootCauseMessage
The deepest non-null message onthrowable's cause chain, falling back to the throwable's own class name when every message isnull— a bareNullPointerExceptioncarries no message at all, and an emptyerrorfield 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-facingEventBus(WIRE-16). This is the single publish site — see the dispatch-table call site inhandleInboundMessage(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; onlyBukkit.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 missingEventBuscan never break the inbound message path — both are logged no-ops.- Parameters:
type- the resolved message typemessage- the full inbound envelopedata- the message'sdataobject, possiblynull
-
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 againstdata;Capability.NONEruns 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 oneRemoteActionLog.Verdict.ALLOWEDentry; disabled sends onecapability_deniedreply and records oneRemoteActionLog.Verdict.DENIEDentry — the handler is never invoked on the denied path, so there is only ever one writer there.execute_commandandfile_operationare the two entries whose handler performs its own, finer-grainedAccessDecisioncheck and records its own verdict from that check — recording a second, blanketALLOWEDentry 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-logactionand the refusal payloadmessage- the full inbound messagedata- the message'sdataobject, possiblynullentry- the dispatch-table entry that serves this type- Returns:
- whether the message should also reach
PanelMessageEventsubscribers —trueforCapability.NONEand for an enabled capability,falsefor a denied one. The caller carries this straight into the publish decision so the gate and the publish can never disagree (seehandleInboundMessage(com.google.gson.JsonObject)).
-
sendCapabilityRefusal
private static void sendCapabilityRefusal(String type, com.google.gson.JsonObject data, Capability capability) Sends onecapability_deniedoutbound message naming the config key, the config file, the refusal reason, and echoing whichever correlation id the inbound message carried (commandId,operationIdorrequestId) so the panel can correlate the refusal with the request that caused it. Not reusing the existingerrormessage type — seehandleInboundMessage(com.google.gson.JsonObject)'s own comment on why unsolicitederrorreplies are avoided on this path. A logged no-op when no client is connected.- Parameters:
type- the inbound message type that was refuseddata- the message'sdataobject, possiblynullcapability- the capability that refused it
-
copyIfPresent
private static void copyIfPresent(com.google.gson.JsonObject source, com.google.gson.JsonObject target, String field) Copiesfieldfromsourcetotargetonly 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. AnullUltiTools.getInstance().getRemoteActionLog()is a silent no-op — the existing inbound-message tests mockUltiToolsand return null for it. -
resolveActionLogAction
The action-logactionfield — the message type, extended with the resolved sub-operation forfile_operation. -
resolveActionLogTarget
The action-logtargetfield — the command text, file path, or message type otherwise. -
resolveActor
The action-logactorfield — the inboundexecutorfield verbatim, or the literal"panel"when absent. The framework cannot attribute a remote command to an individual panel operator today (seeRemoteActionLog.Entry's javadoc), so this never invents a per-operator identity. -
initializeManagers
static void initializeManagers()Wires all WebSocket managers up to the current connection, onCloudSession.current().This method hangs off
onConnectHandler, and an in-flight handshake can still land after/ulticloud logout. Without a guard, the listenersdisableCloud()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
CloudSessionitself (its own intrinsic lock replaces the former global the former global lifecycle lock — seeCloudSession.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
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:sendBatchUpdatefiring 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 ownwireManagers()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 readdata.config; the panel names the file withdata.fileName, and this never read it except forserver_properties; the panel does not sendrequestId, and this used whetherrequestIdwas present as the test for "is this a request" — when it was missing, the code just logged oneLevel.FINEline and dropped the message, andFINEdoes 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
requestIdis 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
Reads a string field that may be missing, or may be a JSON null. -
readConfigContent
Reads the config content, preferringdata.configData.data.configused to be the only field this method read, but no producer of it can be found anywhere in the tree — the panel has always sentconfigData. It is kept only for compatibility with a possible third-party panel; reading it logs a deprecation warning. -
applyConfigUpdate
Decides where to write based onfileName.The three branches correspond to three payload shapes — exactly what the former "fileName is never read" covered up:
server_propertiesis 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 fromConfigManager.toJson().- Throws:
IOException
-
sendConfigUpdateResponse
Sends back oneconfig_update_response.The payload sits in
data, matching every other plugin-to-Worker message (seeCommandExecutionManager.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_configcarries 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'stypestraight 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 themessagefield; recognize it there and stop (issue #359). The formerdata.has("requestId")gate is deleted rather than fixed — nothing anywhere in the system (panel, frontend, or Worker route) ever populates that field on anupload_configpayload, so no code path here reads it any more. -
handleConfigUploadLogic
Handles the config upload logic.upload_configacceptsplugin_configonly (#435, D-13).server_propertiesis rejected naming the message that actually handles it — the dedicatedserver_propertiesmessage routed toServerPropertiesManager.handleServerProperties(com.google.gson.JsonObject).permissionsis defined nowhere in the system, so it is not given a meaning here — it falls to the pre-existing fail-closeddefault, whose wording already says exactly what a dedicated branch would.- Throws:
Exception
-
sendErrorResponse
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
Uploads the local config to the server -
uploadServerProperties
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
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 againstsession, never against whateverCloudSession.current()happens to be when this runs (D-16). The reconnect-exhausted handler that invokes this captures its own session lexically at registration time (seeinitWebsocket(CloudSession)); the no-argreinitWebSocket()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 fromstopWebsocket()is that the latter only disconnects the current client, and the reconnection chain will bring it back up; this method invalidatesCloudSession.current()first, soreinitWebSocket(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()andCloudSession.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 toCloudSession.current()taken after teardown returns -- eliminating the specific race where a concurrentlogin()installs a new session in the gap between those two reads (seeCloudAuthManager#logout()'s own javadoc for the full account). This particular overload keeps its originalvoidreturn --disableCloud()waspublicin 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
Thesession-targeted,CloudSession-returning form ofdisableCloud()(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-argdisableCloud()(which always targets whateverCloudSession.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 withCloudSession.current()captured at its own call site -- immediately before teardown begins -- and acts on the returned reference afterward rather than re-readingCloudSession.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 ofCloudSession.current()
-
doDisableCloud
The actual teardown performed bydisableCloud()/disableCloud(CloudSession).sessionis 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 ofCloudSession.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
sessionhas 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 onsessionstill 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
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 failureaction- the teardown action
-
onWebSocketConnected
static void onWebSocketConnected()Compatibility overload (WR-07, 16-REVIEW-cloud.md): falls back toCloudSession.current(), preserved only becauseCloudReconnectStateMachineTestcalls 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)callsonWebSocketConnected(CloudSession)with its own captured session instead. -
onWebSocketConnected
Called when a reconnection succeeds: resetssession's own outer budget.Only a genuinely successful handshake is entitled to reset the budget. Resetting it inside
reinitWebSocketinstead 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 byinitWebsocket(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
Atomically, inside the session's own lock: "re-check thatsessionis 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
sessionitself, the same monitorCloudSession.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-checkingsession's own currency while holding the lock, sees it is no longer current and returns false directly (also clean). Checkingsession.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 logouton 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:
trueif activated;falseif 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 byreinitWebSocket(), 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 thatCloudSession.invalidate()already ran on (typicallydisableCloud(), 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 (everyenableCloud()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 byUltiTools.onDisable(), where a fulldisableCloud()teardown is unnecessary (the JVM is going away regardless). -
getPanelWebsocketClient
private static UltiPanelWebSocketClient getPanelWebsocketClient(TokenEntity token) throws IOException - Throws:
IOException
-
safeGetString
-
safeGetBoolean
private static boolean safeGetBoolean(com.google.gson.JsonObject obj, String key, boolean defaultValue) -
safeGetLong
-
safeGetDouble
private static double safeGetDouble(com.google.gson.JsonObject obj, String key, double defaultValue)
-