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:
parent
e00ea8eb11
commit
44353e893c
24 changed files with 894 additions and 52 deletions
15
TODO.md
15
TODO.md
|
|
@ -132,14 +132,13 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло
|
|||
`releases/latest/download/lovisual.jar` в GitHub; в план сайта добавлены
|
||||
редактор тем в браузере, страница «Скачать» + ченджлог, публичные профили;
|
||||
лендинг — насыщенный (живой ClickGui/HUD, частицы), см. `frontend/PLAN.md`.
|
||||
Интеграция мода 2026-09-27: первый шаг — URL бэкенда стал runtime-настройкой,
|
||||
а не константой сборки. Новый `PlatformConfig` (`mod/src/main/java/dev/loki/lovisual/features/platform/`,
|
||||
+ `BackendEndpoint` нормализатор) хранит `backendBaseUrl` через тот же
|
||||
`ConfigSerializer`, редактируется в ClickGui (категория UTILITY главных
|
||||
настроек, рядом со `StyleConfig`). Значение по умолчанию — плейсхолдер
|
||||
`https://api.lovisual.example` (реальный прод-домен ещё нигде в репо не
|
||||
зафиксирован). HTTP-клиент и сама интеграция (auth/device-link/витрина) —
|
||||
отдельная будущая задача.
|
||||
Интеграция мода (2026-10-02): `PlatformHttpClient`, `%link` (device-link),
|
||||
`%config save <1-4>` / `load <код>` / `slots` / `pull <1-4>` /
|
||||
`publish <1-4> <название>` / `unpublish <1-4>`, `%showcase [new|popular] [стр]` /
|
||||
`load <n|id>` / `copy <n|id> <1-4>`; URL по умолчанию — прод
|
||||
`https://visual.loki-code.dev/api`. Осталось: серверный отзыв привязки при
|
||||
`%link unlink` (нужен id текущей привязки), ClickGui-экран вместо команд,
|
||||
`%config load IDDQD`, ручная проверка против прода.
|
||||
Решение 2026-09-29 (см. задачу «Скачивание» ниже): прямая ссылка на GitHub
|
||||
из кнопок убрана — jar отдаёт наш бэкенд, GitHub остаётся второй кнопкой
|
||||
под лентой «временно не работает».
|
||||
|
|
|
|||
|
|
@ -99,3 +99,19 @@ pub async fn revoke_link(
|
|||
Err(AppError::NotFound("no such device link".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RevokeRequest {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
/// Self-revoke for the mod's `%link unlink`: the gateway strips the bearer
|
||||
/// header before forwarding, so the token travels in the body. Always 204 so
|
||||
/// the endpoint is not an oracle for which tokens exist.
|
||||
pub async fn revoke_current(
|
||||
State(state): State<DeviceState>,
|
||||
AppJson(req): AppJson<RevokeRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
links::revoke_by_token(&state.pool, &req.device_token).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,3 +52,13 @@ pub async fn revoke(pool: &PgPool, account_id: Uuid, link_id: Uuid) -> Result<bo
|
|||
.await?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Deletes the link a device token belongs to (RFC 7009-style self-revoke:
|
||||
/// holding the token is the proof). `false` when the token is unknown.
|
||||
pub async fn revoke_by_token(pool: &PgPool, token: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM device_links WHERE device_token_hash = $1")
|
||||
.bind(hash_token(token))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
|||
.route("/device/code", post(device::handlers::create_code))
|
||||
.route("/device/confirm", post(device::handlers::confirm))
|
||||
.route("/device/token", post(device::handlers::token))
|
||||
.route("/device/revoke", post(device::handlers::revoke_current))
|
||||
.route("/device/links", get(device::handlers::list_links))
|
||||
.route(
|
||||
"/device/links/{id}",
|
||||
|
|
|
|||
|
|
@ -114,3 +114,36 @@ async fn unknown_device_token_does_not_authenticate() {
|
|||
.unwrap();
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_revoke_by_token_deletes_only_that_link() {
|
||||
let pool = common::test_pool().await;
|
||||
let server = common::test_server(accounts_service::build_app(
|
||||
pool.clone(),
|
||||
&common::test_config(),
|
||||
));
|
||||
let (account, _) = common::register_account(&server).await;
|
||||
let t1 = link_device(&server, account).await;
|
||||
let t2 = link_device(&server, account).await;
|
||||
|
||||
server
|
||||
.post("/device/revoke")
|
||||
.json(&serde_json::json!({ "device_token": t1 }))
|
||||
.await
|
||||
.assert_status(StatusCode::NO_CONTENT);
|
||||
// Unknown tokens answer the same way: no oracle.
|
||||
server
|
||||
.post("/device/revoke")
|
||||
.json(&serde_json::json!({ "device_token": "lvd_nope" }))
|
||||
.await
|
||||
.assert_status(StatusCode::NO_CONTENT);
|
||||
|
||||
let gone = accounts_service::device::links::authenticate(&pool, &t1)
|
||||
.await
|
||||
.unwrap();
|
||||
let kept = accounts_service::device::links::authenticate(&pool, &t2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(gone, None);
|
||||
assert_eq!(kept, Some(account));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ pub fn rules() -> Vec<Rule> {
|
|||
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
|
||||
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
|
||||
rule(Method::POST, "/device/token", Quota::per_minute(n(30)), Ip),
|
||||
rule(Method::POST, "/device/revoke", Quota::per_minute(n(10)), Ip),
|
||||
rule(Method::PUT, "/configs/*", Quota::per_minute(n(20)), Account),
|
||||
rule(
|
||||
Method::GET,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue