feat(mod): cloud config upload via %config save <1-4>

Add PlatformHttpClient.put() and ConfigCloudUploader so %config save
<slot> snapshots the current modules and PUTs them to /configs/{slot},
reporting the returned share_code for a friend's %config load <code>.
Plain %config save still writes locally. Update command usage/i18n and
add a buildBody unit test.
This commit is contained in:
loki5512344 2026-09-28 14:10:16 +02:00
parent 904c724c9f
commit 61a73cffbb
Signed by: boba
GPG key ID: 253067914055423B
7 changed files with 142 additions and 4 deletions

View file

@ -7,6 +7,7 @@ import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo; import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.CommandOutput; import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.core.ModuleManager; import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.features.platform.config.ConfigCloudUploader;
import dev.loki.lovisual.features.platform.config.ConfigShareLoader; import dev.loki.lovisual.features.platform.config.ConfigShareLoader;
import java.util.List; import java.util.List;
@ -15,7 +16,7 @@ import java.util.Locale;
@CommandInfo( @CommandInfo(
id = "config", id = "config",
aliases = {"cfg", "settings"}, aliases = {"cfg", "settings"},
usage = "%config [save|load [code]|path]", usage = "%config [save [1-4 name]|load [code]|path]",
descriptionKey = "command.config.description" descriptionKey = "command.config.description"
) )
public final class ConfigCommand implements ClientCommand { public final class ConfigCommand implements ClientCommand {
@ -24,6 +25,13 @@ public final class ConfigCommand implements ClientCommand {
String action = ctx.arg(0) == null ? "save" : ctx.arg(0).toLowerCase(Locale.ROOT); String action = ctx.arg(0) == null ? "save" : ctx.arg(0).toLowerCase(Locale.ROOT);
switch (action) { switch (action) {
case "save" -> { case "save" -> {
String slotArg = ctx.arg(1);
if (slotArg != null && slotArg.matches("[1-4]")) {
// %config save <1-4> [name]: snapshot current modules and
// upload to the linked account's cloud slot (PUT /configs/{slot}).
ConfigCloudUploader.upload(Integer.parseInt(slotArg), ctx.arg(2));
break;
}
ModuleManager.saveAllModuleConfigs(); ModuleManager.saveAllModuleConfigs();
ConfigSerializer.saveAll(); ConfigSerializer.saveAll();
CommandOutput.success("Client config saved."); CommandOutput.success("Client config saved.");

View file

@ -0,0 +1,80 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonObject;
import dev.loki.lovisual.config.profile.codec.ConfigProfileType;
import dev.loki.lovisual.config.profile.service.ConfigProfileCollector;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.PlatformSession;
import dev.loki.lovisual.features.platform.http.ApiResult;
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
import dev.loki.lovisual.features.platform.json.ConfigProfileJsonCodec;
import net.minecraft.client.Minecraft;
/**
* Backs {@code %config save <slot>}: snapshots the current module settings and
* uploads them to the authenticated account's cloud slot through
* {@code PUT /configs/{slot}} (gateway forwards it to configs-service, which
* returns the slot's {@code share_code}). Complements {@link ConfigShareLoader}
* ({@code %config load <code>}) — the pair is what makes a config portable:
* upload here yields the code a friend redeems there. Kept out of
* {@code ConfigCommand} for the same line-budget/package-cohesion reasons.
*/
public final class ConfigCloudUploader {
private static final ConfigProfileCollector COLLECTOR = new ConfigProfileCollector();
private ConfigCloudUploader() {
}
/**
* Wraps a snapshot in the {@code {name, data}} body configs-service expects
* on {@code PUT /configs/{slot}}. Split out from {@link #upload} so it is
* unit-testable without a live backend or a running client.
*/
public static JsonObject buildBody(String name, ConfigProfileSnapshot snapshot) {
JsonObject body = new JsonObject();
body.addProperty("name", name);
body.add("data", ConfigProfileJsonCodec.toJson(snapshot));
return body;
}
public static void upload(int slot, String rawName) {
if (slot < 1 || slot > 4) {
CommandOutput.warning("Slot must be 1..4. Usage: %config save <1-4> [name]");
return;
}
if (!PlatformSession.get().isLinked()) {
CommandOutput.error("Not linked to a platform account. Run %link first.");
return;
}
String name = (rawName == null || rawName.isBlank()) ? ("Slot " + slot) : rawName.trim();
ConfigProfileSnapshot snapshot = COLLECTOR.collect(ConfigProfileType.MODULES, name);
CommandOutput.send("Uploading \"" + name + "\" to cloud slot " + slot + "...");
PlatformHttpClient.put("/configs/" + slot, buildBody(name, snapshot), PlatformSession.get().getDeviceToken())
.thenAccept(result -> onUploaded(slot, result));
}
private static void onUploaded(int slot, ApiResult result) {
dispatch(() -> {
if (!result.success() || result.asObject() == null) {
CommandOutput.error("Cloud upload failed: " + result.error());
return;
}
JsonObject json = result.asObject();
String shareCode = json.has("share_code") && !json.get("share_code").isJsonNull()
? json.get("share_code").getAsString() : "?";
CommandOutput.success("Saved slot " + slot + ". Share code: " + shareCode
+ " (friend uses %config load " + shareCode + ").");
});
}
private static void dispatch(Runnable runnable) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.isSameThread()) {
runnable.run();
} else {
mc.execute(runnable);
}
}
}

View file

@ -52,6 +52,14 @@ public final class PlatformHttpClient {
return send(builder, bearerToken); return send(builder, bearerToken);
} }
public static CompletableFuture<ApiResult> put(String path, JsonObject body, String bearerToken) {
String json = body == null ? "{}" : body.toString();
HttpRequest.Builder builder = requestBuilder(path)
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(json));
return send(builder, bearerToken);
}
public static CompletableFuture<ApiResult> delete(String path, String bearerToken) { public static CompletableFuture<ApiResult> delete(String path, String bearerToken) {
return send(requestBuilder(path).DELETE(), bearerToken); return send(requestBuilder(path).DELETE(), bearerToken);
} }

View file

@ -25,7 +25,8 @@ import java.util.Map;
* compressed, CRC32-checked binary blob written by * compressed, CRC32-checked binary blob written by
* {@code ConfigProfileBinaryCodec}, which is not what a JSONB column can * {@code ConfigProfileBinaryCodec}, which is not what a JSONB column can
* hold. The backend's own plan docs do not yet define an exact shape for * hold. The backend's own plan docs do not yet define an exact shape for
* {@code data} (as of this pass, mod-side upload is still unimplemented, and * {@code data} (as of this pass, mod-side upload rides on this same shape via
* {@code ConfigCloudUploader}, and
* {@code configs-service/PLAN.md} only fixes its own easter-egg payload as * {@code configs-service/PLAN.md} only fixes its own easter-egg payload as
* an example) so the shape below ({@code {meta:{...}, entries:[{ownerId, * an example) so the shape below ({@code {meta:{...}, entries:[{ownerId,
* displayName, values}]}}) is this task's judgment call: it mirrors * displayName, values}]}}) is this task's judgment call: it mirrors

View file

@ -3248,7 +3248,7 @@
"setting.nointeract.only_kill_aura": "Only with KillAura", "setting.nointeract.only_kill_aura": "Only with KillAura",
"command.addons.description": "Lists, scans, enables, or disables client addons.", "command.addons.description": "Lists, scans, enables, or disables client addons.",
"command.bind.description": "Changes module key binds and lists existing binds.", "command.bind.description": "Changes module key binds and lists existing binds.",
"command.config.description": "Saves, reloads, or locates the active client config.", "command.config.description": "Saves, reloads, or locates the client config; uploads it to a cloud slot or loads a shared code.",
"command.coordinates.description": "Shows the current player coordinates.", "command.coordinates.description": "Shows the current player coordinates.",
"command.enemy.description": "Manages the client enemy list.", "command.enemy.description": "Manages the client enemy list.",
"command.gps.description": "Sets or clears the Gps compass target.", "command.gps.description": "Sets or clears the Gps compass target.",

View file

@ -3043,7 +3043,7 @@
"setting.nointeract.only_kill_aura": "Только с KillAura", "setting.nointeract.only_kill_aura": "Только с KillAura",
"command.addons.description": "Показывает, сканирует, включает и отключает аддоны клиента.", "command.addons.description": "Показывает, сканирует, включает и отключает аддоны клиента.",
"command.bind.description": "Изменяет привязки клавиш модулей и показывает текущие привязки.", "command.bind.description": "Изменяет привязки клавиш модулей и показывает текущие привязки.",
"command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации клиента.", "command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации; загружает её в облачный слот или считывает по коду.",
"command.coordinates.description": "Показывает текущие координаты игрока.", "command.coordinates.description": "Показывает текущие координаты игрока.",
"command.enemy.description": "Управляет списком врагов клиента.", "command.enemy.description": "Управляет списком врагов клиента.",
"command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.", "command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.",

View file

@ -0,0 +1,41 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonObject;
import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry;
import dev.loki.lovisual.config.profile.codec.ConfigProfileType;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies {@link ConfigCloudUploader#buildBody} wraps a snapshot in the
* {@code {name, data}} shape configs-service expects on
* {@code PUT /configs/{slot}}, with {@code data} being the codec's
* {@code {meta, entries}} tree. This is the pure half of the upload path, so
* it is tested without a running client or backend.
*/
public final class ConfigCloudUploaderTest {
@Test
void buildsNameAndDataEnvelope() {
ConfigProfileMeta meta = new ConfigProfileMeta("slot1", "My Build", ConfigProfileType.MODULES, "Tester", 10L, 20L, 1);
ConfigProfileSnapshot snapshot = new ConfigProfileSnapshot(meta,
List.of(new ConfigProfileEntry("module:ChinaHat", "China Hat", Map.of("enabled", true))));
JsonObject body = ConfigCloudUploader.buildBody("My Build", snapshot);
assertEquals("My Build", body.get("name").getAsString());
JsonObject data = body.getAsJsonObject("data");
assertEquals("slot1", data.getAsJsonObject("meta").get("id").getAsString());
assertEquals("My Build", data.getAsJsonObject("meta").get("name").getAsString());
assertTrue(data.getAsJsonArray("entries").get(0).isJsonObject());
assertEquals("module:ChinaHat",
data.getAsJsonArray("entries").get(0).getAsJsonObject().get("ownerId").getAsString());
}
}