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:
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());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user