feat: mod platform integration (showcase, cloud slots, cloud screen, server-side unlink)

Mod:
- %config slots/pull/publish/unpublish for the four cloud slots
- %showcase [new|popular] [page] | load | copy, with number references
- %cloud opens a CloudScreen (link/unlink, slots, showcase) on vanilla widgets
- %config load IDDQD works offline (everything off except ChinaHat)
- default backend URL is now the production gateway
- shared ConfigRemoteApplier and ClientThread replace per-class copies
- %link unlink revokes the link on the server, then clears the local token

Backend:
- POST /device/revoke (RFC 7009 style self-revoke, always 204), rate limited
  to 10/min per IP at the gateway

CloudScreen is compiled but has not been opened in a running client yet.
This commit is contained in:
loki5512344 2026-10-02 09:23:44 +02:00
parent e00ea8eb11
commit 44353e893c
Signed by: boba
GPG key ID: 253067914055423B
24 changed files with 894 additions and 52 deletions

View file

@ -7,8 +7,10 @@ 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.CloudSlots;
import dev.loki.lovisual.features.platform.config.ConfigCloudUploader;
import dev.loki.lovisual.features.platform.config.ConfigShareLoader;
import dev.loki.lovisual.features.platform.showcase.ShowcaseClient;
import java.util.List;
import java.util.Locale;
@ -16,7 +18,7 @@ import java.util.Locale;
@CommandInfo(
id = "config",
aliases = {"cfg", "settings"},
usage = "%config [save [1-4 name]|load [code]|path]",
usage = "%config [save [1-4 name]|load [code]|slots|pull <1-4>|publish <1-4> <title>|unpublish <1-4>|path]",
descriptionKey = "command.config.description"
)
public final class ConfigCommand implements ClientCommand {
@ -47,16 +49,28 @@ public final class ConfigCommand implements ClientCommand {
ModuleManager.loadAllModuleConfigs();
CommandOutput.success("Client config reloaded.");
}
case "slots" -> CloudSlots.list();
case "pull" -> CloudSlots.pull(slotOf(ctx.arg(1)));
case "publish" -> ShowcaseClient.publish(slotOf(ctx.arg(1)), restFrom(ctx, 2));
case "unpublish" -> ShowcaseClient.unpublish(slotOf(ctx.arg(1)));
case "path", "folder" -> CommandOutput.send("Config directory: " + ConfigPaths.root().toAbsolutePath());
default -> CommandOutput.warning("Usage: " + metadata().usage());
}
return true;
}
private static int slotOf(String arg) {
return arg != null && arg.matches("[1-4]") ? Integer.parseInt(arg) : -1;
}
private static String restFrom(CommandContext ctx, int from) {
return String.join(" ", ctx.args().subList(Math.min(from, ctx.args().size()), ctx.args().size()));
}
@Override
public List<String> suggest(CommandContext ctx, int argIndex, String token) {
if (argIndex != 1) return List.of();
String lower = token == null ? "" : token.toLowerCase(Locale.ROOT);
return List.of("save", "load", "path").stream().filter(value -> value.startsWith(lower)).toList();
return List.of("save", "load", "slots", "pull", "publish", "unpublish", "path").stream().filter(value -> value.startsWith(lower)).toList();
}
}

View file

@ -0,0 +1,23 @@
package dev.loki.lovisual.features.command.impl.platform;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.ClientCommand;
import dev.loki.lovisual.features.gui.mainmenu.cloud.CloudScreen;
import dev.loki.lovisual.util.screen.ClientScreen;
/** Opens the cloud configs screen: account link, the four slots and the showcase. */
@CommandInfo(
id = "cloud",
usage = "%cloud",
descriptionKey = "command.cloud.description"
)
public final class CloudCommand implements ClientCommand {
@Override
public boolean execute(CommandContext ctx) {
// The chat screen closes right after a command runs and would replace ours: open on the next tick.
ctx.mc().execute(() -> ClientScreen.show(ctx.mc(), new CloudScreen(null)));
return true;
}
}

View file

