Stop sending the Minecraft access token to the Saturn server. The connect loop now: 1. fetches GET /versions and compares this client's mod version: supported -> connect; deprecated -> warn, connect; neither -> warn, don't connect (warnings are shown once per status change) 2. requests auth_challenge with the username to get a server id 3. calls Mojang's session `join` with the access token and server id (the token only goes to Mojang); a refused token gives up 4. requests auth_verify, which the server confirms with `hasJoined` Add SaturnProvider.getModVersion() and showWarning() for the version check and its toast. Refs saturnclientmc/saturnclient#7 Co-Authored-By: Claude Opus 5.5 <[email protected]>
447 lines
16 KiB
Java
447 lines
16 KiB
Java
package org.saturnclient.client;
|
|
|
|
import java.net.URI;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.time.Duration;
|
|
import java.util.UUID;
|
|
import java.util.concurrent.CompletionException;
|
|
import java.util.concurrent.ExecutionException;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
|
import org.saturnclient.client.player.SaturnPlayer;
|
|
import org.saturnclient.common.provider.Providers;
|
|
import org.saturnclient.common.ref.game.MinecraftClientRef;
|
|
import org.saturnclient.cosmetics.Cloaks;
|
|
import org.saturnclient.cosmetics.Hats;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
import dev.selimaj.session.Session;
|
|
import dev.selimaj.session.types.SessionErrorException;
|
|
|
|
public class ServiceClient {
|
|
/** Override with -Dsaturn.serverUri=ws://127.0.0.1:8080 to use a local server. */
|
|
private static final String SERVER_URI = System.getProperty("saturn.serverUri", "wss://saturn-server.selimaj.dev");
|
|
private static final long CONNECT_TIMEOUT_SECONDS = 10;
|
|
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
|
private static final long MIN_RECONNECT_DELAY_MS = 1_000;
|
|
private static final long MAX_RECONNECT_DELAY_MS = 60_000;
|
|
|
|
/** The authenticated session, or null while disconnected or connecting. */
|
|
private static volatile Session session;
|
|
public static volatile UUID uuid;
|
|
|
|
private static final AtomicBoolean CONNECTING = new AtomicBoolean();
|
|
private static volatile boolean stopping = false;
|
|
|
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
|
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
|
.build();
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
|
private enum VersionStatus {
|
|
SUPPORTED,
|
|
/** Still accepted, but the player should update. */
|
|
DEPRECATED,
|
|
/** Not accepted; don't connect. */
|
|
UNSUPPORTED
|
|
}
|
|
|
|
/** Last status the player was warned about, so reconnects don't repeat the toast. */
|
|
private static volatile VersionStatus warnedStatus = VersionStatus.SUPPORTED;
|
|
|
|
public static void initialize() {
|
|
Providers.saturn.getClient().onClientStopping(() -> {
|
|
stopping = true;
|
|
Session current = session;
|
|
session = null;
|
|
|
|
if (current != null) {
|
|
try {
|
|
current.close();
|
|
} catch (Exception e) {
|
|
Providers.saturn.logError("Unable to close Saturn session", e);
|
|
}
|
|
}
|
|
});
|
|
|
|
connectInBackground();
|
|
}
|
|
|
|
public static boolean isConnected() {
|
|
return session != null;
|
|
}
|
|
|
|
/** Starts the connect loop unless one is already running. */
|
|
private static void connectInBackground() {
|
|
if (stopping || !CONNECTING.compareAndSet(false, true)) {
|
|
return;
|
|
}
|
|
|
|
Thread thread = new Thread(() -> {
|
|
boolean connected = false;
|
|
try {
|
|
connected = connectLoop();
|
|
} finally {
|
|
CONNECTING.set(false);
|
|
}
|
|
|
|
// A drop between connecting and clearing CONNECTING couldn't start a new loop.
|
|
if (connected && session == null) {
|
|
connectInBackground();
|
|
}
|
|
}, "Saturn-Connect");
|
|
thread.setDaemon(true);
|
|
thread.start();
|
|
}
|
|
|
|
/**
|
|
* Connects and authenticates, retrying with exponential backoff. Returns
|
|
* whether it connected.
|
|
*/
|
|
private static boolean connectLoop() {
|
|
long delay = MIN_RECONNECT_DELAY_MS;
|
|
|
|
while (!stopping) {
|
|
switch (connectOnce()) {
|
|
case CONNECTED -> {
|
|
Providers.saturn.logInfo("Saturn Session initialization complete");
|
|
return true;
|
|
}
|
|
case GIVE_UP -> {
|
|
Providers.saturn.logError("Failed to authenticate with the server");
|
|
return false;
|
|
}
|
|
case RETRY -> {
|
|
Providers.saturn.logError("Saturn server unavailable, retrying in " + delay / 1000 + "s");
|
|
|
|
try {
|
|
Thread.sleep(delay);
|
|
} catch (InterruptedException e) {
|
|
return false;
|
|
}
|
|
|
|
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private enum ConnectResult {
|
|
CONNECTED,
|
|
/** Retrying cannot help, e.g. no Minecraft session or the token was rejected. */
|
|
GIVE_UP,
|
|
RETRY
|
|
}
|
|
|
|
private static ConnectResult connectOnce() {
|
|
MinecraftClientRef client = Providers.saturn.getClient();
|
|
|
|
String accessToken = client.getAccessToken();
|
|
UUID playerUuid = client.getUuid();
|
|
String username = client.getUsername();
|
|
|
|
if (accessToken == null || playerUuid == null || username == null) {
|
|
Providers.saturn.logError("No active Minecraft session found");
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
|
|
uuid = playerUuid;
|
|
|
|
VersionStatus version;
|
|
try {
|
|
version = checkVersion();
|
|
} catch (Exception e) {
|
|
Providers.saturn.logError("Unable to fetch the Saturn version manifest: " + rootMessage(e));
|
|
return ConnectResult.RETRY;
|
|
}
|
|
|
|
if (version == VersionStatus.UNSUPPORTED) {
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
|
|
Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid);
|
|
|
|
Session s;
|
|
try {
|
|
s = Session.connect(SERVER_URI, CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
} catch (Exception e) {
|
|
Providers.saturn.logError("Unable to connect to the Saturn server: " + rootMessage(e));
|
|
return ConnectResult.RETRY;
|
|
}
|
|
|
|
ServiceMethods.Types.Player response;
|
|
try {
|
|
// 1. The server hands out a one-time server id for this connection.
|
|
String serverId;
|
|
try {
|
|
serverId = s.request(ServiceMethods.AuthChallenge, username, REQUEST_TIMEOUT).get();
|
|
} catch (ExecutionException e) {
|
|
s.close();
|
|
|
|
if (e.getCause() instanceof SessionErrorException rejected) {
|
|
Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage());
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
|
|
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
|
|
return ConnectResult.RETRY;
|
|
}
|
|
|
|
// 2. Join with it at Mojang; the access token only goes to Mojang.
|
|
switch (MojangSession.join(HTTP, accessToken, playerUuid, serverId)) {
|
|
case JOINED -> {
|
|
}
|
|
case REJECTED -> {
|
|
s.close();
|
|
Providers.saturn.logError("Mojang rejected the Minecraft session");
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
case FAILED -> {
|
|
s.close();
|
|
Providers.saturn.logError("Unable to reach the Mojang session server");
|
|
return ConnectResult.RETRY;
|
|
}
|
|
}
|
|
|
|
// 3. The server confirms the join with Mojang and logs us in.
|
|
try {
|
|
response = s.request(ServiceMethods.AuthVerify, null, REQUEST_TIMEOUT).get();
|
|
} catch (ExecutionException e) {
|
|
s.close();
|
|
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
|
|
return ConnectResult.RETRY;
|
|
}
|
|
} catch (InterruptedException e) {
|
|
s.close();
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
|
|
Cloaks.availableCloaks.clear();
|
|
Cloaks.availableCloaks.addAll(java.util.List.of(response.cloaks()));
|
|
Cloaks.initialize();
|
|
|
|
Hats.availableHats.clear();
|
|
Hats.availableHats.addAll(java.util.List.of(response.hats()));
|
|
Hats.initialize();
|
|
|
|
// Other players may have changed cosmetics while we were disconnected.
|
|
SaturnPlayer.clear();
|
|
SaturnPlayer.set(new SaturnPlayer(playerUuid, username, response.cloak(), response.hat()));
|
|
|
|
eventHandlers(s);
|
|
|
|
if (stopping) {
|
|
s.close();
|
|
return ConnectResult.GIVE_UP;
|
|
}
|
|
|
|
session = s;
|
|
// Registered after publishing so an immediate drop still clears it.
|
|
s.onClose(() -> onDisconnected(s));
|
|
return ConnectResult.CONNECTED;
|
|
}
|
|
|
|
/**
|
|
* Checks this client's version against the server's manifest and warns the
|
|
* player if it's deprecated or unsupported.
|
|
*/
|
|
private static VersionStatus checkVersion() throws Exception {
|
|
URI server = URI.create(SERVER_URI);
|
|
String scheme = "wss".equals(server.getScheme()) ? "https" : "http";
|
|
URI manifestUri = new URI(scheme, server.getAuthority(), "/versions", null, null);
|
|
|
|
HttpResponse<String> res = HTTP.send(
|
|
HttpRequest.newBuilder(manifestUri).timeout(REQUEST_TIMEOUT).GET().build(),
|
|
HttpResponse.BodyHandlers.ofString());
|
|
|
|
if (res.statusCode() != 200) {
|
|
throw new IllegalStateException("GET /versions returned " + res.statusCode());
|
|
}
|
|
|
|
ServiceMethods.VersionManifest manifest = MAPPER.readValue(res.body(), ServiceMethods.VersionManifest.class);
|
|
|
|
// Drop build metadata such as "+1.21.11": the manifest lists mod versions.
|
|
String version = Providers.saturn.getModVersion().split("\\+", 2)[0];
|
|
|
|
VersionStatus status;
|
|
if (manifest.supported() != null && manifest.supported().contains(version)) {
|
|
status = VersionStatus.SUPPORTED;
|
|
} else if (manifest.deprecated() != null && manifest.deprecated().contains(version)) {
|
|
status = VersionStatus.DEPRECATED;
|
|
} else {
|
|
status = VersionStatus.UNSUPPORTED;
|
|
}
|
|
|
|
if (status != warnedStatus) {
|
|
warnedStatus = status;
|
|
|
|
switch (status) {
|
|
case DEPRECATED -> {
|
|
Providers.saturn.logError("Saturn Client " + version + " is deprecated");
|
|
Providers.saturn.showWarning("Saturn Client update available",
|
|
"Version " + version + " is deprecated and will stop working soon. Please update.");
|
|
}
|
|
case UNSUPPORTED -> {
|
|
Providers.saturn.logError("Saturn Client " + version + " is no longer supported, not connecting");
|
|
Providers.saturn.showWarning("Saturn Client is outdated",
|
|
"Version " + version + " is no longer supported. Update to use cosmetics and emotes.");
|
|
}
|
|
case SUPPORTED -> {
|
|
}
|
|
}
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
private static void onDisconnected(Session closed) {
|
|
if (session != closed) {
|
|
return;
|
|
}
|
|
|
|
session = null;
|
|
|
|
if (!stopping) {
|
|
Providers.saturn.logError("Disconnected from the Saturn server, reconnecting");
|
|
connectInBackground();
|
|
}
|
|
}
|
|
|
|
private static String rootMessage(Throwable e) {
|
|
while ((e instanceof CompletionException || e instanceof ExecutionException) && e.getCause() != null) {
|
|
e = e.getCause();
|
|
}
|
|
return e.getClass().getSimpleName() + ": " + e.getMessage();
|
|
}
|
|
|
|
public static void setCloak(String itemId) {
|
|
Session s = session;
|
|
if (s == null)
|
|
return;
|
|
|
|
s.request(ServiceMethods.SetCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to set cloak (service): " + rootMessage(throwable));
|
|
} else {
|
|
sendPlayer(s);
|
|
}
|
|
});
|
|
}
|
|
|
|
public static void setHat(String itemId) {
|
|
Session s = session;
|
|
if (s == null)
|
|
return;
|
|
|
|
s.request(ServiceMethods.SetHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to set hat (service): " + rootMessage(throwable));
|
|
} else {
|
|
sendPlayer(s);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Tells nearby Saturn players about our current cosmetics. */
|
|
private static void sendPlayer(Session s) {
|
|
s.request(ServiceMethods.SendPlayer,
|
|
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()),
|
|
REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to send player (service): " + rootMessage(throwable));
|
|
}
|
|
});
|
|
}
|
|
|
|
public static void buyCloak(String itemId) {
|
|
Session s = session;
|
|
if (s == null)
|
|
return;
|
|
|
|
s.request(ServiceMethods.BuyCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to buy cloak (service): " + rootMessage(throwable));
|
|
} else if (!Cloaks.availableCloaks.contains(itemId)) {
|
|
Cloaks.availableCloaks.add(itemId);
|
|
}
|
|
});
|
|
}
|
|
|
|
public static void buyHat(String itemId) {
|
|
Session s = session;
|
|
if (s == null)
|
|
return;
|
|
|
|
s.request(ServiceMethods.BuyHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to buy hat (service): " + rootMessage(throwable));
|
|
} else if (!Hats.availableHats.contains(itemId)) {
|
|
Hats.availableHats.add(itemId);
|
|
}
|
|
});
|
|
}
|
|
|
|
public static void emote(String emote) {
|
|
Session s = session;
|
|
if (s == null)
|
|
return;
|
|
|
|
s.request(ServiceMethods.Emote,
|
|
new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString()),
|
|
REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
|
if (throwable != null) {
|
|
Providers.saturn.logError("Failed to emote (service): " + rootMessage(throwable));
|
|
}
|
|
});
|
|
}
|
|
|
|
private static void eventHandlers(Session s) {
|
|
s.onNotification(ServiceMethods.EmoteEvent, (data) -> {
|
|
if (data == null) {
|
|
return;
|
|
}
|
|
UUID from;
|
|
try {
|
|
from = UUID.fromString(data.from());
|
|
} catch (Exception e) {
|
|
return;
|
|
}
|
|
|
|
String emote = data.emote() != null && !data.emote().isEmpty() ? data.emote() : null;
|
|
|
|
// Notifications arrive on a background thread.
|
|
Providers.saturn.getClient().executeOnThread(() -> Providers.saturn.playEmote(from, emote));
|
|
});
|
|
|
|
s.onNotification(ServiceMethods.Player, (player) -> {
|
|
SaturnPlayer.set(player.toSaturnPlayer());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the player's Saturn data, or null if the server says they aren't
|
|
* using Saturn. Throws if the lookup itself fails or times out, so callers
|
|
* can retry.
|
|
*/
|
|
public static SaturnPlayer getPlayer(UUID uuid, String name) throws Exception {
|
|
Session s = session;
|
|
|
|
if (s == null)
|
|
throw new IllegalStateException("Not connected to the Saturn server");
|
|
|
|
ServiceMethods.Types.Player player = s.request(ServiceMethods.GetPlayer, uuid.toString(), REQUEST_TIMEOUT)
|
|
.join();
|
|
|
|
if (player == null)
|
|
return null;
|
|
|
|
return player.toSaturnPlayer(uuid, name);
|
|
}
|
|
}
|