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.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.");

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);
}
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);
}

View file

@ -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