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.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.");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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-компаса.",
|
||||||
|
|
|
||||||
|
|
@ -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