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:
parent
904c724c9f
commit
61a73cffbb
7 changed files with 142 additions and 4 deletions
|
|
@ -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.util.CommandOutput;
|
||||
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 java.util.List;
|
||||
|
|
@ -15,7 +16,7 @@ import java.util.Locale;
|
|||
@CommandInfo(
|
||||
id = "config",
|
||||
aliases = {"cfg", "settings"},
|
||||
usage = "%config [save|load [code]|path]",
|
||||
usage = "%config [save [1-4 name]|load [code]|path]",
|
||||
descriptionKey = "command.config.description"
|
||||
)
|
||||
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);
|
||||
switch (action) {
|
||||
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();
|
||||
ConfigSerializer.saveAll();
|
||||
CommandOutput.success("Client config saved.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,14 @@ public final class PlatformHttpClient {
|
|||
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) {
|
||||
return send(requestBuilder(path).DELETE(), bearerToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ import java.util.Map;
|
|||
* compressed, CRC32-checked binary blob written by
|
||||
* {@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
|
||||
* {@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
|
||||
* an example) so the shape below ({@code {meta:{...}, entries:[{ownerId,
|
||||
* displayName, values}]}}) is this task's judgment call: it mirrors
|
||||
|
|
|
|||
|
|
@ -3248,7 +3248,7 @@
|
|||
"setting.nointeract.only_kill_aura": "Only with KillAura",
|
||||
"command.addons.description": "Lists, scans, enables, or disables client addons.",
|
||||
"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.enemy.description": "Manages the client enemy list.",
|
||||
"command.gps.description": "Sets or clears the Gps compass target.",
|
||||
|
|
|
|||
|
|
@ -3043,7 +3043,7 @@
|
|||
"setting.nointeract.only_kill_aura": "Только с KillAura",
|
||||
"command.addons.description": "Показывает, сканирует, включает и отключает аддоны клиента.",
|
||||
"command.bind.description": "Изменяет привязки клавиш модулей и показывает текущие привязки.",
|
||||
"command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации клиента.",
|
||||
"command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации; загружает её в облачный слот или считывает по коду.",
|
||||
"command.coordinates.description": "Показывает текущие координаты игрока.",
|
||||
"command.enemy.description": "Управляет списком врагов клиента.",
|
||||
"command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue