From 2c8446fa33a2d703aa86244d2772aa48146a204d Mon Sep 17 00:00:00 2001 From: Layue13 Date: Sun, 8 Nov 2026 14:56:14 +0800 Subject: [PATCH] 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 --- .../org/jackhuang/hmcl/game/OAuthServer.java | 2 +- .../hmcl/setting/LauncherSettings.java | 19 - .../hmcl/theme/LauncherBackground.java | 24 - .../jackhuang/hmcl/theme/ResolvedTheme.java | 38 - .../java/org/jackhuang/hmcl/theme/Theme.java | 229 --- .../jackhuang/hmcl/theme/ThemeAppearance.java | 374 ---- .../jackhuang/hmcl/theme/ThemeBackground.java | 215 --- .../hmcl/theme/ThemeBackgroundSettings.java | 103 -- .../hmcl/theme/ThemeColorSource.java | 157 -- .../jackhuang/hmcl/theme/ThemeCondition.java | 189 -- .../jackhuang/hmcl/theme/ThemeOverride.java | 76 - .../jackhuang/hmcl/theme/ThemePackAsset.java | 72 - .../jackhuang/hmcl/theme/ThemePackAuthor.java | 138 -- .../hmcl/theme/ThemePackExporter.java | 100 - .../hmcl/theme/ThemePackManager.java | 1607 ----------------- .../hmcl/theme/ThemePackManifest.java | 345 ---- .../hmcl/theme/ThemePackResource.java | 200 -- .../jackhuang/hmcl/theme/ThemeReference.java | 30 - .../hmcl/theme/ThemeResolveContext.java | 79 - .../jackhuang/hmcl/theme/ThemeTitleBar.java | 80 - .../java/org/jackhuang/hmcl/theme/Themes.java | 1163 +----------- .../hmcl/theme/WallpaperColorExtractor.java | 80 - .../hmcl/ui/decorator/MainWindowPane.java | 1 - .../hmcl/theme/BuiltinBackgroundTest.java | 44 - .../hmcl/theme/LauncherBackgroundTest.java | 39 - .../hmcl/theme/ThemePackManagerTest.java | 156 -- .../hmcl/theme/ThemePackManifestTest.java | 77 - 27 files changed, 38 insertions(+), 5599 deletions(-) delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java delete mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java delete mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java delete mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java delete mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java index 318e827657..85783102e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -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")) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java index 978b5de0db..a8623fd62a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java @@ -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. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java deleted file mode 100644 index 6db69cdae2..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java +++ /dev/null @@ -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); - } - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java deleted file mode 100644 index ea2768830a..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java +++ /dev/null @@ -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(); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java deleted file mode 100644 index bf9460e4c5..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java +++ /dev/null @@ -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 authors, - @Nullable LocalizedText description, - @Nullable String icon, - ThemeAppearance appearance, - @Unmodifiable List 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 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 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; - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java deleted file mode 100644 index e8246f5c0b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java +++ /dev/null @@ -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"; - }; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java deleted file mode 100644 index 3568c18533..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java +++ /dev/null @@ -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"); - } - - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java deleted file mode 100644 index f47eef18e3..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java +++ /dev/null @@ -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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java deleted file mode 100644 index 091299335e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java +++ /dev/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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java deleted file mode 100644 index cdb67e8dc0..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java +++ /dev/null @@ -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> 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 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> copy = new LinkedHashMap<>(); - for (Map.Entry> entry : requirements.entrySet()) { - String key = normalizeKey(entry.getKey()); - Set values = entry.getValue(); - if (values.isEmpty()) { - throw new IllegalArgumentException("Theme condition field has no accepted values: " + key); - } - - LinkedHashSet 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> requirements = new LinkedHashMap<>(); - for (Map.Entry 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> 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> 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 readAcceptedValues(String key, JsonElement element) throws JsonParseException { - LinkedHashSet 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; - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java deleted file mode 100644 index 2abef978e2..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java deleted file mode 100644 index 1d12195bed..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java deleted file mode 100644 index 16a261b1fa..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java +++ /dev/null @@ -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 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 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 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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java deleted file mode 100644 index c6e547a3ac..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java +++ /dev/null @@ -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 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 assets) throws IOException { - Set 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()); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java deleted file mode 100644 index b26444d070..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java +++ /dev/null @@ -1,1607 +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.JsonParseException; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import kala.compress.archivers.zip.ZipArchiveEntry; -import kala.compress.archivers.zip.ZipArchiveReader; -import org.glavo.monetfx.Brightness; -import org.glavo.monetfx.ColorRole; -import org.glavo.monetfx.ColorStyle; -import org.glavo.monetfx.Contrast; -import org.glavo.url.WebURL; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.BackgroundType; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.SettingsManager; -import org.jackhuang.hmcl.setting.ThemeColorType; -import org.jackhuang.hmcl.task.CacheFileTask; -import org.jackhuang.hmcl.util.MathUtils; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.i18n.LocalizedText; -import org.jackhuang.hmcl.util.io.ContentEncoding; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.IOUtils; -import org.jackhuang.hmcl.util.io.NetworkUtils; -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.io.OutputStream; -import java.net.HttpURLConnection; -import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/// Imports, applies, and exports launcher theme packs. -@NotNullByDefault -public final class ThemePackManager { - /// Directory where imported theme packs are stored for the launcher UI. - public static final Path THEME_PACKS_DIRECTORY = Metadata.HMCL_LOCAL_HOME.resolve("themes"); - - /// Directory where user-wide theme packs are discovered. - private static final Path USER_THEME_PACKS_DIRECTORY = Metadata.HMCL_USER_HOME.resolve("themes"); - - /// Default version used when exporting the current launcher appearance. - public static final String CURRENT_THEME_PACK_VERSION = "1.0.0"; - - /// IDs of launcher-bundled theme packs in display order. - private static final @Unmodifiable List BUILTIN_THEME_PACK_IDS = List.of( - "hmcl.default", - "hmcl.classic" - ); - - /// Built-in default theme reference. - public static final ThemeReference BUILTIN_DEFAULT_THEME_REFERENCE = LauncherSettings.DEFAULT_THEME_REFERENCE; - - /// Built-in theme packs bundled with the launcher. - private static final @Unmodifiable List BUILTIN_THEME_PACKS = loadBuiltinThemePacks(); - - /// Prevents instantiation. - private ThemePackManager() { - } - - /// A theme pack loaded from a zip-compatible theme-pack file. - /// - /// @param file the source theme-pack file - /// @param manifest the parsed manifest - public record LoadedThemePack(Path file, ThemePackManifest manifest) { - /// Creates a loaded theme-pack descriptor. - /// - /// @param file the source theme-pack file - /// @param manifest the parsed manifest - public LoadedThemePack { - file = Objects.requireNonNull(file).toAbsolutePath().normalize(); - Objects.requireNonNull(manifest); - } - } - - /// Identifies where an installed theme pack is stored. - public sealed interface ThemePackLocation permits ThemePackLocation.Local, ThemePackLocation.Builtin { - /// Returns whether this location points to launcher-bundled resources. - /// - /// @return `true` for built-in theme packs - boolean builtin(); - - /// Returns the backing filesystem path for local theme packs. - /// - /// @return the backing file or directory, or `null` for non-filesystem locations - @Nullable Path file(); - - /// A theme pack stored as a filesystem file or directory. - /// - /// @param file the backing file or directory - @NotNullByDefault - record Local(Path file) implements ThemePackLocation { - /// Creates a local theme-pack location. - /// - /// @param file the backing file or directory - public Local { - file = Objects.requireNonNull(file).toAbsolutePath().normalize(); - } - - /// Returns whether this location points to launcher-bundled resources. - @Override - public boolean builtin() { - return false; - } - } - - /// A theme pack bundled in launcher classpath resources. - /// - /// @param id the built-in theme-pack ID - @NotNullByDefault - record Builtin(String id) implements ThemePackLocation { - /// Creates a built-in theme-pack location. - /// - /// @param id the built-in theme-pack ID - public Builtin { - id = ThemePackManifest.requirePackageId(id); - } - - /// Returns whether this location points to launcher-bundled resources. - @Override - public boolean builtin() { - return true; - } - - /// Returns the backing filesystem path for local theme packs. - @Override - public @Nullable Path file() { - return null; - } - } - } - - /// A theme pack available to the launcher. - /// - /// @param location where this theme pack is stored - /// @param manifest the parsed manifest - public record InstalledThemePack(ThemePackLocation location, ThemePackManifest manifest) { - /// Creates an installed user theme-pack descriptor. - /// - /// @param file the installed theme-pack file - /// @param manifest the parsed manifest - public InstalledThemePack(Path file, ThemePackManifest manifest) { - this(new ThemePackLocation.Local(file), manifest); - } - - /// Creates an available theme-pack descriptor. - /// - /// @param location where this theme pack is stored - /// @param manifest the parsed manifest - public InstalledThemePack { - Objects.requireNonNull(location); - Objects.requireNonNull(manifest); - } - - /// Returns whether this package is bundled with the launcher resources. - /// - /// @return `true` for built-in theme packs - public boolean builtin() { - return location.builtin(); - } - - /// Returns the backing filesystem path for local theme packs. - /// - /// @return the backing file or directory, or `null` for built-in theme packs - public @Nullable Path file() { - return location.file(); - } - } - - /// A theme pack assembled from launcher settings and ready to export. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - /// @param temporaryFiles temporary files owned by this export result - public record ExportedThemePack( - ThemePackManifest manifest, - @Unmodifiable List assets, - @Unmodifiable List temporaryFiles) { - /// Creates an exportable theme-pack descriptor without owned temporary files. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - public ExportedThemePack(ThemePackManifest manifest, @Unmodifiable List assets) { - this(manifest, assets, List.of()); - } - - /// Creates an exportable theme-pack descriptor. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - /// @param temporaryFiles temporary files owned by this export result - public ExportedThemePack { - Objects.requireNonNull(manifest); - assets = List.copyOf(assets); - temporaryFiles = List.copyOf(temporaryFiles); - } - } - - /// A concrete launcher background resolved from either custom settings or a selected theme. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param imageResource the resolved theme-pack image resource, or `null` when not using a theme-pack image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public record ResolvedBackground( - BackgroundType type, - @Nullable String builtinBackgroundId, - @Nullable Path imagePath, - @Nullable ThemePackResource imageResource, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - /// Creates a resolved launcher background without a built-in wallpaper ID. - /// - /// @param type the launcher background source type - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable Path imagePath, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - this(type, null, imagePath, null, networkImageUrl, networkImageCachePolicy, paint, opacity); - } - - /// Creates a resolved launcher background with a theme-pack image resource. - /// - /// @param type the launcher background source type - /// @param imagePath the resolved local image file, or `null` when the resource is not a direct file - /// @param imageResource the resolved theme-pack image resource - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable Path imagePath, - ThemePackResource imageResource, - double opacity) { - this(type, null, imagePath, imageResource, null, null, null, opacity); - } - - /// Creates a resolved launcher background. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable String builtinBackgroundId, - @Nullable Path imagePath, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - this(type, builtinBackgroundId, imagePath, null, networkImageUrl, networkImageCachePolicy, paint, opacity); - } - - /// Creates a resolved launcher background. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param imageResource the resolved theme-pack image resource, or `null` when not using a theme-pack image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground { - Objects.requireNonNull(type); - if (builtinBackgroundId != null) { - builtinBackgroundId = builtinBackgroundId.trim(); - if (builtinBackgroundId.isEmpty()) { - throw new IllegalArgumentException("Resolved background built-in wallpaper ID is blank"); - } - } - opacity = Double.isFinite(opacity) - ? MathUtils.clamp(opacity, 0.0, 1.0) - : 1.0; - } - - /// Returns a copy with a different opacity. - public ResolvedBackground withOpacity(double opacity) { - return new ResolvedBackground( - type, - builtinBackgroundId, - imagePath, - imageResource, - networkImageUrl, - networkImageCachePolicy, - paint, - opacity); - } - - /// Returns a copy with a different remote image cache policy. - /// - /// @param networkImageCachePolicy the new remote image cache policy - /// @return the copied background - public ResolvedBackground withNetworkImageCachePolicy( - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy) { - return new ResolvedBackground( - type, - builtinBackgroundId, - imagePath, - imageResource, - networkImageUrl, - networkImageCachePolicy, - paint, - opacity); - } - - } - - /// Loads and parses a theme-pack file or unpacked theme-pack directory. - /// - /// @param file the theme-pack file or directory - /// @return the loaded theme pack - /// @throws IOException if the file cannot be read or the manifest is invalid - public static LoadedThemePack load(Path file) throws IOException { - Objects.requireNonNull(file); - - Path normalizedFile = file.toAbsolutePath().normalize(); - try { - if (Files.isDirectory(normalizedFile)) { - Path manifestFile = normalizedFile.resolve(ThemePackExporter.MANIFEST_ENTRY); - if (!Files.isRegularFile(manifestFile)) { - throw new IOException("Theme pack directory does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - - ThemePackManifest manifest = JsonUtils.fromJsonFile(manifestFile, ThemePackManifest.class); - if (manifest == null) { - throw new JsonParseException("Manifest is null"); - } - return new LoadedThemePack(normalizedFile, manifest); - } - - try (var reader = new ZipArchiveReader(normalizedFile)) { - var manifestEntry = reader.getEntry(ThemePackExporter.MANIFEST_ENTRY); - if (manifestEntry == null || manifestEntry.isDirectory()) { - throw new IOException("Theme pack does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - - ThemePackManifest manifest; - try (var inputStream = reader.getInputStream(manifestEntry)) { - manifest = JsonUtils.fromNonNullJsonFully(inputStream, ThemePackManifest.class); - } - return new LoadedThemePack(normalizedFile, manifest); - } - } catch (JsonParseException | IllegalArgumentException e) { - throw new IOException("Invalid theme-pack manifest", e); - } - } - - /// Loads built-in theme pack manifests from launcher resources. - private static @Unmodifiable List loadBuiltinThemePacks() { - ArrayList themePacks = new ArrayList<>(); - for (String id : BUILTIN_THEME_PACK_IDS) { - try { - ThemePackManifest manifest; - - try (InputStream input = ThemePackManager.class.getResourceAsStream( - "/assets/themes/" + id + "/" + ThemePackExporter.MANIFEST_ENTRY)) { - if (input == null) { - throw new IOException("Missing built-in theme-pack manifest: " + id); - } - - manifest = JsonUtils.fromNonNullJsonFully(input, ThemePackManifest.class); - } - if (!manifest.id().equals(id)) { - throw new IOException("Built-in theme-pack id does not match resource directory: " + id); - } - themePacks.add(new InstalledThemePack( - new ThemePackLocation.Builtin(manifest.id()), - manifest)); - } catch (IOException | JsonParseException | IllegalArgumentException e) { - throw new ExceptionInInitializerError(e); - } - } - - if (themePacks.isEmpty()) { - throw new ExceptionInInitializerError("No built-in theme packs are declared"); - } - return List.copyOf(themePacks); - } - - /// Loads a manifest from an installed theme-pack file. - /// - /// @param file the installed theme-pack file - /// @return the installed theme pack - /// @throws IOException if the installed manifest cannot be read or parsed - public static InstalledThemePack loadInstalled(Path file) throws IOException { - LoadedThemePack loadedThemePack = load(file); - return new InstalledThemePack(loadedThemePack.file(), loadedThemePack.manifest()); - } - - /// Loads the installed theme pack referenced by a theme reference. - /// - /// @param reference the referenced installed theme - /// @return the installed theme pack, or `null` if the selected package file does not exist - /// @throws IOException if the installed manifest cannot be read or parsed - public static @Nullable InstalledThemePack findInstalled(ThemeReference reference) throws IOException { - Objects.requireNonNull(reference); - - @Nullable InstalledThemePack themePack = findInstalled(THEME_PACKS_DIRECTORY, reference.packId()); - if (themePack != null) { - return themePack; - } - - themePack = findInstalled(USER_THEME_PACKS_DIRECTORY, reference.packId()); - if (themePack != null) { - return themePack; - } - - return findBuiltinThemePack(reference.packId()); - } - - /// Lists all installed theme packs. - /// - /// @return installed theme packs with built-in package IDs first and local packages overriding user packages - /// @throws IOException if installed theme-pack files cannot be listed - public static @Unmodifiable List listInstalled() throws IOException { - LinkedHashMap highestPriorityThemePacks = new LinkedHashMap<>(); - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - - for (InstalledThemePack themePack : listInstalled(USER_THEME_PACKS_DIRECTORY)) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - for (InstalledThemePack themePack : listInstalled(THEME_PACKS_DIRECTORY)) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - - ArrayList result = new ArrayList<>(); - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - @Nullable InstalledThemePack selectedThemePack = highestPriorityThemePacks.remove(themePack.manifest().id()); - if (selectedThemePack != null) { - result.add(selectedThemePack); - } - } - - ArrayList remainingThemePacks = new ArrayList<>(highestPriorityThemePacks.values()); - remainingThemePacks.sort(Comparator - .comparing((InstalledThemePack themePack) -> themePack.manifest().displayName(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(themePack -> themePack.manifest().id(), String.CASE_INSENSITIVE_ORDER)); - result.addAll(remainingThemePacks); - return List.copyOf(result); - } - - /// Installs a theme-pack file under the launcher's local theme-pack directory. - /// - /// Existing files for the same package ID are replaced. - /// - /// @param file the theme-pack file - /// @return the installed theme pack - /// @throws IOException if the theme pack cannot be validated or installed - public static InstalledThemePack install(Path file) throws IOException { - LoadedThemePack loadedThemePack = load(file); - validateThemePackFile(loadedThemePack.file()); - - Path targetFile = installedThemePackFile(THEME_PACKS_DIRECTORY, loadedThemePack.manifest().id()); - Path temporaryFile = FileUtils.tmpSaveFile(targetFile); - - Files.createDirectories(Objects.requireNonNull(targetFile.getParent())); - deleteIfExists(temporaryFile); - try { - Files.copy(loadedThemePack.file(), temporaryFile, StandardCopyOption.REPLACE_EXISTING); - moveReplacing(temporaryFile, targetFile); - return new InstalledThemePack(targetFile, loadedThemePack.manifest()); - } catch (IOException | RuntimeException e) { - try { - deleteIfExists(temporaryFile); - } catch (IOException suppressed) { - e.addSuppressed(suppressed); - } - throw e; - } - } - - /// Removes an installed theme pack from a managed theme-pack directory. - /// - /// If the removed package is currently selected, the stored theme reference is cleared. - /// - /// @param themePack the installed theme pack to remove - /// @throws IOException if the installed package cannot be removed - public static void uninstall(InstalledThemePack themePack) throws IOException { - Objects.requireNonNull(themePack); - - if (themePack.builtin()) { - throw new IOException("Cannot delete a built-in theme pack: " + themePack.manifest().id()); - } - - @Nullable Path file = themePack.file(); - if (file == null) { - throw new IOException("Theme pack does not have a local file: " + themePack.manifest().id()); - } - - Path targetFile = file.toAbsolutePath().normalize(); - Path localDirectory = THEME_PACKS_DIRECTORY.toAbsolutePath().normalize(); - Path userDirectory = USER_THEME_PACKS_DIRECTORY.toAbsolutePath().normalize(); - boolean localThemePack = targetFile.startsWith(localDirectory) && !targetFile.equals(localDirectory); - boolean userThemePack = targetFile.startsWith(userDirectory) && !targetFile.equals(userDirectory); - if (!localThemePack && !userThemePack) { - throw new IOException("Theme-pack file is outside the managed directory: " + targetFile); - } - - deleteIfExists(targetFile); - - ThemeReference reference = settings().getSelectedThemeOrDefault(); - ThemePackManifest manifest = themePack.manifest(); - if (reference.packId().equals(manifest.id())) { - @Nullable InstalledThemePack replacementThemePack = findInstalled(reference); - @Nullable Theme replacementTheme = replacementThemePack == null - ? null - : replacementThemePack.manifest().findTheme(reference.themeId()); - if (replacementTheme == null) { - settings().selectedThemeProperty().set(BUILTIN_DEFAULT_THEME_REFERENCE); - } - } - } - - /// Applies one theme from a loaded theme pack to current launcher settings. - /// - /// @param themePack the loaded theme pack - /// @param theme the theme to apply - /// @throws IOException if the pack cannot be installed, referenced assets cannot be read, or settings cannot be applied - public static void apply(LoadedThemePack themePack, Theme theme) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - InstalledThemePack installedThemePack = install(themePack.file()); - @Nullable Theme installedTheme = installedThemePack.manifest().findTheme(theme.id()); - if (installedTheme == null) { - throw new IOException("Installed theme pack does not contain theme: " + theme.id()); - } - apply(installedThemePack, installedTheme); - } - - /// Applies one theme from an installed theme pack to current launcher settings. - /// - /// @param themePack the installed theme pack - /// @param theme the theme to apply - /// @throws IOException if referenced assets cannot be read or settings cannot be applied - public static void apply(InstalledThemePack themePack, Theme theme) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - apply(themePack.manifest(), theme); - } - - /// Applies one theme to current launcher settings. - /// - /// @param manifest the parsed manifest - /// @param theme the theme to apply - /// @throws IOException if referenced assets cannot be read or settings cannot be applied - private static void apply(ThemePackManifest manifest, Theme theme) throws IOException { - Objects.requireNonNull(manifest); - Objects.requireNonNull(theme); - - ThemeReference reference = new ThemeReference(manifest.id(), theme.id()); - LauncherSettings currentSettings = settings(); - currentSettings.getThemeAppearanceOverrides().clear(); - currentSettings.selectedThemeProperty().set(reference); - } - - /// Exports the current launcher appearance to a theme-pack file. - /// - /// @param outputFile the target theme-pack file - /// @param packId the exported package identifier - /// @param version the exported package version - /// @param packName the exported package display name - /// @param authorName the exported package author name - /// @throws IOException if the current appearance cannot be exported - public static void exportCurrent(Path outputFile, String packId, String version, String packName, String authorName) throws IOException { - Objects.requireNonNull(outputFile); - - ExportedThemePack themePack = createCurrent(packId, version, packName, authorName); - try { - ThemePackExporter.export(themePack.manifest(), themePack.assets(), outputFile); - } finally { - for (Path temporaryFile : themePack.temporaryFiles()) { - try { - Files.deleteIfExists(temporaryFile); - } catch (IOException e) { - LOG.warning("Failed to delete temporary theme-pack asset: " + temporaryFile, e); - } - } - } - } - - /// Creates an exportable theme pack from the current launcher appearance. - /// - /// @param packId the exported package identifier - /// @param version the exported package version - /// @param packName the exported package display name - /// @param authorName the exported package author name - /// @return the exportable theme-pack descriptor - /// @throws IOException if the current appearance cannot be represented as a theme pack - public static ExportedThemePack createCurrent(String packId, String version, String packName, String authorName) throws IOException { - packId = ThemePackManifest.requirePackageId(packId); - version = requireNonBlank(version, "version"); - packName = requireNonBlank(packName, "packName"); - authorName = requireNonBlank(authorName, "authorName"); - - List assets = new ArrayList<>(); - List temporaryFiles = new ArrayList<>(); - try { - ThemeBackgroundSettings background = createCurrentBackground(assets, temporaryFiles); - ThemeAppearance appearance = new ThemeAppearance( - currentThemeColorSource(), - currentControlledBrightness(), - currentColorStyle(), - null, - background, - new ThemeTitleBar(currentTitleBarTransparent()), - currentWindowTransparent()); - Theme theme = new Theme( - null, - null, - List.of(), - null, - null, - appearance, - List.of()); - ThemePackManifest manifest = new ThemePackManifest( - packId, - version, - LocalizedText.plain(packName), - List.of(new ThemePackAuthor(LocalizedText.plain(authorName))), - background.source() instanceof ThemeBackground.Image image ? image.path() : null, - null, - List.of(theme)); - - return new ExportedThemePack(manifest, assets, temporaryFiles); - } catch (IOException | RuntimeException e) { - for (Path temporaryFile : temporaryFiles) { - try { - Files.deleteIfExists(temporaryFile); - } catch (IOException suppressed) { - e.addSuppressed(suppressed); - } - } - throw e; - } - } - - /// Returns the current launcher theme color source as a theme-pack directive. - private static ThemeColorSource currentThemeColorSource() throws IOException { - BackgroundType backgroundType = resolveCurrentBackground(currentResolveContext()).type(); - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR)) { - @Nullable ThemeColorSource colorSource = resolveCurrentThemeColorSource(currentResolveContext()); - if (colorSource != null) { - if (colorSource instanceof ThemeColorSource.Wallpaper && backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColorSource.DEFAULT; - } - return colorSource; - } - return ThemeColorSource.DEFAULT; - } - - ThemeColorType themeColorType = Objects.requireNonNullElse( - settings().themeColorTypeProperty().get(), - ThemeColorType.DEFAULT); - if (themeColorType == ThemeColorType.DEFAULT) { - return ThemeColorSource.DEFAULT; - } - if (themeColorType == ThemeColorType.SYSTEM) { - return ThemeColorSource.custom(Themes.getSystemThemeColor()); - } - if (themeColorType == ThemeColorType.BACKGROUND) { - if (backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColorSource.DEFAULT; - } - return ThemeColorSource.wallpaper(); - } - - ThemeColor color = Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT); - return ThemeColorSource.custom(color); - } - - /// Returns the current launcher color style as a theme-pack directive. - private static ColorStyle currentColorStyle() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR_STYLE) - ? Objects.requireNonNullElse(settings().themeColorStyleProperty().get(), ResolvedTheme.DEFAULT.colorStyle()) - : Objects.requireNonNullElse( - resolveCurrentThemeColorStyle(currentResolveContext()), - ResolvedTheme.DEFAULT.colorStyle()); - } - - /// Returns the current launcher brightness as an explicit theme-pack directive, or `null` for auto mode. - private static @Nullable Brightness currentControlledBrightness() throws IOException { - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - return resolveCurrentThemeBrightness(currentResolveContext()); - } - - String brightness = settings().themeBrightnessModeProperty().get(); - if (StringUtils.isBlank(brightness)) { - return null; - } - return switch (brightness.trim().toLowerCase(Locale.ROOT)) { - case "light" -> Brightness.LIGHT; - case "dark" -> Brightness.DARK; - default -> null; - }; - } - - /// Returns whether the current title bar should be transparent. - private static boolean currentTitleBarTransparent() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_TITLE_BAR_TRANSPARENT) - ? settings().titleBarTransparentProperty().get() - : Objects.requireNonNullElse(resolveCurrentTitleBarTransparent(currentResolveContext()), false); - } - - /// Returns whether the current launcher window should be transparent. - private static boolean currentWindowTransparent() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT) - ? settings().windowTransparentProperty().get() - : Objects.requireNonNullElse(resolveCurrentWindowTransparent(currentResolveContext()), false); - } - - /// Resolves the selected theme's controlled brightness. - /// - /// @param context the condition resolution context - /// @return the selected theme brightness directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Brightness resolveCurrentThemeBrightness(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.brightness() : null; - } - - /// Resolves the selected theme color style directive. - /// - /// @param context the condition resolution context - /// @return the selected theme color style directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable ColorStyle resolveCurrentThemeColorStyle(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.colorStyle() : null; - } - - /// Resolves the selected theme contrast directive. - /// - /// @param context the condition resolution context - /// @return the selected theme contrast directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Contrast resolveCurrentThemeContrast(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.contrast() : null; - } - - /// Resolves the selected theme title-bar transparency directive. - /// - /// @param context the condition resolution context - /// @return the selected theme title-bar transparency directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Boolean resolveCurrentTitleBarTransparent(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null && appearance.titleBar() != null ? appearance.titleBar().transparent() : null; - } - - /// Resolves the selected theme window transparency directive. - /// - /// @param context the condition resolution context - /// @return the selected theme window transparency directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Boolean resolveCurrentWindowTransparent(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.windowTransparent() : null; - } - - /// Resolves the current selected theme color source without extracting any wallpaper pixels. - /// - /// @param context the condition resolution context - /// @return the selected theme color source, or `null` when unavailable - /// @throws IOException if the selected theme pack manifest cannot be read - public static @Nullable ThemeColorSource resolveCurrentThemeColorSource(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.color() : null; - } - - /// Resolves the selected theme appearance for a condition context. - private static @Nullable ThemeAppearance resolveCurrentThemeAppearance(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - ThemeReference reference = settings().getSelectedThemeOrDefault(); - - @Nullable InstalledThemePack themePack = findInstalled(reference); - if (themePack == null) { - return null; - } - - @Nullable Theme theme = themePack.manifest().findTheme(reference.themeId()); - if (theme == null) { - return null; - } - - return theme.resolve(context); - } - - /// Returns the current condition resolution context. - /// - /// @return the current resolution context - public static ThemeResolveContext currentResolveContext() { - return ThemeResolveContext.current(Themes.getThemeConditionBrightness()); - } - - /// Resolves the background currently selected by launcher settings. - /// - /// When the launcher background setting is inherited, this resolves the selected launcher theme background first. - /// Otherwise it resolves the direct launcher background fields. - /// - /// @param context the condition resolution context - /// @return the currently effective launcher background - /// @throws IOException if the referenced theme pack exists but its background cannot be resolved - public static ResolvedBackground resolveCurrentBackground(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND)) { - ThemeReference reference = settings().getSelectedThemeOrDefault(); - @Nullable ResolvedBackground resolved = resolveThemeBackground(reference, context); - if (resolved != null) { - return resolved; - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - currentBackgroundOpacityOrDefault()); - } - - return resolveCustomBackground(); - } - - /// Resolves the background that will be effective after applying the given theme. - /// - /// Applying a theme clears launcher appearance overrides, so a theme without a - /// background resolves to the launcher default background with built-in opacity. - /// - /// @param themePack the theme pack to apply - /// @param theme the theme to apply - /// @param context the condition resolution context - /// @return the background that should be displayed after the theme is applied - /// @throws IOException if the theme background cannot be resolved - public static ResolvedBackground resolveBackgroundAfterApplyingTheme( - InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - @Nullable ResolvedBackground resolved = resolveThemeBackground(themePack, theme, context); - if (resolved != null) { - return resolved; - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - 1.0); - } - - /// Resolves the fallback background used when the current launcher background cannot be loaded. - /// - /// @param context the condition resolution context - /// @return the effective fallback background - /// @throws IOException if the selected theme fallback exists but cannot be resolved - public static ResolvedBackground resolveCurrentBackgroundFallback(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - BackgroundType fallbackType = Objects.requireNonNullElse( - settings().backgroundFallbackTypeProperty().get(), - BackgroundType.BUILTIN); - double opacity = currentBackgroundOpacityOrDefault(); - return switch (fallbackType) { - case BUILTIN -> new ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - case PAINT -> new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().backgroundFallbackPaintProperty().get(), - opacity); - case THEME_COLOR -> new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), - opacity); - case CUSTOM, NETWORK, DEFAULT -> new ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - }; - } - - /// Resolves the background loading policy used by current launcher settings. - /// - /// @return the effective background loading policy - public static BackgroundLoadPolicy resolveCurrentBackgroundLoadPolicy() { - return Objects.requireNonNullElse( - settings().backgroundLoadPolicyProperty().get(), - BackgroundLoadPolicy.WAIT_FOR_BACKGROUND); - } - - /// Resolves the launcher custom background fields without considering theme-pack background references. - /// - /// @return the custom launcher background settings - public static ResolvedBackground resolveCustomBackground() { - BackgroundType type = Objects.requireNonNullElse(settings().backgroundTypeProperty().get(), BackgroundType.DEFAULT); - double opacity = currentBackgroundOpacityOrDefault(); - return switch (type) { - case DEFAULT -> new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - case BUILTIN -> new ResolvedBackground( - BackgroundType.BUILTIN, - currentBuiltinBackgroundId(), - null, - null, - null, - null, - opacity); - case CUSTOM -> { - @Nullable String customBackgroundImagePath = settings().customBackgroundImagePathProperty().get(); - @Nullable Path imagePath = StringUtils.isBlank(customBackgroundImagePath) - ? null - : Path.of(customBackgroundImagePath).toAbsolutePath().normalize(); - yield new ResolvedBackground( - BackgroundType.CUSTOM, - imagePath, - null, - null, - null, - opacity); - } - case NETWORK -> { - @Nullable String networkBackgroundImageUrl = settings().networkBackgroundImageUrlProperty().get(); - yield new ResolvedBackground( - BackgroundType.NETWORK, - null, - StringUtils.isBlank(networkBackgroundImageUrl) ? null : networkBackgroundImageUrl.trim(), - Objects.requireNonNullElse( - settings().networkBackgroundImageCachePolicyProperty().get(), - NetworkBackgroundImageCachePolicy.ENABLED), - null, - opacity); - } - case PAINT -> new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().customBackgroundPaintProperty().get(), - opacity); - case THEME_COLOR -> new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), - opacity); - }; - } - - /// Resolves the background contributed by a selected installed theme. - /// - /// @param reference the selected theme reference - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme or background is not available - /// @throws IOException if the installed theme pack exists but its background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - ThemeReference reference, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(reference); - Objects.requireNonNull(context); - - @Nullable InstalledThemePack themePack = findInstalled(reference); - if (themePack == null) { - return null; - } - - @Nullable Theme theme = themePack.manifest().findTheme(reference.themeId()); - if (theme == null) { - return null; - } - - @Nullable ResolvedBackground resolved = resolveThemeBackground(themePack, theme, context); - if (resolved == null) { - return null; - } - if (SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY)) { - resolved = resolved.withOpacity(currentBackgroundOpacity()); - } - if (resolved.type() == BackgroundType.NETWORK) { - resolved = resolved.withNetworkImageCachePolicy(Objects.requireNonNullElse( - settings().networkBackgroundImageCachePolicyProperty().get(), - NetworkBackgroundImageCachePolicy.ENABLED)); - } - return resolved; - } - - /// Resolves the background contributed by one theme without applying launcher appearance overrides. - /// - /// @param themePackFile the installed theme-pack file - /// @param theme the theme to resolve - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme does not define one - /// @throws IOException if the theme background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - Path themePackFile, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePackFile); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - return resolveThemeBackground(new ThemePackLocation.Local(themePackFile), theme, context); - } - - /// Resolves the background contributed by one installed theme without applying launcher appearance overrides. - /// - /// @param themePack the installed theme pack - /// @param theme the theme to resolve - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme does not define one - /// @throws IOException if the theme background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePack); - return resolveThemeBackground(themePack.location(), theme, context); - } - - /// Resolves the background contributed by one theme without applying launcher appearance overrides. - private static @Nullable ResolvedBackground resolveThemeBackground( - ThemePackLocation location, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(location); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - ThemeAppearance appearance = theme.resolve(context); - @Nullable ThemeBackgroundSettings background = appearance.background(); - if (background == null) { - return null; - } - return resolveBackground( - location, - background, - 1.0, - appearance.toResolvedTheme(context).toColorScheme().getColor(ColorRole.SURFACE_CONTAINER)); - } - - /// Resolves a concrete launcher background from a theme-pack background object. - private static ResolvedBackground resolveBackground( - ThemePackLocation location, - ThemeBackgroundSettings background, - double fallbackOpacity, - Paint themeColorPaint) throws IOException { - double opacity = Objects.requireNonNullElse(background.opacity(), fallbackOpacity); - @Nullable ThemeBackground source = background.source(); - if (source instanceof ThemeBackground.Default) { - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - } - if (source instanceof ThemeBackground.Builtin builtin) { - return new ResolvedBackground( - BackgroundType.BUILTIN, - resolveBuiltinBackgroundId(builtin.id()), - null, - null, - null, - null, - opacity); - } - if (source instanceof ThemeBackground.Image image) { - ThemePackResource resource = resolveInstalledAsset( - location, - requireNonBlank(image.path(), "background.path")); - return new ResolvedBackground( - BackgroundType.CUSTOM, - resource.file(), - resource, - opacity); - } - if (source instanceof ThemeBackground.ThemeColor) { - return new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - themeColorPaint, - opacity); - } - if (source instanceof ThemeBackground.Paint paint) { - return new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - parsePaint(requireNonBlank(paint.paint(), "background.paint")), - opacity); - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - } - - /// Resolves one asset referenced by an installed theme pack. - /// - /// @param location the installed theme-pack location - /// @param entryName the theme-pack relative asset entry name - /// @return the resolved resource - /// @throws IOException if the asset cannot be read - public static ThemePackResource resolveInstalledAsset(ThemePackLocation location, String entryName) throws IOException { - Objects.requireNonNull(location); - String normalizedEntryName = ThemePackAsset.normalizeEntryName(entryName); - if (location instanceof ThemePackLocation.Builtin builtin) { - return resolveBuiltinAsset(builtin, normalizedEntryName); - } - - @Nullable Path file = location.file(); - if (file == null) { - throw new IOException("Theme pack location has no readable file: " + location); - } - Path installedFile = file.toAbsolutePath().normalize(); - if (Files.isDirectory(installedFile)) { - Path assetFile = installedFile.resolve(normalizedEntryName).normalize(); - if (!assetFile.startsWith(installedFile)) { - throw new IOException("Theme-pack asset escapes the installed directory: " + normalizedEntryName); - } - if (!Files.isRegularFile(assetFile)) { - throw new IOException("Installed theme-pack asset is missing: " + normalizedEntryName); - } - return new ThemePackResource.File(assetFile, normalizedEntryName); - } - if (!Files.isRegularFile(installedFile)) { - throw new IOException("Installed theme-pack file is missing: " + installedFile); - } - - try (ZipArchiveReader zipFile = new ZipArchiveReader(installedFile, StandardCharsets.UTF_8)) { - ZipArchiveEntry entry = zipFile.getEntry(normalizedEntryName); - if (entry == null || entry.isDirectory()) { - throw new IOException("Installed theme-pack asset is missing: " + normalizedEntryName); - } - } - return new ThemePackResource.Zip(installedFile, normalizedEntryName); - } - - /// Resolves one asset stored in a bundled theme pack. - private static ThemePackResource resolveBuiltinAsset(ThemePackLocation.Builtin builtin, String entryName) throws IOException { - String id = ThemePackManifest.requirePackageId(builtin.id()); - String resourcePath = "/assets/themes/" + id + "/" + entryName; - try (InputStream input = ThemePackManager.class.getResourceAsStream(resourcePath)) { - if (input != null) { - return new ThemePackResource.Builtin(resourcePath, entryName); - } - } - throw new IOException("Built-in theme-pack asset is missing: " + entryName); - } - - /// Returns the installed theme-pack file under a theme-pack directory for one package ID. - private static Path installedThemePackFile(Path themePacksDirectory, String packId) { - String id = ThemePackManifest.requirePackageId(packId); - return themePacksDirectory - .resolve(id + ThemePackExporter.FILE_EXTENSION) - .toAbsolutePath() - .normalize(); - } - - /// Returns whether a path is an installed theme-pack file candidate. - private static boolean isInstalledThemePackFile(Path path) { - String fileName = path.getFileName().toString(); - return !fileName.startsWith(".") - && fileName.endsWith(ThemePackExporter.FILE_EXTENSION); - } - - /// Returns whether a path is an unpacked installed theme-pack directory candidate. - private static boolean isInstalledThemePackDirectory(Path path) { - String fileName = path.getFileName().toString(); - return !fileName.startsWith(".") - && Files.isDirectory(path) - && Files.isRegularFile(path.resolve(ThemePackExporter.MANIFEST_ENTRY)); - } - - /// Returns whether a path is an installed theme-pack candidate. - private static boolean isInstalledThemePackPath(Path path) { - return Files.isRegularFile(path) - ? isInstalledThemePackFile(path) - : isInstalledThemePackDirectory(path); - } - - /// Lists theme packs from one filesystem theme-pack directory. - private static @Unmodifiable List listInstalled(Path themePacksDirectory) throws IOException { - if (!Files.isDirectory(themePacksDirectory)) { - return List.of(); - } - - ArrayList themePacks = new ArrayList<>(); - try (Stream themePackFiles = Files.list(themePacksDirectory)) { - for (Path themePackFile : themePackFiles - .filter(ThemePackManager::isInstalledThemePackPath) - .toList()) { - try { - themePacks.add(loadInstalled(themePackFile)); - } catch (IOException | RuntimeException e) { - LOG.warning("Failed to load installed theme pack: " + themePackFile, e); - } - } - } - - themePacks.sort(Comparator - .comparing((InstalledThemePack themePack) -> themePack.manifest().displayName(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(themePack -> themePack.manifest().id(), String.CASE_INSENSITIVE_ORDER)); - return List.copyOf(themePacks); - } - - /// Finds a theme pack by package ID from one filesystem theme-pack directory. - private static @Nullable InstalledThemePack findInstalled(Path themePacksDirectory, String packId) throws IOException { - Path file = installedThemePackFile(themePacksDirectory, packId); - if (Files.isRegularFile(file)) { - InstalledThemePack themePack = loadInstalled(file); - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - LOG.warning("Installed theme-pack file name does not match manifest id: " + file); - } - return findInstalledByManifestId(themePacksDirectory, packId, file); - } - - /// Finds an installed theme-pack path by package ID stored in its manifest. - private static @Nullable InstalledThemePack findInstalledByManifestId( - Path themePacksDirectory, - String packId, - Path excludedPath) throws IOException { - if (!Files.isDirectory(themePacksDirectory)) { - return null; - } - - Path excluded = excludedPath.toAbsolutePath().normalize(); - try (Stream themePackPaths = Files.list(themePacksDirectory)) { - for (Path themePackPath : themePackPaths - .filter(ThemePackManager::isInstalledThemePackPath) - .sorted(Comparator.comparing( - (Path path) -> path.getFileName().toString(), - String.CASE_INSENSITIVE_ORDER)) - .toList()) { - if (themePackPath.toAbsolutePath().normalize().equals(excluded)) { - continue; - } - try { - InstalledThemePack themePack = loadInstalled(themePackPath); - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Failed to load installed theme pack: " + themePackPath, e); - } - } - } - return null; - } - - /// Finds a bundled theme pack by package ID. - private static @Nullable InstalledThemePack findBuiltinThemePack(String packId) { - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - } - return null; - } - - /// Validates all zip entries in a theme-pack file. - private static void validateThemePackFile(Path themePackFile) throws IOException { - Set entries = new HashSet<>(); - boolean hasManifest = false; - - try (ZipArchiveReader zipFile = new ZipArchiveReader(themePackFile, StandardCharsets.UTF_8)) { - for (ZipArchiveEntry entry : zipFile.getEntries()) { - String rawEntryName = entry.getName(); - String entryName = normalizeThemePackEntryName(rawEntryName); - String canonicalEntryName = entry.isDirectory() ? entryName + "/" : entryName; - if (!canonicalEntryName.equals(rawEntryName)) { - throw new IOException("Theme-pack entry name is not normalized: " + rawEntryName); - } - checkSupportedThemePackEntry(entryName); - - if (!entries.add(entryName)) { - throw new IOException("Duplicate theme-pack entry: " + entryName); - } - if (ThemePackExporter.MANIFEST_ENTRY.equals(entryName) && !entry.isDirectory()) { - hasManifest = true; - } - } - } - - if (!hasManifest) { - throw new IOException("Theme pack does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - } - - /// Moves a file into place, using an atomic move when the platform supports it. - private static void moveReplacing(Path source, Path target) throws IOException { - try { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - /// Returns a normalized and safe theme-pack zip entry name. - private static String normalizeThemePackEntryName(String entryName) throws IOException { - Objects.requireNonNull(entryName); - - String normalized = entryName.trim().replace('\\', '/'); - if (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - if (normalized.isEmpty()) { - throw new IOException("Theme-pack entry is empty"); - } - if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) { - throw new IOException("Theme-pack entry must be relative: " + entryName); - } - - for (String segment : normalized.split("/")) { - if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { - throw new IOException("Theme-pack 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 IOException("Theme-pack entry contains a control character: " + entryName); - } - } - } - return normalized; - } - - /// Checks that a theme-pack zip entry belongs to the current file layout. - private static void checkSupportedThemePackEntry(String entryName) throws IOException { - if (!ThemePackExporter.MANIFEST_ENTRY.equals(entryName) - && !"assets".equals(entryName) - && !entryName.startsWith("assets/")) { - throw new IOException("Unsupported theme-pack entry: " + entryName); - } - } - - /// Deletes an existing file, symbolic link, or directory tree. - private static void deleteIfExists(Path path) throws IOException { - if (Files.exists(path) || Files.isSymbolicLink(path)) { - FileUtils.forceDelete(path); - } - } - - /// Creates the background model for the current launcher settings. - private static ThemeBackgroundSettings createCurrentBackground( - List assets, - List temporaryFiles) throws IOException { - ResolvedBackground background = resolveCurrentBackground(currentResolveContext()); - Double opacity = background.opacity(); - return switch (background.type()) { - case DEFAULT -> new ThemeBackgroundSettings( - new ThemeBackground.Default(), - opacity); - case BUILTIN -> new ThemeBackgroundSettings( - new ThemeBackground.Builtin(background.builtinBackgroundId()), - opacity); - case CUSTOM -> new ThemeBackgroundSettings( - createCurrentImageBackgroundSource(assets, background), - opacity); - case NETWORK -> new ThemeBackgroundSettings( - createCurrentNetworkBackgroundSource(assets, temporaryFiles, background), - opacity); - case PAINT -> new ThemeBackgroundSettings( - new ThemeBackground.Paint(Objects.requireNonNullElse(background.paint(), Color.WHITE).toString()), - opacity); - case THEME_COLOR -> new ThemeBackgroundSettings( - new ThemeBackground.ThemeColor(), - opacity); - }; - } - - /// Creates the image background source for the resolved current background image. - private static ThemeBackground.Image createCurrentImageBackgroundSource( - List assets, - ResolvedBackground background) throws IOException { - @Nullable ThemePackResource imageResource = background.imageResource(); - if (imageResource != null) { - assets.add(new ThemePackAsset(imageResource, imageResource.name())); - return new ThemeBackground.Image(imageResource.name()); - } - - @Nullable Path imagePath = background.imagePath(); - if (imagePath == null) { - throw new IOException("Theme background image path is not configured"); - } - Path source = imagePath.toAbsolutePath().normalize(); - if (Files.isDirectory(source)) { - throw new IOException("Cannot export a background directory as a theme-pack asset: " + source); - } - if (!Files.isRegularFile(source)) { - throw new IOException("Theme background image does not exist: " + source); - } - - String entryName = "assets/wallpapers/" + sanitizePathSegment(source.getFileName().toString()); - assets.add(new ThemePackAsset(source, entryName)); - return new ThemeBackground.Image(entryName); - } - - /// Downloads the current network background and exports it as a theme-pack image asset. - private static ThemeBackground.Image createCurrentNetworkBackgroundSource( - List assets, - List temporaryFiles, - ResolvedBackground background) throws IOException { - String url = requireNonBlank(background.networkImageUrl(), "background.url"); - WebURL webUrl = WebURL.parse(url); - if (!NetworkUtils.isHttpUri(webUrl)) { - throw new IOException("Theme background URL must be HTTP or HTTPS: " + url); - } - - String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl); - if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) { - try { - @Nullable Path cachedFile = new CacheFileTask(webUrl).run(); - if (cachedFile != null && Files.isRegularFile(cachedFile)) { - assets.add(new ThemePackAsset(cachedFile, entryName)); - return new ThemeBackground.Image(entryName); - } - } catch (Exception e) { - LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e); - } - } - - Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp"); - boolean success = false; - try { - downloadNetworkBackground(webUrl, temporaryFile); - assets.add(new ThemePackAsset(temporaryFile, entryName)); - temporaryFiles.add(temporaryFile); - success = true; - return new ThemeBackground.Image(entryName); - } finally { - if (!success) { - try { - deleteIfExists(temporaryFile); - } catch (IOException e) { - LOG.warning("Failed to delete temporary theme-pack asset: " + temporaryFile, e); - } - } - } - } - - /// Downloads one network background into a temporary file without installing it into the persistent image cache. - private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException { - HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url)); - try { - int responseCode = connection.getResponseCode(); - if (responseCode / 100 != 2) { - throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url); - } - - ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection); - try (InputStream input = contentEncoding.wrap(connection.getInputStream()); - OutputStream output = Files.newOutputStream(outputFile)) { - IOUtils.copyTo(input, output, new byte[IOUtils.DEFAULT_BUFFER_SIZE]); - } - } finally { - connection.disconnect(); - } - } - - /// Returns a safe theme-pack asset file name for a downloaded network background. - private static String networkBackgroundAssetName(WebURL url) { - String path = Objects.toString(url.getPath(), ""); - @Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName(); - String fileName = fileNamePath != null ? fileNamePath.toString() : ""; - String sanitized = sanitizePathSegment(fileName); - return "_".equals(sanitized) ? "network-background" : sanitized; - } - - /// Returns the current launcher background opacity. - private static double currentBackgroundOpacity() { - double opacity = settings().backgroundOpacityProperty().get(); - if (!Double.isFinite(opacity)) { - return 1.0; - } - return Math.max(0.0, Math.min(1.0, opacity)); - } - - /// Returns the effective custom launcher background opacity, or the built-in default when not overridden. - private static double currentBackgroundOpacityOrDefault() { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY) - ? currentBackgroundOpacity() - : 1.0; - } - - /// Returns the currently selected built-in wallpaper ID. - private static String currentBuiltinBackgroundId() { - return BuiltinBackground.fromIdOrFallback(settings().builtinBackgroundIdProperty().get()).id(); - } - - /// Resolves a theme-pack built-in wallpaper ID. - private static String resolveBuiltinBackgroundId(@Nullable String id) throws IOException { - String normalizedId = StringUtils.isBlank(id) - ? BuiltinBackground.FALLBACK.id() - : id.trim().toLowerCase(Locale.ROOT); - if (BuiltinBackground.fromId(normalizedId) != null) { - return normalizedId; - } - throw new IOException("Theme packs cannot reference built-in wallpaper: " + normalizedId); - } - - /// Parses a serialized JavaFX paint value. - private static Paint parsePaint(String value) throws IOException { - try { - return Paint.valueOf(value); - } catch (IllegalArgumentException e) { - throw new IOException("Invalid theme background paint: " + value, e); - } - } - - /// Returns a non-blank string value. - private static String requireNonBlank(@Nullable String value, String name) throws IOException { - if (StringUtils.isBlank(value)) { - throw new IOException("Theme pack value is missing: " + name); - } - return value.trim(); - } - - /// Sanitizes one path segment used for exported asset files. - private static String sanitizePathSegment(String value) { - String sanitized = value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); - if (sanitized.isBlank()) { - return "_"; - } - return sanitized; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java deleted file mode 100644 index 2367c095b3..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java +++ /dev/null @@ -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 authors, - @Nullable String icon, - @Nullable LocalizedText description, - @Unmodifiable List 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 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 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 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 localizedValues = new LinkedHashMap<>(); - for (Map.Entry 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 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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java deleted file mode 100644 index 832f195150..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java +++ /dev/null @@ -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; - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java deleted file mode 100644 index 5628224f0b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java +++ /dev/null @@ -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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java deleted file mode 100644 index f50bd0fee9..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java deleted file mode 100644 index 1c61e9c1ab..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java +++ /dev/null @@ -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(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java index 84f17581e0..cd5acb9d9e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -1,1184 +1,95 @@ -// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors +// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors // SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL package org.jackhuang.hmcl.theme; -import com.sun.jna.Pointer; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; -import javafx.beans.binding.ObjectBinding; -import javafx.beans.binding.ObjectExpression; -import javafx.beans.property.*; -import javafx.beans.value.ChangeListener; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.value.ObservableValue; -import javafx.collections.SetChangeListener; -import javafx.event.EventHandler; -import javafx.geometry.Insets; -import javafx.scene.image.Image; -import javafx.scene.layout.Background; -import javafx.scene.layout.BackgroundFill; -import javafx.scene.layout.BackgroundImage; -import javafx.scene.layout.BackgroundPosition; -import javafx.scene.layout.BackgroundRepeat; -import javafx.scene.layout.BackgroundSize; -import javafx.scene.layout.CornerRadii; +import javafx.beans.value.ObservableValueBase; import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; import javafx.stage.Stage; -import javafx.stage.WindowEvent; import org.glavo.monetfx.Brightness; import org.glavo.monetfx.ColorScheme; -import org.glavo.monetfx.ColorRole; +import org.glavo.monetfx.ColorSpecVersion; import org.glavo.monetfx.ColorStyle; import org.glavo.monetfx.Contrast; import org.glavo.monetfx.beans.property.ColorSchemeProperty; import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty; import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty; -import org.glavo.url.WebURL; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.BackgroundType; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.ThemeColorType; -import org.jackhuang.hmcl.task.CacheFileTask; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.MacOSNativeUtils; -import org.jackhuang.hmcl.ui.WindowsNativeUtils; -import org.jackhuang.hmcl.util.MathUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.NativeUtils; -import org.jackhuang.hmcl.util.platform.OSVersion; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.SystemUtils; -import org.jackhuang.hmcl.util.platform.windows.Dwmapi; -import org.jackhuang.hmcl.util.platform.windows.WinConstants; -import org.jackhuang.hmcl.util.platform.windows.WinReg; -import org.jackhuang.hmcl.util.platform.windows.WinTypes; import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.*; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage; -import static org.jackhuang.hmcl.util.io.FileUtils.getExtension; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/// Provides the current launcher MonetFX theme and derived color bindings. +/// Provides the launcher's fixed warm light theme. +/// +/// The launcher no longer supports user themes, wallpapers or dark mode. The MonetFX color scheme here +/// is only used by the few controls that paint colors from Java; style sheets use `/assets/css/warm.css`. @NotNullByDefault public final class Themes { - /// The stage property retaining the listener for native appearance updates and marking completed registration. - private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener"; + /// The seed of the clay accent color. + private static final Color SEED = Color.web("#A9532F"); - /// The seed color extracted from the last loaded wallpaper image. - private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>(); + /// The fixed color scheme. + private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); - /// The resolved launcher theme used to build the current MonetFX color scheme. - private static final ObjectExpression theme = new ObjectBinding<>() { - { - List observables = new ArrayList<>(); + /// Always `false`: the launcher has a light appearance only. + private static final BooleanBinding darkMode = Bindings.createBooleanBinding(() -> false); - observables.add(settings().selectedThemeProperty()); - observables.add(settings().getThemeAppearanceOverrides()); - observables.add(settings().themeBrightnessModeProperty()); - observables.add(settings().customThemeColorProperty()); - observables.add(settings().themeColorTypeProperty()); - observables.add(settings().themeColorStyleProperty()); - observables.add(settings().backgroundTypeProperty()); - observables.add(settings().customBackgroundImagePathProperty()); - observables.add(settings().customBackgroundPaintProperty()); - observables.add(wallpaperThemeColor); - if (FXUtils.DARK_MODE != null) { - observables.add(FXUtils.DARK_MODE); - } - if (FXUtils.ACCENT_COLOR != null) { - observables.add(FXUtils.ACCENT_COLOR); - } - bind(observables.toArray(new Observable[0])); - } + /// Always `false`: the launcher does not follow the system brightness. + private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper(false); - /// Returns the configured MonetFX color style. - private ColorStyle getColorStyle() { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR_STYLE)) { - return Objects.requireNonNullElse( - settings().themeColorStyleProperty().get(), - ResolvedTheme.DEFAULT.colorStyle()); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeColorStyle(ThemePackManager.currentResolveContext()), - ResolvedTheme.DEFAULT.colorStyle()); - } catch (IOException | RuntimeException e) { - return ResolvedTheme.DEFAULT.colorStyle(); - } - } - - /// Returns the configured MonetFX contrast. - private Contrast getContrast() { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeContrast(ThemePackManager.currentResolveContext()), - ResolvedTheme.DEFAULT.contrast()); - } catch (IOException | RuntimeException e) { - return ResolvedTheme.DEFAULT.contrast(); - } - } - - /// Computes the resolved launcher theme. + /// The title text color. + private static final ObservableValue titleFill = new ObservableValueBase<>() { @Override - protected ResolvedTheme computeValue() { - return new ResolvedTheme(resolveCurrentThemeColor(), getCurrentBrightness(), getColorStyle(), getContrast()); + public Color getValue() { + return WarmPalette.INK; } }; - /// Returns the effective theme color for the current launcher settings. - static ThemeColor resolveCurrentThemeColor() { - ThemeColor fallback = settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT) - : ThemeColor.DEFAULT; - ThemeResolveContext context = ThemePackManager.currentResolveContext(); - BackgroundType backgroundType; - try { - backgroundType = ThemePackManager.resolveCurrentBackground(context).type(); - } catch (IOException | RuntimeException e) { - backgroundType = BackgroundType.DEFAULT; - } - return resolveThemeColor( - fallback, - settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? Objects.requireNonNullElse(settings().themeColorTypeProperty().get(), ThemeColorType.DEFAULT) - : null, - backgroundType, - context); - } - - /// Resolves a Monet seed color from configured theme color and background sources. - static ThemeColor resolveThemeColor( - ThemeColor fallback, - @Nullable ThemeColorType themeColorType, - BackgroundType backgroundType, - ThemeResolveContext context) { - Objects.requireNonNull(fallback); - Objects.requireNonNull(backgroundType); - Objects.requireNonNull(context); - - if (themeColorType == null) { - try { - return resolveThemeColorSource( - fallback, - ThemePackManager.resolveCurrentThemeColorSource(context), - backgroundType); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - return switch (themeColorType) { - case DEFAULT -> ThemeColor.DEFAULT; - case SYSTEM -> getSystemThemeColor(); - case CUSTOM -> fallback; - case BACKGROUND -> resolveWallpaperThemeColor(fallback, backgroundType); - }; - } - - /// Resolves a theme-pack color source into a concrete Monet seed color. - private static ThemeColor resolveThemeColorSource( - ThemeColor fallback, - @Nullable ThemeColorSource source, - BackgroundType backgroundType) { - if (source == null) { - return fallback; - } - if (source instanceof ThemeColorSource.Default) { - return ThemeColor.DEFAULT; - } - if (source instanceof ThemeColorSource.Custom custom) { - return custom.color(); - } - return resolveWallpaperThemeColor(fallback, backgroundType); - } - - /// Resolves the Monet seed color represented by the effective wallpaper. - private static ThemeColor resolveWallpaperThemeColor(ThemeColor fallback, BackgroundType backgroundType) { - if (backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColor.DEFAULT; - } - @Nullable ThemeColor loadedWallpaperColor = wallpaperThemeColor.get(); - if (loadedWallpaperColor != null) { - return loadedWallpaperColor; - } - return getBackgroundThemeColor(fallback); - } - - /// Returns a Monet seed color extracted from the current background when possible. - private static ThemeColor getBackgroundThemeColor(ThemeColor fallback) { - try { - ThemePackManager.ResolvedBackground background = - ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()); - return switch (background.type()) { - case CUSTOM -> getImageThemeColor(background.imagePath(), background.imageResource(), fallback); - case BUILTIN -> BuiltinBackground.fromIdOrFallback(background.builtinBackgroundId()).themeColor(); - case PAINT -> background.paint() instanceof Color color ? ThemeColor.of(color) : fallback; - case THEME_COLOR -> ThemeColor.DEFAULT; - case DEFAULT, NETWORK -> fallback; - }; - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - /// Returns a Monet seed color extracted from the current custom background image. - private static ThemeColor getImageThemeColor( - @Nullable Path imageFile, - @Nullable ThemePackResource imageResource, - ThemeColor fallback) { - if (imageResource != null) { - try { - return WallpaperColorExtractor.extract(imageResource, fallback); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - if (imageFile == null) { - return fallback; - } - - try { - if (!Files.isRegularFile(imageFile)) { - return fallback; - } - return WallpaperColorExtractor.extract(imageFile, fallback); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - /// The current MonetFX color scheme generated from [#theme]. - private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); - - /// Whether the current color scheme uses dark brightness. - private static final BooleanBinding darkMode = Bindings.createBooleanBinding( - () -> colorScheme.get().getBrightness() == Brightness.DARK, - colorScheme - ); - - /// Whether the current color scheme brightness is inherited from system. - private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper(); - - /// The current JavaFX launcher background and its node opacity. - private static final ReadOnlyObjectWrapper background = new ReadOnlyObjectWrapper<>( - new LauncherBackground( - new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)), - 1.0)); - - /// The last loaded background source used to rebuild the JavaFX background without reloading images. - private static @Nullable LoadedBackground loadedBackground; - - /// Current background load generation used to ignore obsolete asynchronous updates. - private static int backgroundLoadGeneration = 0; - - /// Whether JavaFX background loading has been requested by the UI. - private static boolean backgroundUpdatesStarted = false; - - /// Whether theme application is already installing a preloaded background. - private static boolean suppressBackgroundRefresh = false; - - /// A loaded JavaFX background source and the wallpaper color extracted while loading it. - private sealed interface LoadedBackground { - /// Returns the loaded JavaFX background and its node opacity. - LauncherBackground background(); - - /// Returns whether this background is the configured fallback background. - boolean fallbackBackground(); - - /// Returns the seed color extracted from the actual wallpaper image, or `null` when unavailable. - @Nullable ThemeColor wallpaperThemeColor(); - - /// A loaded image background. - /// - /// @param background the loaded JavaFX background - /// @param image the loaded image source - /// @param wallpaperThemeColor the seed color extracted from [#image], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - record Image( - LauncherBackground background, - javafx.scene.image.Image image, - @Nullable ThemeColor wallpaperThemeColor, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded image background. - /// - /// @param background the loaded JavaFX background - /// @param image the loaded image source - /// @param wallpaperThemeColor the seed color extracted from [#image], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - public Image { - Objects.requireNonNull(background); - Objects.requireNonNull(image); - } - } - - /// A loaded fixed-paint background. - /// - /// @param background the loaded JavaFX background - /// @param paint the paint source, or `null` for the launcher default paint - /// @param wallpaperThemeColor the seed color represented by [#paint], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - record Paint( - LauncherBackground background, - @Nullable javafx.scene.paint.Paint paint, - @Nullable ThemeColor wallpaperThemeColor, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded fixed-paint background. - /// - /// @param background the loaded JavaFX background - /// @param paint the paint source, or `null` for the launcher default paint - /// @param wallpaperThemeColor the seed color represented by [#paint], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - public Paint { - Objects.requireNonNull(background); - } - } - - /// A loaded background whose paint follows the current theme color scheme. - /// - /// @param background the loaded JavaFX background - /// @param fallbackBackground whether this background is the configured fallback background - record ThemeColorFill( - LauncherBackground background, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded theme-color background. - /// - /// @param background the loaded JavaFX background - /// @param fallbackBackground whether this background is the configured fallback background - public ThemeColorFill { - Objects.requireNonNull(background); - } - - @Override - public @Nullable ThemeColor wallpaperThemeColor() { - return null; - } - } - } - static { - Supplier<@Nullable Brightness> effectiveBrightnessSupplier = () -> { - try { - return ThemePackManager.resolveCurrentThemeBrightness(ThemePackManager.currentResolveContext()); - } catch (IOException | RuntimeException e) { - return null; - } - }; - InvalidationListener autoBrightnessListener = o -> { - boolean auto; - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - String val = settings().themeBrightnessModeProperty().get(); - auto = val == null || switch (val.trim().toLowerCase(Locale.ROOT)) { - case "light", "dark" -> false; - default -> true; - }; - } else { - auto = effectiveBrightnessSupplier.get() == null; - } - autoBrightness.set(auto); - }; - settings().selectedThemeProperty().addListener(autoBrightnessListener); - settings().getThemeAppearanceOverrides().addListener(autoBrightnessListener); - settings().themeBrightnessModeProperty().addListener(autoBrightnessListener); - settings().backgroundTypeProperty().addListener(autoBrightnessListener); - if (FXUtils.DARK_MODE != null) { - FXUtils.DARK_MODE.addListener(autoBrightnessListener); - } - autoBrightnessListener.invalidated(null); - - ChangeListener listener = (observable, oldValue, newValue) -> { - if (!Objects.equals(oldValue, newValue)) { - colorScheme.set(newValue.toColorScheme()); - } - }; - colorScheme.set(theme.get().toColorScheme()); - theme.addListener(listener); - - InvalidationListener backgroundListener = ignored -> { - if (backgroundUpdatesStarted && !suppressBackgroundRefresh) { - refreshBackground(); - } - }; - settings().selectedThemeProperty().addListener(backgroundListener); - settings().getThemeAppearanceOverrides().addListener((SetChangeListener) change -> { - String setting = change.wasAdded() ? change.getElementAdded() : change.getElementRemoved(); - switch (setting) { - case LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE, - LauncherSettings.THEME_APPEARANCE_BACKGROUND -> - backgroundListener.invalidated(settings().getThemeAppearanceOverrides()); - case LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY -> refreshBackgroundOpacity(); - default -> { - } - } - }); - settings().backgroundTypeProperty().addListener(backgroundListener); - settings().builtinBackgroundIdProperty().addListener(backgroundListener); - settings().customBackgroundImagePathProperty().addListener(backgroundListener); - settings().networkBackgroundImageUrlProperty().addListener(backgroundListener); - settings().networkBackgroundImageCachePolicyProperty().addListener(backgroundListener); - settings().customBackgroundPaintProperty().addListener(backgroundListener); - settings().backgroundOpacityProperty().addListener(ignored -> refreshBackgroundOpacity()); - settings().backgroundFallbackTypeProperty().addListener(ignored -> refreshFallbackBackground()); - settings().backgroundFallbackPaintProperty().addListener(ignored -> refreshFallbackBackground()); - settings().themeBrightnessModeProperty().addListener(backgroundListener); - if (FXUtils.DARK_MODE != null) { - FXUtils.DARK_MODE.addListener(backgroundListener); - } - colorSchemeProperty().addListener(ignored -> refreshThemeColorBackground()); + colorScheme.set(ColorScheme.newBuilder() + .setPrimaryColorSeed(SEED) + .setColorStyle(ColorStyle.FIDELITY) + .setBrightness(Brightness.LIGHT) + .setSpecVersion(ColorSpecVersion.SPEC_2025) + .setContrast(Contrast.DEFAULT) + .build()); } - /// Cached system default brightness. - private static @Nullable Brightness defaultBrightness; - - /// Returns the brightness used for resolving theme conditions without reading the selected theme's brightness value. - /// - /// @return the brightness used by theme condition matching - public static Brightness getThemeConditionBrightness() { - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - return getAutomaticBrightness(); - } - - String themeBrightnessMode = settings().themeBrightnessModeProperty().get(); - return switch (Objects.toString(themeBrightnessMode, "").trim().toLowerCase(Locale.ROOT)) { - case "light" -> Brightness.LIGHT; - case "dark" -> Brightness.DARK; - default -> getAutomaticBrightness(); - }; - } - - /// Returns the effective brightness from launcher, theme, and system settings. - /// - /// @return the effective launcher brightness - public static Brightness getCurrentBrightness() { - Brightness contextBrightness = getThemeConditionBrightness(); - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeBrightness(ThemeResolveContext.current(contextBrightness)), - contextBrightness); - } catch (IOException | RuntimeException ignored) { - } - } - return contextBrightness; - } - - /// Returns the brightness requested by the current system or platform settings. - private static Brightness getAutomaticBrightness() { - if (FXUtils.DARK_MODE != null) { - return FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT; - } - return getDefaultBrightness(); - } - - /// Detects and returns the system default brightness. - private static Brightness getDefaultBrightness() { - if (defaultBrightness != null) - return defaultBrightness; - - LOG.info("Detecting system theme brightness"); - Brightness brightness = Brightness.DEFAULT; - if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { - WinReg reg = WinReg.INSTANCE; - if (reg != null) { - Object appsUseLightTheme = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme"); - if (appsUseLightTheme instanceof Integer value) { - brightness = value == 0 ? Brightness.DARK : Brightness.LIGHT; - } - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - try { - String result = SystemUtils.run("/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle").trim(); - brightness = "Dark".equalsIgnoreCase(result) ? Brightness.DARK : Brightness.LIGHT; - } catch (Exception e) { - // If the key does not exist, it means Light mode is used - brightness = Brightness.LIGHT; - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) { - Path dbusSend = SystemUtils.which("dbus-send"); - if (dbusSend != null) { - try { - String[] result = SystemUtils.run(List.of( - FileUtils.getAbsolutePath(dbusSend), - "--session", - "--print-reply=literal", - "--reply-timeout=1000", - "--dest=org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings.Read", - "string:org.freedesktop.appearance", - "string:color-scheme" - ), Duration.ofSeconds(2)).trim().split(" "); - - if (result.length > 0) { - String value = result[result.length - 1]; - // 1: prefer dark - // 2: prefer light - if ("1".equals(value)) { - brightness = Brightness.DARK; - } else if ("2".equals(value)) { - brightness = Brightness.LIGHT; - } - } - } catch (Exception e) { - LOG.warning("Failed to get system theme from D-Bus", e); - } - } - } - LOG.info("Detected system theme brightness: " + brightness); - - return defaultBrightness = brightness; - } - - /// Returns the current operating system accent color, or the launcher default when unavailable. - public static ThemeColor getSystemThemeColor() { - if (FXUtils.ACCENT_COLOR == null) { - return ThemeColor.DEFAULT; - } - @Nullable Color accentColor = FXUtils.ACCENT_COLOR.get(); - return accentColor != null ? ThemeColor.of(accentColor) : ThemeColor.DEFAULT; - } - - /// Returns the current resolved launcher theme. - public static ResolvedTheme getTheme() { - return theme.get(); - } - - /// Returns the current MonetFX color scheme property. + /// Returns the color scheme property. public static ReadOnlyColorSchemeProperty colorSchemeProperty() { return colorScheme; } - /// Returns the current MonetFX color scheme. + /// Returns the color scheme. public static ColorScheme getColorScheme() { return colorScheme.get(); } - /// Returns the current JavaFX launcher background and node opacity property. - public static ReadOnlyObjectProperty backgroundProperty() { - startBackgroundUpdates(); - return background.getReadOnlyProperty(); - } - - /// Returns whether applying a theme should wait for its background before changing settings. - public static boolean shouldWaitForThemeBackground() { - return backgroundUpdatesStarted && getBackgroundLoadPolicy() == BackgroundLoadPolicy.WAIT_FOR_BACKGROUND; - } - - /// Creates a task that applies one theme and waits for its background when requested. - /// - /// @param themePack the installed theme pack - /// @param theme the selected theme - /// @return the task applying the theme - public static Task applyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - if (!backgroundUpdatesStarted || getBackgroundLoadPolicy() != BackgroundLoadPolicy.WAIT_FOR_BACKGROUND) { - return Task.runAsync(Schedulers.javafx(), () -> ThemePackManager.apply(themePack, theme)); - } - - ThemeResolveContext context = ThemeResolveContext.current(getAutomaticBrightness()); - return Task.supplyAsync(Schedulers.io(), () -> loadBackground(themePack, theme, context)) - .thenAcceptAsync(Schedulers.javafx(), loadedBackground -> { - suppressBackgroundRefresh = true; - try { - ThemePackManager.apply(themePack, theme); - applyLoadedBackground(loadedBackground, ++backgroundLoadGeneration); - } finally { - suppressBackgroundRefresh = false; - } - }); - } - - /// Starts JavaFX launcher background loading when the UI first requests it. - private static void startBackgroundUpdates() { - if (!backgroundUpdatesStarted) { - backgroundUpdatesStarted = true; - if (getBackgroundLoadPolicy() == BackgroundLoadPolicy.WAIT_FOR_BACKGROUND) { - loadInitialBackground(); - } else { - refreshBackground(); - } - } - } - - /// Returns the configured launcher background loading policy. - private static BackgroundLoadPolicy getBackgroundLoadPolicy() { - try { - return ThemePackManager.resolveCurrentBackgroundLoadPolicy(); - } catch (RuntimeException e) { - return BackgroundLoadPolicy.WAIT_FOR_BACKGROUND; - } - } - - /// Loads the initial JavaFX launcher background synchronously before the UI uses it. - private static void loadInitialBackground() { - final int currentGeneration = ++backgroundLoadGeneration; - applyLoadedBackground(loadBackground(), currentGeneration); - } - - /// Refreshes the current JavaFX launcher background asynchronously. - public static void refreshBackground() { - final int currentGeneration = ++backgroundLoadGeneration; - if (getBackgroundLoadPolicy() == BackgroundLoadPolicy.SHOW_FALLBACK_WHILE_LOADING) { - applyLoadedBackground(loadFallbackBackground(), currentGeneration); - } - Task.supplyAsync(Schedulers.io(), Themes::loadBackground) - .setName("Update background") - .whenComplete(Schedulers.javafx(), (newLoadedBackground, exception) -> { - if (exception == null) { - applyLoadedBackground(newLoadedBackground, currentGeneration); - } else { - LOG.warning("Failed to update background", exception); - } - }).start(); - } - - /// Applies one loaded background when it is still the newest requested update. - private static void applyLoadedBackground(LoadedBackground newLoadedBackground, int generation) { - if (backgroundLoadGeneration != generation) { - return; - } - - loadedBackground = newLoadedBackground; - background.set(newLoadedBackground.background()); - wallpaperThemeColor.set(newLoadedBackground.wallpaperThemeColor()); - } - - /// Loads the current JavaFX launcher background. - private static LoadedBackground loadBackground() { - @Nullable LoadedBackground loaded = tryLoadBackground(); - return loaded != null ? loaded : loadFallbackBackground(); - } - - /// Loads the background that will become effective after applying the given theme. - private static LoadedBackground loadBackground( - ThemePackManager.InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) { - try { - ThemePackManager.ResolvedBackground resolved = ThemePackManager.resolveBackgroundAfterApplyingTheme( - themePack, - theme, - context); - @Nullable LoadedBackground loaded = tryCreateResolvedBackground(resolved, false); - if (loaded != null) { - return loaded; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't load theme background before applying theme", e); - } - return loadFallbackBackground(theme, context); - } - - /// Attempts to load the configured JavaFX launcher background. - private static @Nullable LoadedBackground tryLoadBackground() { - try { - return tryCreateResolvedBackground( - ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't resolve background", e); - return null; - } - } - - /// Creates a JavaFX launcher background from a resolved theme-pack background. - private static @Nullable LoadedBackground tryCreateResolvedBackground( - ThemePackManager.ResolvedBackground resolvedBackground, - boolean fallbackBackground) { - @Nullable Image image = null; - @Nullable ThemeColor imageThemeColor = null; - switch (resolvedBackground.type()) { - case CUSTOM: - @Nullable ThemePackResource imageResource = resolvedBackground.imageResource(); - if (imageResource != null) { - try { - image = FXUtils.loadImage(imageResource.openStream(), imageResource.name()); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } else if (resolvedBackground.imagePath() != null) { - Path imagePath = resolvedBackground.imagePath(); - try { - image = Files.isDirectory(imagePath) - ? randomImageIn(imagePath) - : tryLoadImage(imagePath); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } - break; - case NETWORK: - @Nullable String networkBackgroundImageUrl = resolvedBackground.networkImageUrl(); - if (networkBackgroundImageUrl != null) { - try { - image = loadNetworkBackgroundImage( - networkBackgroundImageUrl, - resolvedBackground.networkImageCachePolicy()); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } - break; - case BUILTIN: - BuiltinBackground builtinBackground = - BuiltinBackground.fromIdOrFallback(resolvedBackground.builtinBackgroundId()); - image = loadBuiltinBackgroundImage(builtinBackground.id()); - imageThemeColor = builtinBackground.themeColor(); - break; - case PAINT: - @Nullable Paint paint = resolvedBackground.paint(); - return new LoadedBackground.Paint( - createPaintBackground(paint, resolvedBackground.opacity()), - paint, - paint instanceof Color color ? ThemeColor.of(color) : null, - fallbackBackground); - case THEME_COLOR: - return new LoadedBackground.ThemeColorFill( - createPaintBackground(resolvedBackground.paint(), resolvedBackground.opacity()), - fallbackBackground); - case DEFAULT: - image = loadLocalDefaultBackgroundImage(); - if (image == null) { - image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - imageThemeColor = BuiltinBackground.FALLBACK.themeColor(); - } - break; - } - if (image == null) { - return null; - } - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, resolvedBackground.opacity()), - image, - imageThemeColor != null ? imageThemeColor : extractWallpaperThemeColor(image), - fallbackBackground); - } - - /// Loads the deterministic fallback background configured by launcher settings. - private static LoadedBackground loadFallbackBackground() { - try { - @Nullable LoadedBackground loaded = tryCreateResolvedBackground( - ThemePackManager.resolveCurrentBackgroundFallback(ThemePackManager.currentResolveContext()), - true); - if (loaded != null) { - return loaded; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't resolve fallback background", e); - } - - Image image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, getLoadedBackgroundOpacity(true)), - image, - BuiltinBackground.FALLBACK.themeColor(), - true); - } - - /// Loads the fallback background used while applying a not-yet-selected theme. - private static LoadedBackground loadFallbackBackground(Theme theme, ThemeResolveContext context) { - double opacity = 1.0; - ThemeAppearance appearance = theme.resolve(context); - @Nullable ThemeBackgroundSettings background = appearance.background(); - if (background != null && background.opacity() != null) { - opacity = background.opacity(); - } - - Paint themeColorPaint = appearance.toResolvedTheme(context).toColorScheme().getColor(ColorRole.SURFACE_CONTAINER); - BackgroundType fallbackType = Objects.requireNonNullElse( - settings().backgroundFallbackTypeProperty().get(), - BackgroundType.BUILTIN); - ThemePackManager.ResolvedBackground resolvedBackground = switch (fallbackType) { - case PAINT -> new ThemePackManager.ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().backgroundFallbackPaintProperty().get(), - opacity); - case THEME_COLOR -> new ThemePackManager.ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - themeColorPaint, - opacity); - case DEFAULT, BUILTIN, CUSTOM, NETWORK -> new ThemePackManager.ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - }; - @Nullable LoadedBackground loaded = tryCreateResolvedBackground(resolvedBackground, true); - if (loaded != null) { - return loaded; - } - - Image image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, opacity), - image, - BuiltinBackground.FALLBACK.themeColor(), - true); - } - - /// Updates the background paint when the background explicitly follows the current color scheme. - private static void refreshThemeColorBackground() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) { - LauncherBackground newBackground = - createThemeColorBackground(getLoadedBackgroundOpacity(loaded.fallbackBackground())); - loadedBackground = new LoadedBackground.ThemeColorFill(newBackground, themeColorFill.fallbackBackground()); - background.set(newBackground); - } - } - - /// Rebuilds the current fallback background after fallback settings change. - private static void refreshFallbackBackground() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded != null && loaded.fallbackBackground()) { - LoadedBackground fallback = loadFallbackBackground(); - loadedBackground = fallback; - background.set(fallback.background()); - wallpaperThemeColor.set(fallback.wallpaperThemeColor()); - } - } - - /// Rebuilds the current JavaFX background after opacity changes without reloading its source. - private static void refreshBackgroundOpacity() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded == null) { - return; - } - - double opacity = getLoadedBackgroundOpacity(loaded.fallbackBackground()); - LoadedBackground refreshed; - if (loaded instanceof LoadedBackground.Image imageBackground) { - refreshed = new LoadedBackground.Image( - new LauncherBackground( - imageBackground.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - imageBackground.image(), - imageBackground.wallpaperThemeColor(), - imageBackground.fallbackBackground()); - } else if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) { - refreshed = new LoadedBackground.ThemeColorFill( - new LauncherBackground( - themeColorFill.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - themeColorFill.fallbackBackground()); - } else if (loaded instanceof LoadedBackground.Paint paintBackground) { - refreshed = new LoadedBackground.Paint( - new LauncherBackground( - paintBackground.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - paintBackground.paint(), - paintBackground.wallpaperThemeColor(), - paintBackground.fallbackBackground()); - } else { - return; - } - - loadedBackground = refreshed; - background.set(refreshed.background()); - } - - /// Resolves the opacity for the currently loaded primary or fallback background. - private static double getLoadedBackgroundOpacity(boolean fallbackBackground) { - try { - return fallbackBackground - ? ThemePackManager.resolveCurrentBackgroundFallback(ThemePackManager.currentResolveContext()).opacity() - : ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()).opacity(); - } catch (IOException | RuntimeException e) { - double configured = settings().backgroundOpacityProperty().get(); - return settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY) && Double.isFinite(configured) - ? MathUtils.clamp(configured, 0., 1.) - : 1.0; - } - } - - /// Extracts a seed color from a loaded wallpaper image. - private static @Nullable ThemeColor extractWallpaperThemeColor(Image image) { - try { - ThemeColor fallback = !settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? ThemeColor.DEFAULT - : Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT); - return WallpaperColorExtractor.extract(image, fallback); - } catch (RuntimeException e) { - LOG.warning("Couldn't extract theme color from background image", e); - return null; - } - } - - /// Loads one remote background image using the requested cache policy. - private static Image loadNetworkBackgroundImage( - String url, - @Nullable NetworkBackgroundImageCachePolicy cachePolicy) throws Exception { - if (cachePolicy == NetworkBackgroundImageCachePolicy.DISABLED) { - return FXUtils.loadImage(WebURL.parseBrowserInput(url)); - } - return FXUtils.loadImage(new CacheFileTask(url).run()); - } - - /// Creates a JavaFX paint background with a separately stored node opacity. - private static LauncherBackground createPaintBackground(@Nullable Paint paint, double opacity) { - return new LauncherBackground( - new Background(new BackgroundFill( - Objects.requireNonNullElse(paint, Color.WHITE), - CornerRadii.EMPTY, - Insets.EMPTY)), - MathUtils.clamp(opacity, 0., 1.)); - } - - /// Creates a JavaFX background from the current theme color scheme surface container. - private static LauncherBackground createThemeColorBackground(double opacity) { - return createPaintBackground(getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), opacity); - } - - /// Creates a JavaFX image background with a separately stored node opacity. - private static LauncherBackground createBackgroundWithOpacity(Image image, double opacity) { - Background imageBackground = new Background(new BackgroundImage( - image, - BackgroundRepeat.NO_REPEAT, - BackgroundRepeat.NO_REPEAT, - BackgroundPosition.DEFAULT, - new BackgroundSize( - BackgroundSize.AUTO, - BackgroundSize.AUTO, - false, - false, - false, - true) - )); - return new LauncherBackground(imageBackground, MathUtils.clamp(opacity, 0., 1.)); - } - - /// Loads a default launcher background image from local workspace files. - private static @Nullable Image loadLocalDefaultBackgroundImage() { - @Nullable Image image = randomImageIn(Metadata.HMCL_LOCAL_HOME.resolve("background")); - if (image != null) { - return image; - } - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - image = tryLoadImage(Metadata.HMCL_LOCAL_HOME.resolve("background." + extension)); - if (image != null) { - return image; - } - } - - image = randomImageIn(Metadata.CURRENT_DIRECTORY.resolve("bg")); - if (image != null) { - return image; - } - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - image = tryLoadImage(Metadata.CURRENT_DIRECTORY.resolve("background." + extension)); - if (image != null) { - return image; - } - } - - return null; - } - - /// Loads one built-in launcher wallpaper by ID. - private static Image loadBuiltinBackgroundImage(@Nullable String id) { - String wallpaperId = BuiltinBackground.fromIdOrFallback(id).id(); - return newBuiltinImage("/assets/img/wallpapers/" + wallpaperId + ".jpg"); - } - - /// Loads a random readable image from a directory. - private static @Nullable Image randomImageIn(Path imageDir) { - if (!Files.isDirectory(imageDir)) { - return null; - } - - ArrayList candidates; - try (Stream stream = Files.list(imageDir)) { - candidates = stream - .filter(it -> FXUtils.IMAGE_EXTENSIONS.contains(getExtension(it).toLowerCase(Locale.ROOT))) - .filter(Files::isReadable) - .collect(Collectors.toCollection(ArrayList::new)); - } catch (IOException e) { - LOG.warning("Failed to list files in " + imageDir, e); - return null; - } - - Random random = new Random(); - while (!candidates.isEmpty()) { - int selected = random.nextInt(candidates.size()); - @Nullable Image loaded = tryLoadImage(candidates.get(selected)); - if (loaded != null) { - return loaded; - } else { - candidates.remove(selected); - } - } - return null; - } - - /// Loads a readable image file. - private static @Nullable Image tryLoadImage(Path path) { - if (!Files.isReadable(path)) { - return null; - } - - try { - return FXUtils.loadImage(path); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - return null; - } - } - - /// Whether the title area should be transparent after applying launcher and theme settings. - private static final BooleanBinding titleBarTransparent = Bindings.createBooleanBinding( - () -> { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_TITLE_BAR_TRANSPARENT)) { - return settings().titleBarTransparentProperty().get(); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentTitleBarTransparent(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - return false; - } - }, - settings().titleBarTransparentProperty(), - settings().getThemeAppearanceOverrides(), - settings().selectedThemeProperty(), - settings().themeBrightnessModeProperty(), - FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty() - ); - - /// Whether the launcher window should be transparent after applying launcher and theme settings. - private static final BooleanBinding windowTransparent = Bindings.createBooleanBinding( - () -> { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT)) { - return settings().windowTransparentProperty().get(); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentWindowTransparent(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - return false; - } - }, - settings().windowTransparentProperty(), - settings().getThemeAppearanceOverrides(), - settings().selectedThemeProperty(), - settings().themeBrightnessModeProperty(), - FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty() - ); - - /// The title text fill derived from the current color scheme. - private static final ObjectBinding titleFill = Bindings.createObjectBinding( - () -> WarmPalette.INK, - colorSchemeProperty() - ); - - /// Returns the title text fill property derived from the current color scheme. + /// Returns the title text color, which never changes. public static ObservableValue titleFillProperty() { return titleFill; } - /// Returns whether the title area should be transparent after applying launcher and theme settings. - public static BooleanBinding titleBarTransparentProperty() { - return titleBarTransparent; - } - - /// Returns whether the launcher window should be transparent after applying launcher and theme settings. + /// Returns whether the window is transparent; it never is. public static BooleanBinding windowTransparentProperty() { - return windowTransparent; + return darkMode; } - /// Returns whether the current color scheme uses dark brightness. + /// Returns whether the current appearance is dark; it never is. public static BooleanBinding darkModeProperty() { return darkMode; } - /// Returns whether the current color scheme brightness is inherited from system. + /// Returns whether the brightness is inherited from the system; it never is. public static ReadOnlyBooleanProperty autoBrightnessProperty() { return autoBrightness.getReadOnlyProperty(); } - /// Registers native dark-mode integration once per stage where the platform supports it. + /// Kept for callers that prepare secondary windows; there is no dark mode to apply. /// - /// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains - /// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do - /// nothing, including after hiding the stage or replacing its scene. - /// - /// @param stage the stage retaining the registration, accessed on the JavaFX application thread + /// @param stage the stage that would receive the native appearance public static void applyNativeDarkMode(Stage stage) { - if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) { - return; - } - if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) { - ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> { - if (stage.isShowing()) { - WindowsNativeUtils.getWindowHandle(stage).ifPresent(handle -> { - if (handle == WinTypes.HANDLE.INVALID_VALUE) - return; - - Dwmapi.INSTANCE.DwmSetWindowAttribute( - new WinTypes.HANDLE(Pointer.createConstant(handle)), - WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE, - new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)), - WinTypes.BOOL.SIZE - ); - }); - } - }); - stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener); - - if (stage.isShowing()) { - listener.changed(null, false, Themes.darkModeProperty().get()); - } else { - stage.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() { - @Override - public void handle(WindowEvent event) { - stage.removeEventFilter(WindowEvent.WINDOW_SHOWN, this); - listener.changed(null, false, Themes.darkModeProperty().get()); - } - }); - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) { - MacOSNativeUtils.setAppearance(darkModeProperty().get()); - - ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance); - stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener); - } } /// Prevents instantiation. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java deleted file mode 100644 index 4d083b46d9..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java +++ /dev/null @@ -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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java index fb36167694..f84d4fde01 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java @@ -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)); diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java deleted file mode 100644 index 9e7f3c51a7..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java +++ /dev/null @@ -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 checks = Arrays.stream(BuiltinBackground.values()) - .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); - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java deleted file mode 100644 index 89d4a1dc30..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java +++ /dev/null @@ -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)); - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java deleted file mode 100644 index 2bc428e2f9..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java +++ /dev/null @@ -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(); - } - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java deleted file mode 100644 index ba48551920..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java +++ /dev/null @@ -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()); - } - -}