refactor: replace the 1190-line theme system with a fixed warm theme; drop theme packs, wallpapers and their tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Sonnet 5.5 committed 2026-11-08 14:56:14 +08:00
1 parent 21fcac1b0c
commit 2c8446fa33
27 files changed
+38 -5599

No files matched your search

@@ -131,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
@@ -59,7 +58,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
/// Default launcher theme used when no stored theme reference is available.
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
/// Theme appearance override key for theme brightness mode.
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
@@ -225,23 +223,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
// UI
// Theme selection
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
@SerializedName("selectedTheme")
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
/// Returns the selected installed theme property.
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
return selectedTheme;
}
/// Returns the selected installed theme, falling back to the built-in default theme.
public ThemeReference getSelectedThemeOrDefault() {
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
}
// Theme appearance overrides
/// Theme appearance setting keys overridden by the launcher.
@@ -1,24 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.layout.Background;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// A launcher background and the opacity applied by its rendering node.
///
/// @param background the JavaFX background rendered by the launcher
/// @param opacity the node opacity in the range `[0, 1]`
@NotNullByDefault
public record LauncherBackground(Background background, double opacity) {
/// Creates a launcher background value.
public LauncherBackground {
Objects.requireNonNull(background);
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
}
}
}
@@ -1,38 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.*;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.*;
/// Concrete launcher theme values resolved for MonetFX.
///
/// @param primaryColorSeed Color seed used to generate the MonetFX color scheme.
/// @param brightness Brightness used by the generated color scheme.
/// @param colorStyle MonetFX color style used by the generated color scheme.
/// @param contrast MonetFX contrast level used by the generated color scheme.
@NotNullByDefault
public record ResolvedTheme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast) {
/// Default launcher theme used when no custom theme values are configured.
public static final ResolvedTheme DEFAULT = new ResolvedTheme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
/// Converts these resolved values to a MonetFX color scheme.
///
/// @return the generated color scheme
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
}
@@ -1,229 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// One selectable theme inside a theme pack.
///
/// The theme object's top-level appearance fields provide default values. Each
/// matching override is applied in array order to produce the resolved appearance.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
@NotNullByDefault
public record Theme(
@Nullable String id,
@Nullable LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable LocalizedText description,
@Nullable String icon,
ThemeAppearance appearance,
@Unmodifiable List<ThemeOverride> overrides) {
/// Creates a theme.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
public Theme {
if (id != null) {
id = ThemePackManifest.requireThemeId(id);
}
if (name != null && name.mayBeEmpty()) {
throw new IllegalArgumentException("Theme name is empty: " + name);
}
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
authors = List.copyOf(authors);
Objects.requireNonNull(appearance);
overrides = List.copyOf(overrides);
}
/// Parses a theme from JSON.
///
/// @param object the theme JSON object
/// @param requireIdentity whether the theme must declare an explicit ID and name
/// @return the parsed theme
/// @throws JsonParseException if required identity fields are missing or malformed
static Theme fromJson(JsonObject object, boolean requireIdentity) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String id = JsonUtils.getString(object, "id");
if (id != null) {
try {
id = ThemePackManifest.requireThemeId(id);
} catch (IllegalArgumentException e) {
if (requireIdentity) {
throw new JsonParseException(e);
}
LOG.warning("Ignored invalid theme id: " + id, e);
id = null;
}
}
if (id == null && requireIdentity) {
throw new JsonParseException("Theme is missing the id");
}
@Nullable LocalizedText name;
try {
name = LocalizedText.fromJson(object.get("name"));
if (name != null && name.mayBeEmpty()) {
throw new JsonParseException("Theme name is empty: " + name);
}
} catch (JsonParseException | IllegalArgumentException e) {
if (requireIdentity) {
throw e;
}
LOG.warning("Ignored invalid theme name", e);
name = null;
}
if (name == null && requireIdentity) {
throw new JsonParseException("Theme is missing required localized text field: " + "name");
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (Exception e) {
LOG.warning("Failed to parse authors", e);
authors = List.of();
}
@Nullable LocalizedText description;
try {
description = LocalizedText.fromJson(object.get("description"));
if (description != null) {
description = ThemePackManifest.requireLocalizedText(description, "description");
}
} catch (Exception e) {
LOG.warning("Failed to parse description", e);
description = null;
}
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
JsonElement overridesJson = object.get("overrides");
List<ThemeOverride> overrides;
if (overridesJson == null || overridesJson.isJsonNull()) {
overrides = List.of();
} else if (overridesJson instanceof JsonArray array) {
overrides = new ArrayList<>(array.size());
for (JsonElement overrideJson : array) {
try {
ThemeOverride override = ThemeOverride.fromJson(overrideJson);
if (override != null)
overrides.add(override);
} catch (Exception e) {
LOG.warning("Invalid theme override", e);
}
}
} else {
LOG.warning("Invalid theme overrides: expected an array, got " + overridesJson);
overrides = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme icon: " + icon, e);
icon = null;
}
}
return new Theme(id, name, authors, description,
icon,
appearance, overrides);
}
/// Returns the theme display name in the current locale.
///
/// @return the localized display name, or `null` when this theme has no matching display name
public @Nullable String displayName() {
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the theme description in the current locale.
///
/// @return the localized description, or `null` when this theme has no matching description
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Resolves this theme against a context by applying matching overrides.
///
/// @param context the resolution context
/// @return the resolved appearance
public ThemeAppearance resolve(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeAppearance resolved = appearance;
for (ThemeOverride override : overrides) {
if (override.matches(context)) {
resolved = resolved.merge(override.appearance());
}
}
return resolved;
}
/// Converts this theme to its JSON representation.
///
/// @return the JSON object representing this theme
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (id != null) {
object.addProperty("id", id);
}
if (name != null) {
object.add("name", JsonUtils.GSON.toJsonTree(name, LocalizedText.class));
}
object.add("authors", ThemePackAuthor.toJson(authors));
if (description != null) {
object.add("description", JsonUtils.GSON.toJsonTree(description, LocalizedText.class));
}
if (icon != null) {
object.addProperty("icon", icon);
}
appearance.addToJsonObject(object);
if (!overrides.isEmpty()) {
JsonArray array = new JsonArray();
for (ThemeOverride override : overrides) {
array.add(override.toJsonObject());
}
object.add("overrides", array);
}
return object;
}
}
@@ -1,374 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Appearance values contributed by a theme or override.
///
/// All fields are optional so the same type can represent both a default appearance
/// and a conditional patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeAppearance(
@Nullable ThemeColorSource color,
@Nullable Brightness brightness,
@Nullable ColorStyle colorStyle,
@Nullable Contrast contrast,
@Nullable ThemeBackgroundSettings background,
@Nullable ThemeTitleBar titleBar,
@Nullable Boolean windowTransparent) {
/// JSON member name for the color seed.
static final String FIELD_COLOR = "color";
/// JSON member name for the controlled launcher brightness.
static final String FIELD_BRIGHTNESS = "brightness";
/// JSON member name for the MonetFX color style.
static final String FIELD_COLOR_STYLE = "colorStyle";
/// JSON member name for the MonetFX contrast level.
static final String FIELD_CONTRAST = "contrast";
/// JSON member name for background settings.
static final String FIELD_BACKGROUND = "background";
/// JSON member name for title-bar settings.
static final String FIELD_TITLE_BAR = "titleBar";
/// JSON member name for window transparency.
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
/// Creates an appearance patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
public ThemeAppearance {
if (background != null && background.isEmpty()) {
background = null;
}
if (titleBar != null && titleBar.isEmpty()) {
titleBar = null;
}
}
/// Parses known appearance fields from a JSON object.
///
/// @param object the JSON object containing appearance fields
/// @return the parsed appearance patch
static ThemeAppearance fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeAppearance(
readColor(object),
readBrightness(object),
readColorStyle(object),
readContrast(object),
readBackground(object),
readTitleBar(object),
readWindowTransparent(object));
}
/// Returns whether this appearance contains no concrete fields.
///
/// @return `true` when every appearance field is inherited
public boolean isEmpty() {
return color == null
&& brightness == null
&& colorStyle == null
&& contrast == null
&& background == null
&& titleBar == null
&& windowTransparent == null;
}
/// Adds this appearance patch's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object) {
Objects.requireNonNull(object);
if (color != null) {
object.add(FIELD_COLOR, color.toJsonElement());
}
if (brightness != null) {
object.addProperty(FIELD_BRIGHTNESS, toJsonBrightness(brightness));
}
if (colorStyle != null) {
object.addProperty(FIELD_COLOR_STYLE, colorStyle.name().toLowerCase(Locale.ROOT));
}
if (contrast != null && !contrast.equals(Contrast.DEFAULT)) {
addContrast(object, contrast);
}
if (background != null) {
object.add(FIELD_BACKGROUND, background.toJsonObject());
}
if (titleBar != null) {
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
}
if (windowTransparent != null) {
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
}
}
/// Converts this appearance patch to its JSON representation.
///
/// @return the JSON object representing this appearance patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Applies the given patch over this appearance.
///
/// @param patch the patch to apply
/// @return the merged appearance
public ThemeAppearance merge(ThemeAppearance patch) {
Objects.requireNonNull(patch);
return new ThemeAppearance(
patch.color != null ? patch.color : color,
patch.brightness != null ? patch.brightness : brightness,
patch.colorStyle != null ? patch.colorStyle : colorStyle,
patch.contrast != null ? patch.contrast : contrast,
background != null && patch.background != null
? background.merge(patch.background)
: patch.background != null ? patch.background : background,
titleBar != null && patch.titleBar != null
? titleBar.merge(patch.titleBar)
: patch.titleBar != null ? patch.titleBar : titleBar,
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
}
/// Converts this appearance to concrete launcher theme values.
///
/// @param context the resolution context that provides fallback brightness when this appearance does not control it
/// @return the concrete theme used by MonetFX
public ResolvedTheme toResolvedTheme(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeColor resolvedColor = color != null ? color.resolveFallback() : ResolvedTheme.DEFAULT.primaryColorSeed();
Brightness resolvedBrightness = brightness != null ? brightness : context.brightness();
ColorStyle resolvedColorStyle = colorStyle != null ? colorStyle : ResolvedTheme.DEFAULT.colorStyle();
Contrast resolvedContrast = contrast != null ? contrast : Contrast.DEFAULT;
return new ResolvedTheme(resolvedColor, resolvedBrightness, resolvedColorStyle, resolvedContrast);
}
/// Reads the optional color field.
private static @Nullable ThemeColorSource readColor(JsonObject object) {
JsonElement element = object.get(FIELD_COLOR);
if (element == null) {
return null;
}
try {
return ThemeColorSource.fromJson(element);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme color: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional controlled brightness field.
private static @Nullable Brightness readBrightness(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_BRIGHTNESS);
if (value == null) {
return null;
}
return parseBrightness(value);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme brightness: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional color style field.
private static @Nullable ColorStyle readColorStyle(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_COLOR_STYLE);
if (value == null) {
return null;
}
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return ColorStyle.DEFAULT;
}
return ColorStyle.valueOf(normalized);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme colorStyle: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional contrast field.
private static @Nullable Contrast readContrast(JsonObject object) {
JsonElement element = object.get(FIELD_CONTRAST);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive)) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
try {
if (primitive.isNumber()) {
return readContrastValue(primitive.getAsDouble());
}
if (!primitive.isString()) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
String value = primitive.getAsString();
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "low" -> Contrast.LOW;
case "standard" -> Contrast.DEFAULT;
case "medium" -> Contrast.MEDIUM;
case "high" -> Contrast.HIGH;
default -> {
try {
yield readContrastValue(Double.parseDouble(normalized));
} catch (NumberFormatException e) {
throw new JsonParseException("Invalid theme contrast: " + value, e);
}
}
};
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme contrast: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional background object.
private static @Nullable ThemeBackgroundSettings readBackground(JsonObject object) {
JsonElement element = object.get(FIELD_BACKGROUND);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject background)) {
LOG.warning("Ignored invalid theme background: expected an object, got " + element);
return null;
}
try {
return ThemeBackgroundSettings.fromJson(background);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme background: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional title-bar object.
private static @Nullable ThemeTitleBar readTitleBar(JsonObject object) {
JsonElement element = object.get(FIELD_TITLE_BAR);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject titleBar)) {
LOG.warning("Ignored invalid theme titleBar: expected an object, got " + element);
return null;
}
try {
return ThemeTitleBar.fromJson(titleBar);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme titleBar: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional window transparency field.
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a validated contrast value.
private static Contrast readContrastValue(double value) {
try {
return Contrast.of(value);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Theme contrast value must be between -1 and 1: " + value, e);
}
}
/// Adds a canonical contrast value to a JSON object.
private static void addContrast(JsonObject object, Contrast contrast) {
if (contrast.equals(Contrast.LOW)) {
object.addProperty(FIELD_CONTRAST, "low");
} else if (contrast.equals(Contrast.DEFAULT)) {
object.addProperty(FIELD_CONTRAST, "standard");
} else if (contrast.equals(Contrast.MEDIUM)) {
object.addProperty(FIELD_CONTRAST, "medium");
} else if (contrast.equals(Contrast.HIGH)) {
object.addProperty(FIELD_CONTRAST, "high");
} else {
object.addProperty(FIELD_CONTRAST, contrast.getValue());
}
}
/// Parses a serialized theme brightness value.
private static Brightness parseBrightness(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> throw new IllegalArgumentException("Unsupported theme brightness: " + value);
};
}
/// Converts a controlled brightness value to its canonical JSON string.
private static String toJsonBrightness(Brightness brightness) {
return switch (brightness) {
case LIGHT -> "light";
case DARK -> "dark";
};
}
}
@@ -1,215 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Background source contributed by a theme-pack appearance.
///
/// The sealed implementations describe where the wallpaper comes from. Rendering
/// parameters such as opacity are stored by [ThemeBackgroundSettings].
@NotNullByDefault
public sealed interface ThemeBackground
permits ThemeBackground.Default, ThemeBackground.Builtin, ThemeBackground.Image,
ThemeBackground.Paint, ThemeBackground.ThemeColor {
/// JSON member name for the background type.
String FIELD_TYPE = "type";
/// JSON member name for the built-in wallpaper ID.
String FIELD_ID = "id";
/// JSON member name for the local theme-pack image path.
String FIELD_PATH = "path";
/// JSON member name for a serialized paint value.
String FIELD_PAINT = "paint";
/// Adds this background source's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object);
/// Converts this background source to its JSON representation.
///
/// @return the JSON object representing this background source
default JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Parses a background source from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background source, or `null` when no source field is present
/// @throws JsonParseException if a known field is malformed
static @Nullable ThemeBackground fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String type = readString(object, FIELD_TYPE);
@Nullable String id = readString(object, FIELD_ID);
@Nullable String path = readString(object, FIELD_PATH);
@Nullable String paint = readString(object, FIELD_PAINT);
if (type == null) {
int sourceFields = 0;
if (id != null) {
sourceFields++;
}
if (path != null) {
sourceFields++;
}
if (paint != null) {
sourceFields++;
}
if (sourceFields > 1) {
throw new JsonParseException("Theme background without type must contain only one source field");
}
if (id != null) {
return new Builtin(id);
}
if (path != null) {
return new Image(path);
}
if (paint != null) {
return new Paint(paint);
}
return null;
}
return switch (type.trim().replace('-', '_').toUpperCase(Locale.ROOT)) {
case "DEFAULT" -> new Default();
case "BUILTIN" -> new Builtin(id);
case "IMAGE" -> new Image(path);
case "PAINT" -> new Paint(paint);
case "THEME_COLOR" -> new ThemeColor();
default -> throw new JsonParseException("Unsupported theme background type: " + type);
};
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme background field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a required non-blank string value.
private static String requireNonBlank(@Nullable String value, String field) {
if (value == null) {
throw new JsonParseException("Theme background field is missing: " + field);
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Theme background field is blank: " + field);
}
return trimmed;
}
/// A source that delegates to HMCL's launcher default background resolution.
@NotNullByDefault
record Default() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "default");
}
}
/// A source that uses a launcher built-in wallpaper.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
@NotNullByDefault
record Builtin(@Nullable String id) implements ThemeBackground {
/// Creates a source that uses the fallback built-in wallpaper.
public Builtin() {
this(null);
}
/// Creates a built-in wallpaper source.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
public Builtin {
if (id != null) {
id = requireNonBlank(id, FIELD_ID);
}
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "builtin");
if (id != null) {
object.addProperty(FIELD_ID, id);
}
}
}
/// A source that uses an image stored inside the theme pack.
///
/// @param path the theme-pack relative image path
@NotNullByDefault
record Image(String path) implements ThemeBackground {
/// Creates an image background source.
///
/// @param path the theme-pack relative image path
public Image {
path = requireNonBlank(path, FIELD_PATH);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "image");
object.addProperty(FIELD_PATH, path);
}
}
/// A source that uses a JavaFX paint string.
///
/// @param paint the serialized paint value
@NotNullByDefault
record Paint(String paint) implements ThemeBackground {
/// Creates a paint background source.
///
/// @param paint the serialized paint value
public Paint {
paint = requireNonBlank(paint, FIELD_PAINT);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "paint");
object.addProperty(FIELD_PAINT, paint);
}
}
/// A source that follows the current launcher theme color.
@NotNullByDefault
record ThemeColor() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "theme_color");
}
}
}
@@ -1,103 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Background settings contributed by a theme-pack appearance.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
@NotNullByDefault
public record ThemeBackgroundSettings(
@Nullable ThemeBackground source,
@Nullable Double opacity) {
/// JSON member name for the background opacity.
private static final String FIELD_OPACITY = "opacity";
/// Creates background settings.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
public ThemeBackgroundSettings {
if (opacity != null && (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity))) {
throw new IllegalArgumentException("Theme background opacity must be between 0 and 1: " + opacity);
}
}
/// Parses background settings from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background settings
static ThemeBackgroundSettings fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeBackgroundSettings(
ThemeBackground.fromJson(object),
readOpacity(object));
}
/// Converts these settings to their JSON representation.
///
/// @return the JSON object representing these background settings
public JsonObject toJsonObject() {
JsonObject object = source != null ? source.toJsonObject() : new JsonObject();
if (opacity != null) {
object.addProperty(FIELD_OPACITY, opacity);
}
return object;
}
/// Returns whether these settings contain no concrete fields.
///
/// @return `true` when every background setting is inherited
public boolean isEmpty() {
return source == null && opacity == null;
}
/// Returns settings that apply the given patch over these settings.
///
/// @param patch the patch to apply
/// @return the merged settings
public ThemeBackgroundSettings merge(ThemeBackgroundSettings patch) {
Objects.requireNonNull(patch);
@Nullable ThemeBackground mergedSource = patch.source != null ? patch.source : source;
@Nullable Double mergedOpacity = patch.opacity != null ? patch.opacity : opacity;
return new ThemeBackgroundSettings(mergedSource, mergedOpacity);
}
/// Reads the optional opacity field.
private static @Nullable Double readOpacity(JsonObject object) {
JsonElement element = object.get(FIELD_OPACITY);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
LOG.warning("Ignored invalid theme background opacity: expected a number, got " + element);
return null;
}
try {
double opacity = primitive.getAsDouble();
if (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity)) {
LOG.warning("Ignored invalid theme background opacity: expected a number between 0 and 1, got "
+ opacity);
return null;
}
return opacity;
} catch (NumberFormatException e) {
LOG.warning("Ignored invalid theme background opacity: " + e.getMessage(), e);
return null;
}
}
}
@@ -1,157 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Describes how a theme-pack appearance chooses its Monet seed color.
@NotNullByDefault
public sealed interface ThemeColorSource permits ThemeColorSource.Default, ThemeColorSource.Custom, ThemeColorSource.Wallpaper {
/// JSON member name for the source type.
String FIELD_SOURCE = "source";
/// The launcher default color source.
ThemeColorSource DEFAULT = new Default();
/// Creates a custom color source.
///
/// @param color the custom color
/// @return the custom color source
static ThemeColorSource custom(ThemeColor color) {
return new Custom(color);
}
/// Creates a wallpaper color source.
///
/// @return the wallpaper color source
static ThemeColorSource wallpaper() {
return new Wallpaper();
}
/// Parses a color source from a manifest JSON value.
///
/// @param element the JSON value
/// @return the parsed color source
/// @throws JsonParseException if the color source is malformed
static ThemeColorSource fromJson(JsonElement element) throws JsonParseException {
Objects.requireNonNull(element);
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
String value = primitive.getAsString();
if ("default".equals(value.trim().replace('-', '_').toLowerCase(Locale.ROOT))) {
return DEFAULT;
}
@Nullable ThemeColor color = ThemeColor.of(value);
if (color == null) {
throw new JsonParseException("Invalid theme color: " + value);
}
return custom(color);
}
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Theme color must be a string or object");
}
JsonElement sourceElement = object.get(FIELD_SOURCE);
if (sourceElement == null) {
throw new JsonParseException("Theme color source is missing required field: " + FIELD_SOURCE);
}
if (!(sourceElement instanceof JsonPrimitive sourcePrimitive) || !sourcePrimitive.isString()) {
throw new JsonParseException("Theme color source field must be a string: " + FIELD_SOURCE);
}
String source = sourcePrimitive.getAsString();
String normalized = source.trim().replace('-', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return DEFAULT;
}
if ("WALLPAPER".equals(normalized)) {
return wallpaper();
}
throw new JsonParseException("Unsupported theme color source: " + source);
}
/// Converts this color source to its JSON representation.
///
/// @return the color source JSON value
JsonElement toJsonElement();
/// Returns the best available color without accessing wallpaper pixels.
///
/// @return the custom color or launcher default color
ThemeColor resolveFallback();
/// The launcher default color seed.
@NotNullByDefault
record Default() implements ThemeColorSource {
/// Creates a default color source.
public Default {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "default");
return object;
}
/// Returns the launcher default seed color.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
/// A fixed color seed supplied by the manifest.
///
/// @param color the custom seed color
@NotNullByDefault
record Custom(ThemeColor color) implements ThemeColorSource {
/// Creates a custom color source.
///
/// @param color the custom seed color
public Custom {
Objects.requireNonNull(color);
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
return new JsonPrimitive(color.name());
}
/// Returns the custom seed color.
@Override
public ThemeColor resolveFallback() {
return color;
}
}
/// A dynamic seed color extracted from the effective wallpaper.
@NotNullByDefault
record Wallpaper() implements ThemeColorSource {
/// Creates a wallpaper color source.
public Wallpaper {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "wallpaper");
return object;
}
/// Returns the launcher default color used before wallpaper pixels are available.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
}
@@ -1,189 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/// A simple JSON-object condition used by theme-pack overrides.
///
/// A condition is an AND of all members. A string value requires equality, and
/// an array value requires the context value to match any listed value. Unknown
/// condition keys are accepted for forward compatibility, but they do not match
/// contexts produced by this implementation.
///
/// @param requirements normalized accepted values keyed by condition name
@NotNullByDefault
public record ThemeCondition(@Unmodifiable Map<String, @Unmodifiable Set<String>> requirements) {
/// Condition key for the effective light or dark mode.
static final String KEY_BRIGHTNESS = "brightness";
/// Condition key for the current operating system.
static final String KEY_OS = "os";
/// Condition key for the current UI language.
static final String KEY_LANGUAGE = "language";
/// Supported operating system condition values.
private static final Set<String> SUPPORTED_OS_VALUES = Set.of("windows", "macos", "linux", "freebsd", "unknown");
/// Creates a condition from normalized accepted values.
///
/// @param requirements normalized accepted values keyed by condition name
public ThemeCondition {
Objects.requireNonNull(requirements);
LinkedHashMap<String, Set<String>> copy = new LinkedHashMap<>();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String key = normalizeKey(entry.getKey());
Set<String> values = entry.getValue();
if (values.isEmpty()) {
throw new IllegalArgumentException("Theme condition field has no accepted values: " + key);
}
LinkedHashSet<String> valueCopy = new LinkedHashSet<>();
for (String value : values) {
valueCopy.add(normalizeValue(key, value));
}
copy.put(key, Collections.unmodifiableSet(valueCopy));
}
requirements = Collections.unmodifiableMap(copy);
}
/// Parses a theme condition from a JSON object.
///
/// @param object the condition object
/// @return the parsed condition
/// @throws JsonParseException if the condition contains malformed values
public static ThemeCondition fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
LinkedHashMap<String, Set<String>> requirements = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
String key = normalizeKey(entry.getKey());
requirements.put(key, readAcceptedValues(key, entry.getValue()));
}
return new ThemeCondition(requirements);
}
/// Returns whether this condition matches the given resolution context.
///
/// @param context the context to test
/// @return `true` if every condition member matches the context
public boolean matches(ThemeResolveContext context) {
Objects.requireNonNull(context);
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String value = context.conditionValue(entry.getKey());
if (value == null || !entry.getValue().contains(value)) {
return false;
}
}
return true;
}
/// Converts this condition to its JSON representation.
///
/// @return the JSON object representing this condition
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
if (entry.getValue().size() == 1) {
object.addProperty(entry.getKey(), entry.getValue().iterator().next());
} else {
JsonArray array = new JsonArray();
for (String value : entry.getValue()) {
array.add(value);
}
object.add(entry.getKey(), array);
}
}
return object;
}
/// Reads one condition field value.
private static Set<String> readAcceptedValues(String key, JsonElement element) throws JsonParseException {
LinkedHashSet<String> values = new LinkedHashSet<>();
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
values.add(normalizeValue(key, primitive.getAsString()));
} else if (element instanceof JsonArray array) {
if (array.isEmpty()) {
throw new JsonParseException("Theme condition array is empty: " + key);
}
for (JsonElement item : array) {
if (!(item instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme condition array must contain strings: " + key);
}
values.add(normalizeValue(key, primitive.getAsString()));
}
} else {
throw new JsonParseException("Theme condition value must be a string or string array: " + key);
}
return values;
}
/// Normalizes and validates a condition key.
private static String normalizeKey(String key) {
Objects.requireNonNull(key);
String normalized = key.trim();
if (normalized.isEmpty()) {
throw new JsonParseException("Theme condition key is blank");
}
return normalized;
}
/// Normalizes and validates one condition value.
private static String normalizeValue(String key, String value) {
Objects.requireNonNull(key);
Objects.requireNonNull(value);
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Empty theme condition value for " + key);
}
String normalized = trimmed.toLowerCase(Locale.ROOT);
return switch (key) {
case KEY_BRIGHTNESS -> switch (normalized) {
case "light", "dark" -> normalized;
default -> throw new JsonParseException("Unsupported brightness condition value: " + value);
};
case KEY_OS -> normalizeOperatingSystemValue(normalized, value);
case KEY_LANGUAGE -> normalized;
default -> trimmed;
};
}
/// Normalizes an operating system condition value.
private static String normalizeOperatingSystemValue(String normalized, String original) {
String value = switch (normalized) {
case "win", "windows" -> "windows";
case "mac", "macos", "osx" -> "macos";
case "linux" -> "linux";
case "freebsd" -> "freebsd";
case "unknown", "universal" -> "unknown";
default -> normalized;
};
if (!SUPPORTED_OS_VALUES.contains(value)) {
throw new JsonParseException("Unsupported os condition value: " + original);
}
return value;
}
}
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// A conditional appearance patch in a theme.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
@NotNullByDefault
public record ThemeOverride(ThemeCondition condition, ThemeAppearance appearance) {
/// JSON member name for an override condition.
private static final String FIELD_CONDITION = "condition";
/// Creates a conditional theme override.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
public ThemeOverride {
Objects.requireNonNull(condition);
Objects.requireNonNull(appearance);
if (appearance.isEmpty()) {
throw new IllegalArgumentException("Theme override does not define any appearance fields");
}
}
/// Parses a theme override from JSON.
///
/// @param element the override object
/// @return the parsed override
/// @throws JsonParseException if the override is malformed
static @Nullable ThemeOverride fromJson(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull())
return null;
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Invalid theme override");
}
JsonElement conditionElement = object.get(FIELD_CONDITION);
if (!(conditionElement instanceof JsonObject conditionObject)) {
throw new JsonParseException("Theme override must define an object condition");
}
ThemeCondition condition = ThemeCondition.fromJson(conditionObject);
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
if (appearance.isEmpty()) {
throw new JsonParseException("Theme override does not define any appearance fields");
}
return new ThemeOverride(condition, appearance);
}
/// Returns whether this override matches the given resolution context.
///
/// @param context the context to test
/// @return `true` when the override should be applied
public boolean matches(ThemeResolveContext context) {
return condition.matches(context);
}
/// Converts this override to its JSON representation.
///
/// @return the JSON object representing this override
public JsonObject toJsonObject() {
JsonObject object = appearance.toJsonObject();
object.add(FIELD_CONDITION, condition.toJsonObject());
return object;
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.util.Objects;
/// One resource copied into an exported theme-pack zip.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
@NotNullByDefault
public record ThemePackAsset(ThemePackResource source, String entryName) {
/// Required prefix for theme-pack asset entries.
private static final String ASSETS_PREFIX = "assets/";
/// Creates a theme-pack asset from a local file.
///
/// @param source the local file to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset(Path source, String entryName) {
this(new ThemePackResource.File(source), entryName);
}
/// Creates a theme-pack asset entry.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset {
Objects.requireNonNull(source);
entryName = normalizeEntryName(entryName);
}
/// Normalizes and validates a zip entry name.
///
/// @param entryName the entry name to validate
/// @return the normalized entry name
/// @throws IllegalArgumentException if the entry name is unsafe or outside `assets/`
static String normalizeEntryName(String entryName) {
Objects.requireNonNull(entryName);
String normalized = entryName.trim().replace('\\', '/');
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Theme-pack asset entry is empty");
}
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
throw new IllegalArgumentException("Theme-pack asset entry must be relative: " + entryName);
}
if (!normalized.startsWith(ASSETS_PREFIX)) {
throw new IllegalArgumentException("Theme-pack asset entry must be under assets/: " + entryName);
}
if (normalized.endsWith("/")) {
throw new IllegalArgumentException("Theme-pack asset entry must be a file: " + entryName);
}
for (String segment : normalized.split("/")) {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException("Theme-pack asset entry contains an unsafe segment: " + entryName);
}
for (int i = 0; i < segment.length(); i++) {
char ch = segment.charAt(i);
if (Character.isISOControl(ch) || ch == '\0') {
throw new IllegalArgumentException("Theme-pack asset entry contains a control character: " + entryName);
}
}
}
return normalized;
}
}
@@ -1,138 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Author metadata declared by a theme pack.
///
/// @param name the localized author display name
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackAuthor.Adapter.class)
public record ThemePackAuthor(LocalizedText name) {
/// Parses author metadata from a JSON array.
static @Unmodifiable List<ThemePackAuthor> parseAuthors(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull()) {
return List.of();
}
if (!(element instanceof JsonArray jsonArray)) {
throw new JsonParseException("Theme-pack authors must be an array");
}
ArrayList<ThemePackAuthor> authors = new ArrayList<>(jsonArray.size());
int index = 0;
for (JsonElement authorJson : jsonArray) {
try {
ThemePackAuthor author = fromJson(authorJson);
if (author != null) {
authors.add(author);
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack author at authors[" + index + "]: " + authorJson, e);
}
index++;
}
return List.copyOf(authors);
}
/// Converts author metadata to a JSON array.
static JsonArray toJson(List<ThemePackAuthor> authors) {
JsonArray array = new JsonArray();
for (ThemePackAuthor author : authors) {
array.add(author.toJsonObject());
}
return array;
}
/// Parses author metadata from a JSON object or a plain string.
///
/// @param json the author metadata JSON
/// @return the parsed author metadata, or `null` when `json` is `null`
/// @throws JsonParseException if `json` is neither an object nor a string author name
public static @Nullable ThemePackAuthor fromJson(@Nullable JsonElement json) throws JsonParseException {
if (json == null || json instanceof JsonNull)
return null;
if (json instanceof JsonPrimitive primitive && primitive.isString()) {
try {
return new ThemePackAuthor(LocalizedText.plain(primitive.getAsString()));
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
if (!(json instanceof JsonObject jsonObject)) {
throw new JsonParseException("Theme-pack author must be an object or a string");
}
JsonElement nameJson = jsonObject.get("name");
if (nameJson == null) {
throw new JsonParseException("Missing author name: " + json);
}
LocalizedText name = LocalizedText.fromJson(nameJson);
if (name == null) {
throw new JsonParseException("The author name is null");
}
try {
return new ThemePackAuthor(name);
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
/// Creates theme-pack author metadata.
///
/// @param name the localized author display name
public ThemePackAuthor {
if (name.mayBeEmpty()) {
throw new IllegalArgumentException("The author name cannot be empty");
}
}
/// Returns the author display name in the current locale.
///
/// @return the localized author display name
public String displayName() {
return name.getText(I18n.getLocale().getCandidateLocales());
}
/// Converts this author to its JSON representation.
///
/// @return the JSON object representing this author
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
object.add("name", name.toJsonElement());
return object;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackAuthor>, JsonDeserializer<@Nullable ThemePackAuthor> {
@Override
public @Nullable ThemePackAuthor deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ThemePackAuthor.fromJson(json);
}
@Override
public JsonElement serialize(@Nullable ThemePackAuthor src, Type typeOfSrc, JsonSerializationContext context) {
return src != null ? src.toJsonObject() : JsonNull.INSTANCE;
}
}
}
@@ -1,100 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/// Writes theme-pack manifests and assets into zip-compatible files.
@NotNullByDefault
public final class ThemePackExporter {
/// Recommended file extension for theme-pack files.
public static final String FILE_EXTENSION = ".hmcl-theme";
/// Manifest entry name used by every exported theme pack.
public static final String MANIFEST_ENTRY = "manifest.json";
/// Prevents instantiation.
private ThemePackExporter() {
}
/// Exports a theme pack to a zip-compatible file.
///
/// @param manifest the manifest to write
/// @param assets asset files to copy into the zip
/// @param outputFile the target theme-pack file
/// @throws IOException if a source file cannot be read or the zip cannot be written
/// @throws IllegalArgumentException if an asset entry is duplicated or unsafe
public static void export(
ThemePackManifest manifest,
@Unmodifiable List<ThemePackAsset> assets,
Path outputFile) throws IOException {
Objects.requireNonNull(manifest);
Objects.requireNonNull(assets);
Objects.requireNonNull(outputFile);
validateAssets(assets);
Path output = outputFile.toAbsolutePath().normalize();
Path parent = output.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path temporaryFile = FileUtils.tmpSaveFile(output);
try {
Files.deleteIfExists(temporaryFile);
try (Zipper zipper = new Zipper(temporaryFile)) {
zipper.putTextFile(JsonUtils.GSON.toJson(manifest), MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
try (InputStream input = asset.source().openStream()) {
zipper.putStream(input, asset.entryName());
}
}
}
Files.move(temporaryFile, output, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temporaryFile);
} catch (IOException suppressed) {
e.addSuppressed(suppressed);
}
throw e;
}
}
/// Validates all asset entries and source files before the zip is written.
private static void validateAssets(List<ThemePackAsset> assets) throws IOException {
Set<String> entries = new HashSet<>();
entries.add(MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
Objects.requireNonNull(asset);
@Nullable Path sourceFile = asset.source().file();
if (sourceFile != null && !Files.isRegularFile(sourceFile)) {
throw new IOException("Theme-pack asset source is not a regular file: " + sourceFile);
}
try (InputStream ignored = asset.source().openStream()) {
// Validate readability before writing the target zip.
}
if (!entries.add(asset.entryName())) {
throw new IllegalArgumentException("Duplicate theme-pack zip entry: " + asset.entryName());
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,345 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Parsed metadata and themes from a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackManifest.Adapter.class)
public record ThemePackManifest(
String id,
String version,
LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable String icon,
@Nullable LocalizedText description,
@Unmodifiable List<Theme> themes) {
/// JSON schema for the current manifest format.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("theme-pack", new JsonSchema.Version(1, 0, 0));
/// Package and theme ID format used by theme-pack manifests.
private static final Pattern PACKAGE_ID_PATTERN = Pattern.compile(
"[A-Za-z0-9][A-Za-z0-9_-]*(\\.[A-Za-z0-9][A-Za-z0-9_-]*)*"
);
/// Creates a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
public ThemePackManifest {
id = requirePackageId(id);
version = requireNonBlank(version, "version");
name = requireLocalizedText(name, "name");
authors = List.copyOf(authors);
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
if (description != null) {
description = requireLocalizedText(description, "description");
}
themes = List.copyOf(themes);
if (themes.isEmpty()) {
throw new IllegalArgumentException("Theme pack must declare at least one theme");
}
checkThemeIdentities(themes);
}
public boolean isSimpleThemePack() {
if (themes.size() == 1) {
Theme theme = themes.get(0);
return theme.id() == null && theme.name() == null;
} else {
return false;
}
}
/// Returns the package display name in the current locale.
///
/// @return the localized display name, or the package ID when no localized text matches
public String displayName() {
return Objects.requireNonNullElse(name.getText(I18n.getLocale().getCandidateLocales()), id);
}
/// Returns the package description in the current locale.
///
/// @return the localized description, or `null` when no localized text matches
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the first theme with the given ID.
///
/// A `null` theme ID matches only a single-theme manifest.
///
/// @param themeId the theme ID to find, or `null` for an unnamed single-theme manifest
/// @return the matching theme, or `null` when no theme matches
public @Nullable Theme findTheme(@Nullable String themeId) {
if (themeId == null) {
return themes.size() == 1 ? themes.get(0) : null;
}
for (Theme theme : themes) {
if (themeId.equals(theme.id())) {
return theme;
}
}
return null;
}
/// Reads the required theme declaration.
private static List<Theme> readThemes(JsonObject object) {
boolean hasSingleTheme = object.has("theme");
boolean hasMultipleThemes = object.has("themes");
if (hasSingleTheme == hasMultipleThemes) {
throw new JsonParseException("Theme-pack manifest must declare exactly one of theme or themes");
}
if (hasSingleTheme) {
JsonElement element = object.get("theme");
if (!(element instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
return List.of(Theme.fromJson(themeObject, false));
}
JsonElement element = object.get("themes");
if (!(element instanceof JsonArray array)) {
throw new JsonParseException("Theme-pack manifest is missing themes array");
}
if (array.isEmpty()) {
throw new JsonParseException("Theme-pack themes array must declare at least one theme");
}
ArrayList<Theme> themes = new ArrayList<>(array.size());
for (JsonElement item : array) {
if (!(item instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
themes.add(Theme.fromJson(themeObject, true));
}
return themes;
}
/// Checks that theme IDs and names are present whenever the manifest needs them for disambiguation.
private static void checkThemeIdentities(List<Theme> themes) {
if (themes.size() <= 1) {
return;
}
for (Theme theme : themes) {
if (theme.id() == null) {
throw new IllegalArgumentException("Theme ID is required when a theme pack declares multiple themes");
}
if (theme.name() == null) {
throw new IllegalArgumentException("Theme name is required when a theme pack declares multiple themes");
}
}
}
/// Reads a required string member.
private static String requireMemberString(JsonObject object, String fieldName) {
JsonElement element = object.get(fieldName);
if (element == null) {
throw new JsonParseException("Theme-pack manifest is missing " + fieldName);
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme-pack manifest field must be a string: " + fieldName);
}
return requireNonBlank(primitive.getAsString(), fieldName);
}
/// Parses a localized text value.
static LocalizedText parseLocalizedText(JsonElement element, String field) {
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
return LocalizedText.plain(requireNonBlank(primitive.getAsString(), field));
}
if (element instanceof JsonObject localizedObject) {
if (localizedObject.isEmpty()) {
throw new JsonParseException("Localized text field is empty: " + field);
}
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : localizedObject.entrySet()) {
JsonElement value = entry.getValue();
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Localized text values must be strings: " + field);
}
localizedValues.put(
requireNonBlank(entry.getKey(), field),
requireNonBlank(primitive.getAsString(), field));
}
return new LocalizedText(localizedValues);
}
throw new JsonParseException("Theme-pack localized text must be a string or object: " + field);
}
/// Returns a validated localized text value.
static LocalizedText requireLocalizedText(LocalizedText value, String field) {
Objects.requireNonNull(value);
JsonElement element = JsonUtils.GSON.toJsonTree(value, LocalizedText.class);
return parseLocalizedText(element, field);
}
/// Returns a non-blank string value.
private static String requireNonBlank(String value, String field) {
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Theme-pack manifest field is blank: " + field);
}
return trimmed;
}
/// Returns a package ID matching the theme-pack ID format.
static String requirePackageId(String value) {
return requireId(value, "id", "Theme-pack manifest ID must follow package ID format: ");
}
/// Returns a theme ID matching the theme-pack ID format.
static String requireThemeId(String value) {
return requireId(value, "theme id", "Theme ID must follow package ID format: ");
}
/// Returns an ID matching the theme-pack ID format.
private static String requireId(String value, String field, String messagePrefix) {
String id = requireNonBlank(value, field);
if (!PACKAGE_ID_PATTERN.matcher(id).matches()) {
throw new IllegalArgumentException(messagePrefix + value);
}
return id;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackManifest>,
JsonDeserializer<@Nullable ThemePackManifest> {
@Override
public @Nullable ThemePackManifest deserialize(
@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json instanceof JsonNull) {
return null;
}
if (!(json instanceof JsonObject object)) {
throw new JsonParseException("Theme-pack manifest is not a JsonObject");
}
if (!JsonSchema.check(object, CURRENT_SCHEMA).readable()) {
throw new JsonParseException("Unsupported theme-pack schema: " + object.get(JsonSchema.PROPERTY_SCHEMA));
}
String id = requireMemberString(object, "id");
String version = requireMemberString(object, "version");
LocalizedText name = context.deserialize(object.get("name"), LocalizedText.class);
if (name == null) {
throw new JsonParseException("Theme-pack manifest is missing name");
}
@Nullable LocalizedText description;
try {
description = context.deserialize(object.get("description"), LocalizedText.class);
if (description != null) {
description = requireLocalizedText(description, "description");
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack description", e);
description = null;
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack authors", e);
authors = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack icon: " + icon, e);
icon = null;
}
}
return new ThemePackManifest(
id,
version,
name,
authors,
icon,
description,
readThemes(object));
}
@Override
public JsonElement serialize(@Nullable ThemePackManifest src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) {
return JsonNull.INSTANCE;
}
JsonObject object = new JsonObject();
object.addProperty(JsonSchema.PROPERTY_SCHEMA, CURRENT_SCHEMA.url());
object.addProperty("id", src.id);
object.addProperty("version", src.version);
object.add("name", src.name.toJsonElement());
object.add("authors", ThemePackAuthor.toJson(src.authors));
if (src.description != null) {
object.add("description", src.description.toJsonElement());
}
if (src.icon != null) {
object.addProperty("icon", src.icon);
}
if (src.isSimpleThemePack()) {
object.add("theme", src.themes.get(0).toJsonObject());
} else {
JsonArray themeArray = new JsonArray();
for (Theme theme : src.themes) {
themeArray.add(theme.toJsonObject());
}
object.add("themes", themeArray);
}
return object;
}
}
}
@@ -1,200 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/// A readable resource contributed by a theme pack.
@NotNullByDefault
public sealed interface ThemePackResource
permits ThemePackResource.File, ThemePackResource.Zip, ThemePackResource.Builtin {
/// Returns a stable resource name used for display and extension detection.
///
/// @return the resource name
String name();
/// Opens a fresh input stream for this resource.
///
/// @return a new stream for reading the resource content
/// @throws IOException if the resource cannot be opened
InputStream openStream() throws IOException;
/// Returns the backing file when this resource is a regular filesystem file.
///
/// @return the backing file, or `null` when the resource is not a direct file
default @Nullable Path file() {
return null;
}
/// A resource stored as a regular filesystem file.
///
/// @param path the resource file path
/// @param name the stable resource name
@NotNullByDefault
record File(Path path, String name) implements ThemePackResource {
/// Creates a file-backed theme-pack resource named after its file name.
///
/// @param path the resource file path
public File(Path path) {
this(path, Objects.requireNonNull(path).getFileName().toString());
}
/// Creates a file-backed theme-pack resource.
///
/// @param path the resource file path
/// @param name the stable resource name
public File {
path = Objects.requireNonNull(path).toAbsolutePath().normalize();
name = Objects.requireNonNull(name).trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("Theme-pack resource name is blank");
}
}
/// Opens the file.
@Override
public InputStream openStream() throws IOException {
return Files.newInputStream(path);
}
/// Returns the backing file.
@Override
public Path file() {
return path;
}
}
/// A resource stored as an entry inside a zip theme-pack file.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
@NotNullByDefault
record Zip(Path zipFile, String entryName) implements ThemePackResource {
/// Creates a zip-entry theme-pack resource.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
public Zip {
zipFile = Objects.requireNonNull(zipFile).toAbsolutePath().normalize();
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the zip entry name.
@Override
public String name() {
return entryName;
}
/// Opens the zip entry and closes the zip file when the returned stream is closed.
@Override
public InputStream openStream() throws IOException {
ZipArchiveReader zip = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8);
boolean success = false;
try {
ZipArchiveEntry entry = zip.getEntry(entryName);
if (entry == null || entry.isDirectory()) {
throw new IOException("Theme-pack asset is missing: " + entryName);
}
InputStream input = zip.getInputStream(entry);
success = true;
return new ZipEntryInputStream(input, zip);
} finally {
if (!success) {
zip.close();
}
}
}
}
/// A resource stored in launcher-bundled classpath resources.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
@NotNullByDefault
record Builtin(String resourcePath, String entryName) implements ThemePackResource {
/// Creates a classpath-backed theme-pack resource.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
public Builtin {
resourcePath = Objects.requireNonNull(resourcePath);
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the theme-pack entry name.
@Override
public String name() {
return entryName;
}
/// Opens the classpath resource.
@Override
public InputStream openStream() throws IOException {
@Nullable InputStream input = ThemePackResource.class.getResourceAsStream(resourcePath);
if (input == null) {
throw new IOException("Built-in theme-pack asset is missing: " + entryName);
}
return input;
}
}
/// An input stream that owns a zip file handle.
@NotNullByDefault
final class ZipEntryInputStream extends FilterInputStream {
/// The zip file that must stay open while this stream is read.
private final ZipArchiveReader zipFile;
/// Whether this stream has already been closed.
private boolean closed;
/// Creates a zip-entry stream.
///
/// @param input the zip entry input stream
/// @param zipFile the owning zip file
private ZipEntryInputStream(InputStream input, ZipArchiveReader zipFile) {
super(input);
this.zipFile = Objects.requireNonNull(zipFile);
}
/// Closes both the entry stream and the owning zip file.
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
@Nullable IOException exception = null;
try {
super.close();
} catch (IOException e) {
exception = e;
}
try {
zipFile.close();
} catch (IOException e) {
if (exception != null) {
exception.addSuppressed(e);
} else {
exception = e;
}
}
if (exception != null) {
throw exception;
}
}
}
}
@@ -1,30 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Identifies one theme declared by an installed theme pack.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
@NotNullByDefault
@JsonSerializable
public record ThemeReference(
@SerializedName("packId") String packId,
@SerializedName("themeId") @Nullable String themeId) {
/// Creates a theme reference.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
public ThemeReference {
packId = ThemePackManifest.requirePackageId(packId);
if (themeId != null) {
themeId = ThemePackManifest.requireThemeId(themeId);
}
}
}
@@ -1,79 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.Brightness;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Environment values used to resolve conditional theme-pack overrides.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
@NotNullByDefault
public record ThemeResolveContext(
Brightness brightness,
String os,
String language) {
/// Creates a theme resolution context.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
public ThemeResolveContext {
Objects.requireNonNull(brightness);
os = normalizeToken(os, "os");
language = normalizeToken(language, "language");
}
/// Creates a context for the current process platform.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @return a context containing the current OS and default language
public static ThemeResolveContext current(Brightness brightness) {
return new ThemeResolveContext(
brightness,
normalizeOperatingSystem(OperatingSystem.CURRENT_OS),
Locale.getDefault().getLanguage());
}
/// Returns the normalized value for one supported condition key.
///
/// @param key the condition key
/// @return the normalized context value, or `null` when the key is unsupported
@Nullable String conditionValue(String key) {
return switch (key) {
case ThemeCondition.KEY_BRIGHTNESS -> brightness.name().toLowerCase(Locale.ROOT);
case ThemeCondition.KEY_OS -> os;
case ThemeCondition.KEY_LANGUAGE -> language;
default -> null;
};
}
/// Returns the normalized name for an operating system.
///
/// @param operatingSystem the operating system to normalize
/// @return the normalized operating system name
static String normalizeOperatingSystem(OperatingSystem operatingSystem) {
Objects.requireNonNull(operatingSystem);
return operatingSystem == OperatingSystem.UNKNOWN ? "unknown" : operatingSystem.getCheckedName();
}
/// Normalizes one condition context token.
private static String normalizeToken(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim().toLowerCase(Locale.ROOT);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Empty theme condition context value: " + name);
}
return normalized;
}
}
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Title-bar settings contributed by a theme-pack appearance.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeTitleBar(@Nullable Boolean transparent) {
/// JSON member name for title-bar transparency.
private static final String FIELD_TRANSPARENT = "transparent";
/// Creates a title-bar patch.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
public ThemeTitleBar {
}
/// Parses title-bar settings from a JSON object.
///
/// @param object the title-bar JSON object
/// @return the parsed title-bar patch
static ThemeTitleBar fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeTitleBar(readTransparent(object));
}
/// Converts this title-bar patch to its JSON representation.
///
/// @return the JSON object representing this title-bar patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (transparent != null) {
object.addProperty(FIELD_TRANSPARENT, transparent);
}
return object;
}
/// Returns whether this title-bar object contains no concrete fields.
///
/// @return `true` when every field is inherited
public boolean isEmpty() {
return transparent == null;
}
/// Returns a title-bar patch that applies the given patch over this title-bar patch.
///
/// @param patch the patch to apply
/// @return the merged title-bar patch
public ThemeTitleBar merge(ThemeTitleBar patch) {
Objects.requireNonNull(patch);
return new ThemeTitleBar(patch.transparent != null ? patch.transparent : transparent);
}
/// Reads the optional transparent field.
private static @Nullable Boolean readTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme titleBar.transparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
}
File diff suppressed because it is too large. Load diff
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
/// Extracts a MonetFX seed color from wallpaper images.
@NotNullByDefault
public final class WallpaperColorExtractor {
/// Prevents instantiation.
private WallpaperColorExtractor() {
}
/// Extracts a theme color from an image file.
///
/// @param imageFile the image file
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the image file cannot be read
public static ThemeColor extract(Path imageFile, ThemeColor fallback) throws IOException {
Objects.requireNonNull(imageFile);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(imageFile);
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + imageFile, e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a theme-pack resource.
///
/// @param resource the theme-pack resource
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the resource cannot be read
static ThemeColor extract(ThemePackResource resource, ThemeColor fallback) throws IOException {
Objects.requireNonNull(resource);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(resource.openStream(), resource.name());
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + resource.name(), e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a loaded image.
///
/// @param image the loaded image
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
public static ThemeColor extract(Image image, ThemeColor fallback) {
Objects.requireNonNull(image);
Objects.requireNonNull(fallback);
Color extracted = ColorScheme.extractColor(image, fallback.color());
return ThemeColor.of(extracted);
}
}
@@ -117,7 +117,6 @@ final class MainWindowPane extends StackPane {
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
@@ -1,44 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
/// Tests for built-in launcher wallpapers.
@NotNullByDefault
public final class BuiltinBackgroundTest {
/// Checks precomputed MonetFX seed colors against dynamic extraction.
@Test
public void precomputedThemeColorsMatchDynamicExtraction() {
List<Executable> checks = Arrays.stream(BuiltinBackground.values())
.<Executable>map(background -> () -> assertEquals(
WallpaperColorExtractor.extract(loadWallpaper(background), ThemeColor.DEFAULT),
background.themeColor(),
background.id()))
.toList();
assertAll(checks);
}
/// Loads the bundled wallpaper for a built-in background.
private static Image loadWallpaper(BuiltinBackground background) throws Exception {
String fileName = background.id() + ".jpg";
@Nullable InputStream input = BuiltinBackgroundTest.class.getResourceAsStream(
"/assets/img/wallpapers/" + fileName);
if (input == null) {
throw new AssertionError("Missing built-in wallpaper: " + fileName);
}
return FXUtils.loadImage(input, fileName);
}
}
@@ -1,39 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.geometry.Insets;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
/// Tests launcher background opacity storage.
@NotNullByDefault
public final class LauncherBackgroundTest {
/// Verifies opacity changes reuse the unmodified JavaFX background.
@Test
public void changesOpacityWithoutChangingBackground() {
Background background = new Background(
new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY));
LauncherBackground changed = new LauncherBackground(background, 0.4);
assertSame(background, changed.background());
assertEquals(0.4, changed.opacity());
assertEquals(1.0, ((Color) changed.background().getFills().get(0).getFill()).getOpacity());
}
/// Verifies invalid node opacity values are rejected.
@Test
public void rejectsInvalidOpacity() {
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, -0.1));
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, 1.1));
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, Double.NaN));
}
}
@@ -1,156 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/// Tests for theme-pack import, export, and resolution behavior.
@NotNullByDefault
public final class ThemePackManagerTest {
/// Theme-pack image assets keep their entry names when exported from unpacked theme directories.
@Test
public void exportsUnpackedThemeResourceWithEntryName() throws Exception {
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
launcherSettingsField.setAccessible(true);
Object previousLauncherSettings = launcherSettingsField.get(null);
String packId = "example.unpacked-export";
Path themePacksDirectory = ThemePackManager.THEME_PACKS_DIRECTORY.toAbsolutePath().normalize();
Path themeDirectory = themePacksDirectory.resolve(packId).normalize();
if (!themeDirectory.startsWith(themePacksDirectory)) {
throw new AssertionError("Theme-pack test directory escapes the theme-pack directory: " + themeDirectory);
}
try {
if (Files.exists(themeDirectory)) {
FileUtils.forceDelete(themeDirectory);
}
Path wallpaper = themeDirectory.resolve("assets/wallpapers/wallpaper.png");
Files.createDirectories(wallpaper.getParent());
byte[] body = "unpacked image bytes".getBytes(StandardCharsets.US_ASCII);
Files.write(wallpaper, body);
Files.writeString(themeDirectory.resolve(ThemePackExporter.MANIFEST_ENTRY), """
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.unpacked-export",
"version": "1.0.0",
"name": "Unpacked",
"theme": {
"background": {
"type": "image",
"path": "assets/wallpapers/wallpaper.png"
}
}
}
""");
LauncherSettings launcherSettings = new LauncherSettings();
launcherSettings.selectedThemeProperty().set(new ThemeReference(packId, null));
launcherSettingsField.set(null, launcherSettings);
ThemePackManager.ExportedThemePack exported =
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
Theme theme = exported.manifest().themes().get(0);
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
assertEquals("assets/wallpapers/wallpaper.png", image.path());
assertEquals(1, exported.assets().size());
assertEquals("assets/wallpapers/wallpaper.png", exported.assets().get(0).entryName());
try (InputStream input = exported.assets().get(0).source().openStream()) {
assertArrayEquals(body, input.readAllBytes());
}
} finally {
launcherSettingsField.set(null, previousLauncherSettings);
if (Files.exists(themeDirectory)) {
FileUtils.forceDelete(themeDirectory);
}
}
}
/// Network launcher backgrounds are downloaded and exported as local theme-pack image assets.
@Test
public void exportsNetworkBackgroundAsImageAsset() throws Exception {
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
launcherSettingsField.setAccessible(true);
Object previousLauncherSettings = launcherSettingsField.get(null);
byte[] body = "fake image bytes".getBytes(StandardCharsets.US_ASCII);
ExecutorService executor = Executors.newSingleThreadExecutor();
try (ServerSocket server = new ServerSocket(0)) {
Future<?> serverTask = executor.submit(() -> {
try (Socket socket = server.accept();
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream()) {
int matched = 0;
byte[] endOfHeaders = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
int nextByte;
while (matched < endOfHeaders.length && (nextByte = input.read()) >= 0) {
matched = nextByte == endOfHeaders[matched] ? matched + 1 : 0;
}
byte[] header = ("HTTP/1.1 200 OK\r\n"
+ "Content-Type: image/png\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n").getBytes(StandardCharsets.US_ASCII);
output.write(header);
output.write(body);
}
return null;
});
LauncherSettings launcherSettings = new LauncherSettings();
launcherSettings.backgroundTypeProperty().set(BackgroundType.NETWORK);
launcherSettings.networkBackgroundImageUrlProperty().set(
"http://127.0.0.1:" + server.getLocalPort() + "/wallpaper.png");
launcherSettings.networkBackgroundImageCachePolicyProperty().set(NetworkBackgroundImageCachePolicy.DISABLED);
launcherSettings.getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND);
launcherSettingsField.set(null, launcherSettings);
ThemePackManager.ExportedThemePack exported =
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
try {
Theme theme = exported.manifest().themes().get(0);
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
assertEquals("assets/wallpapers/wallpaper.png", image.path());
assertEquals(1, exported.assets().size());
try (InputStream input = exported.assets().get(0).source().openStream()) {
assertArrayEquals(body, input.readAllBytes());
}
serverTask.get(5, TimeUnit.SECONDS);
} finally {
for (Path temporaryFile : exported.temporaryFiles()) {
Files.deleteIfExists(temporaryFile);
}
}
} finally {
launcherSettingsField.set(null, previousLauncherSettings);
executor.shutdownNow();
}
}
}
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.Brightness;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/// Tests for theme-pack manifest parsing and resolution behavior.
@NotNullByDefault
public final class ThemePackManifestTest {
/// Invalid optional metadata fields are ignored instead of rejecting the whole manifest.
@Test
public void invalidOptionalManifestFieldsAreIgnored() {
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.invalid-optionals",
"version": "1.0.0",
"name": "Example",
"authors": "invalid",
"description": "",
"icon": "../bad.png",
"theme": {
"id": "../bad",
"name": "",
"description": [],
"icon": "../theme.png"
}
}
""", ThemePackManifest.class));
assertTrue(manifest.authors().isEmpty());
assertNull(manifest.description());
assertNull(manifest.icon());
Theme theme = manifest.themes().get(0);
assertNull(theme.id());
assertNull(theme.name());
assertNull(theme.description());
assertNull(theme.icon());
}
/// Applying a theme without a background should resolve to the launcher default background.
@Test
public void emptyThemeUsesDefaultBackgroundAfterApplying() throws Exception {
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.empty-theme",
"version": "1.0.0",
"name": "Example",
"theme": {}
}
""", ThemePackManifest.class));
Theme theme = manifest.themes().get(0);
ThemePackManager.ResolvedBackground background = ThemePackManager.resolveBackgroundAfterApplyingTheme(
new ThemePackManager.InstalledThemePack(Path.of("example.hmcl-theme"), manifest),
theme,
new ThemeResolveContext(Brightness.LIGHT, "linux", "en"));
assertEquals(BackgroundType.DEFAULT, background.type());
assertEquals(1.0, background.opacity(), 0.0);
assertNull(theme.appearance().windowTransparent());
}
}