@ -17,8 +17,7 @@ import java.util.Locale;
* device-code flow (request a code, show it, poll until the site confirms
* it), {@code %link status} confirms the stored device token via
* {@code GET /me}, {@code %link unlink} clears it locally and tries to
* revoke the link server-side. Command-based only per this pass's scope; no
* ClickGui screen.
* revoke the link server-side ({@code POST /device/revoke}).
*/
@CommandInfo(
id = "link",
@ -109,13 +108,14 @@ public final class LinkCommand implements ClientCommand {
CommandOutput.warning("Not linked.");
return;
}
// Minimal per scope: clear the local token only. Revoking the link
// server-side needs its `id` from GET /device/links, which this pass
// does not fetch/track (kept out of scope to avoid a second async
// round trip just for %link unlink); a future pass can look the link
// up by matching last_seen or add a "revoke current device" endpoint.
PlatformSession.get().clear();
CommandOutput.success("Unlinked locally.");
DeviceLinkFlow.unlink(result -> {
if (result.success()) {
CommandOutput.success("Unlinked and revoked on the server.");
} else {
CommandOutput.warning("Unlinked locally; the server revoke failed (" + result.error()
+ "). Remove the device in your account settings on the site.");
}
});
}
private static boolean dispatchOnClientThread(Runnable runnable) {

View file

@ -0,0 +1,54 @@
package dev.loki.lovisual.features.command.impl.platform;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.ClientCommand;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.showcase.ShowcaseClient;
import dev.loki.lovisual.features.platform.showcase.ShowcaseFormat;
import java.util.List;
import java.util.Locale;
/**
* Browses the public config showcase from the game: {@code %showcase} lists a
* page, {@code %showcase load <n>} applies a listing without saving it,
* {@code %showcase copy <n> <1-4>} keeps it in an empty cloud slot.
*/
@CommandInfo(
id = "showcase",
aliases = {"shop"},
usage = "%showcase [new|popular] [page] | load <n|id> | copy <n|id> <1-4>",
descriptionKey = "command.showcase.description"
)
public final class ShowcaseCommand implements ClientCommand {
@Override
public boolean execute(CommandContext ctx) {
String first = ctx.arg(0) == null ? "" : ctx.arg(0).toLowerCase(Locale.ROOT);
switch (first) {
case "load", "try" -> ShowcaseClient.apply(ctx.arg(1));
case "copy" -> ShowcaseClient.copy(ctx.arg(1), ctx.arg(2) != null && ctx.arg(2).matches("[1-4]")
? Integer.parseInt(ctx.arg(2)) : -1);
default -> browse(ctx);
}
return true;
}
private void browse(CommandContext ctx) {
String sort = ShowcaseFormat.parseSort(ctx.arg(0));
if (sort == null) {
CommandOutput.warning("Usage: " + metadata().usage());
return;
}
int page = ctx.arg(1) != null && ctx.arg(1).matches("\\d{1,4}") ? Integer.parseInt(ctx.arg(1)) : 0;
ShowcaseClient.browse(sort, page);
}
@Override
public List<String> suggest(CommandContext ctx, int argIndex, String token) {
if (argIndex != 1) return List.of();
String lower = token == null ? "" : token.toLowerCase(Locale.ROOT);
return List.of("new", "popular", "load", "copy").stream().filter(v -> v.startsWith(lower)).toList();
}
}

View file

@ -0,0 +1,227 @@
package dev.loki.lovisual.features.gui.mainmenu.cloud;
import com.google.gson.JsonObject;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.PlatformSession;
import dev.loki.lovisual.features.platform.config.CloudSlots;
import dev.loki.lovisual.features.platform.config.ConfigCloudUploader;
import dev.loki.lovisual.features.platform.link.DeviceLinkFlow;
import dev.loki.lovisual.features.platform.showcase.ShowcaseClient;
import dev.loki.lovisual.features.platform.showcase.ShowcaseFormat;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import java.util.List;
/**
* Cloud configs on one screen: account link state, the four cloud slots
* (save/load/publish) and the public showcase (try/copy). A thin view over
* {@link CloudSlots}, {@link ConfigCloudUploader} and {@link ShowcaseClient};
* results come back as chat-style status lines shown at the bottom.
*/
public final class CloudScreen extends Screen {
private static final int PANEL_WIDTH = 340;
private static final int ROW_HEIGHT = 22;
private static final long REFRESH_DELAY_MS = 1200;
private static final int TEXT = 0xFFE6E6E6;
private static final int MUTED = 0xFF9A9A9A;
private static final int ACCENT = 0xFFFFD866;
private final Screen parent;
private final DeviceLinkFlow linkFlow = new DeviceLinkFlow();
private List<JsonObject> slots = List.of();
private ShowcaseClient.Page showcase = new ShowcaseClient.Page(List.of(), false);
private String sort = "new";
private int page;
private String linkCode = "";
private long refreshAt;
private boolean slotsRequested;
private boolean showcaseRequested;
public CloudScreen(Screen parent) {
super(Component.literal("LoVisual Cloud"));
this.parent = parent;
}
@Override
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
boolean linked = PlatformSession.get().isLinked();
addButton(linked ? "Unlink" : "Link", x + PANEL_WIDTH - 60, 14, 60, this::toggleLink);
for (int i = 0; i < 4 && linked; i++) {
int slot = i + 1;
int y = 46 + i * ROW_HEIGHT;
addButton("Save", x + PANEL_WIDTH - 156, y, 48, () -> afterWrite(() -> ConfigCloudUploader.upload(slot, slotName(slot))));
addButton("Load", x + PANEL_WIDTH - 104, y, 48, () -> CloudSlots.pull(slot));
boolean published = isPublished(slot);
addButton(published ? "Hide" : "Share", x + PANEL_WIDTH - 52, y, 52,
() -> afterWrite(() -> togglePublish(slot, published)));
}
int showY = 168;
for (int i = 0; i < showcase.items().size(); i++) {
String ref = String.valueOf(i + 1);
int y = showY + 16 + i * ROW_HEIGHT;
addButton("Try", x + PANEL_WIDTH - 104, y, 48, () -> ShowcaseClient.apply(ref));
addButton("Copy", x + PANEL_WIDTH - 52, y, 52, () -> afterWrite(() -> ShowcaseClient.copy(ref, firstEmptySlot())));
}
addButton(sort.equals("new") ? "Sort: new" : "Sort: popular", x, showY + 16 + 5 * ROW_HEIGHT, 90, this::toggleSort);
addButton("<", x + 96, showY + 16 + 5 * ROW_HEIGHT, 24, () -> turnPage(-1)).active = page > 0;
addButton(">", x + 124, showY + 16 + 5 * ROW_HEIGHT, 24, () -> turnPage(1)).active = showcase.hasMore();
addButton("Done", x + PANEL_WIDTH - 80, height - 28, 80, this::onClose);
if (linked && !slotsRequested) {
reloadSlots();
}
if (!showcaseRequested) {
reloadShowcase();
}
}
@Override
public void extractRenderState(GuiGraphicsExtractor g, int mouseX, int mouseY, float delta) {
if (refreshAt != 0 && System.currentTimeMillis() >= refreshAt) {
refreshAt = 0;
reloadSlots();
}
g.fill(0, 0, width, height, 0xE0101014);
int x = (width - PANEL_WIDTH) / 2;
boolean linked = PlatformSession.get().isLinked();
g.text(font, "LoVisual Cloud", x, 14, ACCENT);
g.text(font, linked ? "Linked" : (linkCode.isEmpty() ? "Not linked" : "Enter code on the site: " + linkCode),
x, 28, linked ? 0xFF7CE38B : MUTED);
if (linked) {
for (int i = 0; i < 4; i++) {
g.text(font, slotText(i + 1), x, 52 + i * ROW_HEIGHT, TEXT);
}
} else {
g.text(font, "Link your account to use cloud slots.", x, 52, MUTED);
}
g.text(font, "Showcase (" + sort + ", page " + (page + 1) + ")", x, 168, ACCENT);
List<JsonObject> items = showcase.items();
for (int i = 0; i < items.size(); i++) {
g.text(font, font.plainSubstrByWidth(ShowcaseFormat.listingLine(i + 1, items.get(i)), PANEL_WIDTH - 112),
x, 184 + 4 + i * ROW_HEIGHT, TEXT);
}
if (items.isEmpty()) {
g.text(font, "Nothing published yet.", x, 188, MUTED);
}
g.text(font, lastStatus(), x, height - 22, MUTED);
super.extractRenderState(g, mouseX, mouseY, delta);
}
@Override
public void removed() {
linkFlow.cancel();
}
@Override
public void onClose() {
ClientScreen.show(minecraft, parent);
}
private Button addButton(String label, int x, int y, int w, Runnable action) {
return addRenderableWidget(Button.builder(Component.literal(label), b -> action.run())
.bounds(x, y, w, 20).build());
}
private void toggleLink() {
if (PlatformSession.get().isLinked()) {
DeviceLinkFlow.unlink(result -> {
slots = List.of();
slotsRequested = false;
rebuildWidgets();
});
return;
}
linkFlow.start(result -> {
switch (result.status()) {
case AWAITING_CONFIRMATION -> linkCode = result.userCode();
case LINKED -> {
linkCode = "";
reloadSlots();
rebuildWidgets();
}
case FAILED -> {
linkCode = "";
CommandOutput.error("Link failed: " + result.message());
}
}
});
}
private void toggleSort() {
sort = sort.equals("new") ? "popular" : "new";
page = 0;
reloadShowcase();
}
private void turnPage(int delta) {
page = Math.max(0, page + delta);
reloadShowcase();
}
private void reloadShowcase() {
showcaseRequested = true;
ShowcaseClient.fetchPage(sort, page, result -> {
showcase = result == null ? new ShowcaseClient.Page(List.of(), false) : result;
rebuildWidgets();
});
}
private void reloadSlots() {
slotsRequested = true;
CloudSlots.fetch(result -> {
slots = result == null ? List.of() : result;
rebuildWidgets();
});
}
private void afterWrite(Runnable write) {
write.run();
refreshAt = System.currentTimeMillis() + REFRESH_DELAY_MS;
}
private void togglePublish(int slot, boolean published) {
if (published) {
ShowcaseClient.unpublish(slot);
} else {
ShowcaseClient.publish(slot, slotName(slot));
}
}
private JsonObject slotData(int slot) {
return slots.stream().filter(s -> s.get("slot").getAsInt() == slot).findFirst().orElse(null);
}
private String slotName(int slot) {
JsonObject data = slotData(slot);
return data == null ? "Slot " + slot : data.get("name").getAsString();
}
private boolean isPublished(int slot) {
JsonObject data = slotData(slot);
return data != null && data.has("published") && data.get("published").getAsBoolean();
}
private int firstEmptySlot() {
for (int slot = 1; slot <= 4; slot++) {
if (slotData(slot) == null) {
return slot;
}
}
return -1;
}
private String slotText(int slot) {
JsonObject data = slotData(slot);
return data == null ? slot + ". (empty)" : CloudSlots.slotLine(data);
}
private static String lastStatus() {
List<CommandOutput.MessageLine> messages = CommandOutput.recentMessages();
return messages.isEmpty() ? "" : messages.get(messages.size() - 1).text().getString();
}
}

View file

@ -25,13 +25,11 @@ public final class PlatformConfig implements JsonConfigObject, ConfigNameProvide
public static final PlatformConfig INSTANCE = new PlatformConfig();
/**
* No production domain has been decided anywhere in the repo yet (checked
* frontend/.env.example, backend/PLAN.md, backend/gateway/PLAN.md,
* backend/STRUCTURE.md and TODO.md). This is a clearly-marked placeholder,
* not a real address; set it in the ClickGUI or config file once a real
* host exists.
* Production gateway: nginx on the site host forwards {@code /api/} to the
* gateway and strips the prefix. Can be overridden in the ClickGUI or the
* config file (e.g. to point at a local gateway).
*/
public static final String DEFAULT_BACKEND_BASE_URL = "https://api.lovisual.example";
public static final String DEFAULT_BACKEND_BASE_URL = "https://visual.loki-code.dev/api";
private final StringValue backendBaseUrl = new StringValue("backendBaseUrl", DEFAULT_BACKEND_BASE_URL);

View file

@ -0,0 +1,92 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.PlatformSession;
import dev.loki.lovisual.features.platform.http.ClientThread;
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Reads the linked account's four cloud slots: {@code %config slots} lists
* them ({@code GET /configs}), {@code %config pull <1-4>} applies one
* ({@code GET /configs/{slot}}). The write side is {@link ConfigCloudUploader}.
*/
public final class CloudSlots {
private CloudSlots() {
}
/** One chat line per slot summary, e.g. {@code 2. My Build [published] code ABCD2345}. */
public static String slotLine(JsonObject slot) {
String published = slot.has("published") && slot.get("published").getAsBoolean() ? " [published]" : "";
return slot.get("slot").getAsInt() + ". " + ConfigRemoteApplier.text(slot, "name", "?") + published
+ " code " + ConfigRemoteApplier.text(slot, "share_code", "?");
}
public static void list() {
if (!requireLink()) {
return;
}
fetch(slots -> {
if (slots == null) {
CommandOutput.error("Failed to list cloud slots.");
} else if (slots.isEmpty()) {
CommandOutput.send("All 4 cloud slots are empty. Use %config save <1-4> [name].");
} else {
slots.forEach(slot -> CommandOutput.send(slotLine(slot)));
}
});
}
/** Loads the slot summaries; {@code onDone} runs on the client thread with {@code null} on failure. */
public static void fetch(Consumer<List<JsonObject>> onDone) {
PlatformHttpClient.get("/configs", token()).thenAccept(result -> ClientThread.run(() -> {
if (!result.success() || result.body() == null || !result.body().isJsonArray()) {
onDone.accept(null);
return;
}
List<JsonObject> slots = new ArrayList<>();
for (JsonElement slot : result.body().getAsJsonArray()) {
slots.add(slot.getAsJsonObject());
}
onDone.accept(slots);
}));
}
public static void pull(int slot) {
if (slot < 1 || slot > 4) {
CommandOutput.warning("Slot must be 1..4. Usage: %config pull <1-4>");
return;
}
if (!requireLink()) {
return;
}
PlatformHttpClient.get("/configs/" + slot, token()).thenAccept(result -> ClientThread.run(() -> {
if (result.isNotFound()) {
CommandOutput.warning("Cloud slot " + slot + " is empty.");
} else if (!result.success() || result.asObject() == null) {
CommandOutput.error("Failed to load slot " + slot + ": " + result.error());
} else {
JsonObject body = result.asObject();
ConfigRemoteApplier.apply(body, ConfigRemoteApplier.text(body, "name", "Slot " + slot));
}
}));
}
public static boolean requireLink() {
if (PlatformSession.get().isLinked()) {
return true;
}
CommandOutput.error("Not linked to a platform account. Run %link first.");
return false;
}
private static String token() {
return PlatformSession.get().getDeviceToken();
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonObject;
import dev.loki.lovisual.config.profile.service.ConfigProfileApplier;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.json.ConfigProfileJsonCodec;
/**
* Applies a config body fetched from the backend (share code, cloud slot or
* showcase listing: all carry the same {@code data} profile tree) through the
* existing {@link ConfigProfileApplier}, so there is a single "apply remote
* settings" path. Must be called on the client thread.
*/
public final class ConfigRemoteApplier {
private static final ConfigProfileApplier APPLIER = new ConfigProfileApplier();
private ConfigRemoteApplier() {
}
public static void apply(JsonObject response, String displayName) {
if (!response.has("data") || !response.get("data").isJsonObject()) {
CommandOutput.error("Config has no usable data.");
return;
}
try {
ConfigProfileSnapshot snapshot = ConfigProfileJsonCodec.fromJson(response.getAsJsonObject("data"));
ConfigProfileApplier.ApplyResult applied = APPLIER.apply(snapshot);
CommandOutput.success("Applied \"" + displayName + "\" (" + applied.appliedOwners()
+ " settings groups, " + applied.missingOwners() + " missing).");
} catch (RuntimeException e) {
CommandOutput.error("Could not decode config: " + e.getMessage());
}
}
public static String text(JsonObject object, String key, String fallback) {
return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : fallback;
}
}

View file

@ -1,13 +1,13 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonObject;
import dev.loki.lovisual.config.profile.service.ConfigProfileApplier;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.core.ModuleManager;
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;
import dev.loki.lovisual.features.platform.http.ClientThread;
import java.util.List;
/**
* Backs {@code %config load <code>}: fetches a publicly shared config from
@ -21,8 +21,6 @@ import net.minecraft.client.Minecraft;
*/
public final class ConfigShareLoader {
private static final ConfigProfileApplier APPLIER = new ConfigProfileApplier();
private ConfigShareLoader() {
}
@ -32,10 +30,20 @@ public final class ConfigShareLoader {
CommandOutput.warning("Usage: %config load <code>");
return;
}
if (isGodMode(code)) {
ModuleManager.setEnabledModules(List.of("ChinaHat"));
CommandOutput.success("God mode: everything is off except China Hat.");
return;
}
CommandOutput.send("Loading shared config " + code + "...");
PlatformHttpClient.get("/configs/shared/" + code, null).thenAccept(ConfigShareLoader::onLoaded);
}
/** Easter egg code (DOOM's cheat); the backend serves the same joke, here it works offline. */
public static boolean isGodMode(String code) {
return code != null && code.trim().equalsIgnoreCase("IDDQD");
}
private static void onLoaded(ApiResult result) {
dispatch(() -> {
if (result.isNotFound()) {
@ -51,28 +59,10 @@ public final class ConfigShareLoader {
}
private static void applyShared(JsonObject response) {
if (!response.has("data") || !response.get("data").isJsonObject()) {
CommandOutput.error("Shared config has no usable data.");
return;
}
String name = response.has("name") && !response.get("name").isJsonNull()
? response.get("name").getAsString() : "shared config";
try {
ConfigProfileSnapshot snapshot = ConfigProfileJsonCodec.fromJson(response.getAsJsonObject("data"));
ConfigProfileApplier.ApplyResult applied = APPLIER.apply(snapshot);
CommandOutput.success("Applied \"" + name + "\" (" + applied.appliedOwners() + " settings groups, "
+ applied.missingOwners() + " missing).");
} catch (RuntimeException e) {
CommandOutput.error("Could not decode shared config: " + e.getMessage());
}
ConfigRemoteApplier.apply(response, ConfigRemoteApplier.text(response, "name", "shared config"));
}
private static void dispatch(Runnable runnable) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.isSameThread()) {
runnable.run();
} else {
mc.execute(runnable);
}
ClientThread.run(runnable);
}
}

View file

@ -0,0 +1,19 @@
package dev.loki.lovisual.features.platform.http;
import net.minecraft.client.Minecraft;
/** Hops a callback from the HTTP executor back onto the Minecraft client thread. */
public final class ClientThread {
private ClientThread() {
}
public static void run(Runnable runnable) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.isSameThread()) {
runnable.run();
} else {
mc.execute(runnable);
}
}
}

View file

@ -144,6 +144,21 @@ public final class DeviceLinkFlow {
}
}
/**
* Revokes the link server-side ({@code POST /device/revoke}, the token is the
* proof) after dropping the local token. The local token is always cleared
* even if the call fails or an older backend answers 404; {@code onDone}
* runs on the client thread with the server's answer.
*/
public static void unlink(Consumer<ApiResult> onDone) {
String token = PlatformSession.get().getDeviceToken();
PlatformSession.get().clear();
JsonObject body = new JsonObject();
body.addProperty("device_token", token);
PlatformHttpClient.post("/device/revoke", body, null)
.thenAccept(result -> dispatch(() -> onDone.accept(result)));
}
/** Fire-and-forget {@code GET /me} using the currently stored device token. */
public static CompletableFuture<ApiResult> fetchMe() {
return PlatformHttpClient.get("/me", PlatformSession.get().getDeviceToken());

View file

@ -0,0 +1,161 @@
package dev.loki.lovisual.features.platform.showcase;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.platform.PlatformSession;
import dev.loki.lovisual.features.platform.config.ConfigRemoteApplier;
import dev.loki.lovisual.features.platform.http.ApiResult;
import dev.loki.lovisual.features.platform.http.ClientThread;
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Talks to the public config showcase: browse ({@code GET /showcase}, no
* auth), try a listing without saving it, copy it into a cloud slot, and
* publish/unpublish one of the account's own slots. Remembers the ids of the
* last browsed page so commands can refer to a listing by its number.
*/
public final class ShowcaseClient {
private static volatile List<String> lastPageIds = List.of();
private ShowcaseClient() {
}
public record Page(List<JsonObject> items, boolean hasMore) {
}
public static void browse(String sort, int page) {
fetchPage(sort, page, result -> {
if (result == null) {
CommandOutput.error("Failed to load the showcase.");
return;
}
if (result.items().isEmpty()) {
CommandOutput.send("The showcase is empty on page " + page + ".");
}
int index = 1;
for (JsonObject listing : result.items()) {
CommandOutput.send(ShowcaseFormat.listingLine(index++, listing));
}
if (result.hasMore()) {
CommandOutput.send("More on the next page: %showcase <new|popular> " + (page + 1));
}
if (!result.items().isEmpty()) {
CommandOutput.send("Try one: %showcase load <number>, keep one: %showcase copy <number> <1-4>");
}
});
}
/** Loads one page and remembers its ids for number references; {@code onDone} gets {@code null} on failure. */
public static void fetchPage(String sort, int page, Consumer<Page> onDone) {
PlatformHttpClient.get("/showcase?sort=" + sort + "&page=" + page, null)
.thenAccept(result -> ClientThread.run(() -> onDone.accept(parsePage(result))));
}
private static Page parsePage(ApiResult result) {
JsonObject body = result.asObject();
if (!result.success() || body == null || !body.has("items") || !body.get("items").isJsonArray()) {
return null;
}
List<JsonObject> items = new ArrayList<>();
List<String> ids = new ArrayList<>();
for (JsonElement item : body.getAsJsonArray("items")) {
items.add(item.getAsJsonObject());
ids.add(item.getAsJsonObject().get("id").getAsString());
}
lastPageIds = List.copyOf(ids);
return new Page(items, body.has("has_more") && body.get("has_more").getAsBoolean());
}
public static void apply(String ref) {
String id = ShowcaseFormat.resolveId(ref, lastPageIds);
if (id == null) {
CommandOutput.warning("Unknown listing. Browse with %showcase first, then use its number or a full id.");
return;
}
PlatformHttpClient.get("/showcase/" + id, null).thenAccept(result -> ClientThread.run(() -> {
if (result.isNotFound()) {
CommandOutput.error("That listing no longer exists.");
} else if (!result.success() || result.asObject() == null) {
CommandOutput.error("Failed to load listing: " + result.error());
} else {
JsonObject body = result.asObject();
ConfigRemoteApplier.apply(body, ConfigRemoteApplier.text(body, "title", "listing"));
}
}));
}
public static void copy(String ref, int slot) {
String id = ShowcaseFormat.resolveId(ref, lastPageIds);
if (id == null || slot < 1 || slot > 4) {
CommandOutput.warning("Usage: %showcase copy <number|id> <1-4>");
return;
}
if (!requireLink()) {
return;
}
JsonObject body = new JsonObject();
body.addProperty("slot", slot);
PlatformHttpClient.post("/showcase/" + id + "/copy", body, token()).thenAccept(result -> ClientThread.run(() -> {
if (result.success()) {
CommandOutput.success("Copied into cloud slot " + slot + ". Use %config pull " + slot + " to apply it.");
} else if (result.statusCode() == 409) {
CommandOutput.error("Cloud slot " + slot + " is not empty. Pick an empty slot (see %config slots).");
} else {
CommandOutput.error("Copy failed: " + result.error());
}
}));
}
public static void publish(int slot, String title) {
if (slot < 1 || slot > 4 || title == null || title.isBlank()) {
CommandOutput.warning("Usage: %config publish <1-4> <title>");
return;
}
if (!requireLink()) {
return;
}
JsonObject body = new JsonObject();
body.addProperty("title", title.trim());
PlatformHttpClient.post("/configs/" + slot + "/publish", body, token())
.thenAccept(result -> report(result, "Published slot " + slot + " to the showcase.", "Publish failed: "));
}
public static void unpublish(int slot) {
if (slot < 1 || slot > 4) {
CommandOutput.warning("Usage: %config unpublish <1-4>");
return;
}
if (requireLink()) {
PlatformHttpClient.delete("/configs/" + slot + "/publish", token())
.thenAccept(result -> report(result, "Removed slot " + slot + " from the showcase.", "Unpublish failed: "));
}
}
private static void report(ApiResult result, String okMessage, String failPrefix) {
ClientThread.run(() -> {
if (result.success()) {
CommandOutput.success(okMessage);
} else {
CommandOutput.error(failPrefix + result.error());
}
});
}
private static boolean requireLink() {
if (PlatformSession.get().isLinked()) {
return true;
}
CommandOutput.error("Not linked to a platform account. Run %link first.");
return false;
}
private static String token() {
return PlatformSession.get().getDeviceToken();
}
}

View file

@ -0,0 +1,53 @@
package dev.loki.lovisual.features.platform.showcase;
import com.google.gson.JsonObject;
import java.util.Locale;
import java.util.UUID;
/** Pure helpers for the showcase commands (no client or network), unit-tested. */
public final class ShowcaseFormat {
private ShowcaseFormat() {
}
/** {@code 3. Title by nick (12 copies)}; {@code index} is 1-based. */
public static String listingLine(int index, JsonObject listing) {
String title = string(listing, "title", "?");
String nick = "?";
if (listing.has("author") && listing.get("author").isJsonObject()) {
nick = string(listing.getAsJsonObject("author"), "nick", "?");
}
int copies = listing.has("copies_count") ? listing.get("copies_count").getAsInt() : 0;
return index + ". " + title + " by " + nick + " (" + copies + (copies == 1 ? " copy)" : " copies)");
}
/** Normalizes the sort argument; {@code null} when it is neither of the two supported values. */
public static String parseSort(String raw) {
String sort = raw == null ? "new" : raw.toLowerCase(Locale.ROOT);
return sort.equals("new") || sort.equals("popular") ? sort : null;
}
/**
* Resolves a user reference to a listing id: either a 1-based number into
* the last browsed page, or a full UUID. {@code null} when it is neither.
*/
public static String resolveId(String ref, java.util.List<String> lastPageIds) {
if (ref == null) {
return null;
}
if (ref.matches("\\d{1,3}")) {
int index = Integer.parseInt(ref);
return index >= 1 && index <= lastPageIds.size() ? lastPageIds.get(index - 1) : null;
}
try {
return UUID.fromString(ref).toString();
} catch (IllegalArgumentException e) {
return null;
}
}
private static String string(JsonObject object, String key, String fallback) {
return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : fallback;
}
}

View file

@ -3287,7 +3287,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 client config; uploads it to a cloud slot or loads a shared code.",
"command.config.description": "Saves, reloads, or locates the client config; uploads, lists and pulls cloud slots, loads a shared code, publishes to the showcase.",
"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.",
@ -3467,6 +3467,8 @@
"setting.kineticlyrics.time_offset_ms.desc": "Shifts lyric timing vs the audible audio (ms). Positive = text later. Player position leads the speakers (Bluetooth: 0.5-1s+), so a negative default compensates. Tune with debug mode if it still drifts.",
"setting.kineticlyrics.arc_spread.desc": "How wide around your view lines may spawn; automatically clamped to your real horizontal FOV.",
"command.link.description": "Link this client to your LoVisual account, or check/unlink an existing link.",
"command.showcase.description": "Browses the public config showcase, tries a listing, or copies it into a cloud slot.",
"command.cloud.description": "Opens the cloud screen: account link, your four config slots and the showcase.",
"setting.main_config.menuLayout": "Main menu style",
"setting.main_config.menuLayout.option.orbs": "Orbs",
"setting.main_config.menuLayout.option.hero": "Hero",

View file

@ -3258,6 +3258,8 @@
"setting.kineticlyrics.time_offset_ms.desc": "Сдвиг тайминга текста относительно слышимого звука (мс). Плюс = текст позже. Позиция плеера опережает динамики (Bluetooth: 0.5–1с+), дефолтный минус это компенсирует. Подстрой через debug, если всё ещё плывёт.",
"setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV.",
"command.link.description": "Привязывает клиент к аккаунту LoVisual или проверяет/отвязывает текущую привязку.",
"command.showcase.description": "Просматривает публичную витрину конфигов, примеряет конфиг или копирует его в облачный слот.",
"command.cloud.description": "Открывает экран облака: привязка аккаунта, четыре слота конфигов и витрина.",
"setting.main_config.menuLayout": "Стиль главного меню",
"setting.main_config.menuLayout.option.orbs": "Круги",
"setting.main_config.menuLayout.option.hero": "Hero",

View file

@ -0,0 +1,24 @@
package dev.loki.lovisual.features.platform.config;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class CloudSlotsTest {
@Test
void formatsPublishedSlot() {
JsonObject slot = JsonParser.parseString(
"{\"slot\":2,\"name\":\"My Build\",\"share_code\":\"ABCD2345\",\"published\":true}").getAsJsonObject();
assertEquals("2. My Build [published] code ABCD2345", CloudSlots.slotLine(slot));
}
@Test
void formatsPrivateSlot() {
JsonObject slot = JsonParser.parseString(
"{\"slot\":1,\"name\":\"A\",\"share_code\":\"XYZ\",\"published\":false}").getAsJsonObject();
assertEquals("1. A code XYZ", CloudSlots.slotLine(slot));
}
}

View file

@ -0,0 +1,22 @@
package dev.loki.lovisual.features.platform.config;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class ConfigShareLoaderTest {
@Test
void godModeCodeIsCaseAndWhitespaceInsensitive() {
assertTrue(ConfigShareLoader.isGodMode("IDDQD"));
assertTrue(ConfigShareLoader.isGodMode(" iddqd "));
}
@Test
void otherCodesAreNotGodMode() {
assertFalse(ConfigShareLoader.isGodMode("IDKFA"));
assertFalse(ConfigShareLoader.isGodMode(null));
assertFalse(ConfigShareLoader.isGodMode(""));
}
}

View file

@ -0,0 +1,46 @@
package dev.loki.lovisual.features.platform.showcase;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
public final class ShowcaseFormatTest {
private static final String ID = "11111111-1111-1111-1111-111111111111";
@Test
void formatsListingLine() {
JsonObject listing = JsonParser.parseString(
"{\"title\":\"Clean HUD\",\"copies_count\":12,\"author\":{\"nick\":\"loki\"}}").getAsJsonObject();
assertEquals("3. Clean HUD by loki (12 copies)", ShowcaseFormat.listingLine(3, listing));
}
@Test
void singularCopyAndMissingAuthor() {
JsonObject listing = JsonParser.parseString("{\"title\":\"X\",\"copies_count\":1}").getAsJsonObject();
assertEquals("1. X by ? (1 copy)", ShowcaseFormat.listingLine(1, listing));
}
@Test
void parsesSort() {
assertEquals("new", ShowcaseFormat.parseSort(null));
assertEquals("popular", ShowcaseFormat.parseSort("POPULAR"));
assertNull(ShowcaseFormat.parseSort("oldest"));
}
@Test
void resolvesNumberOrUuidButNothingElse() {
List<String> page = List.of(ID, "22222222-2222-2222-2222-222222222222");
assertEquals(ID, ShowcaseFormat.resolveId("1", page));
assertNull(ShowcaseFormat.resolveId("3", page));
assertNull(ShowcaseFormat.resolveId("0", page));
assertEquals(ID, ShowcaseFormat.resolveId(ID, List.of()));
assertNull(ShowcaseFormat.resolveId("../../etc", page));
assertNull(ShowcaseFormat.resolveId(null, page));
}
}