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 b0bb6a9a772..17c0caeceb9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -466,6 +466,16 @@ public static Brightness getCurrentBrightness() { /// Returns the brightness requested by the current system or platform settings. private static Brightness getAutomaticBrightness() { + // On macOS the launcher pins the application appearance (see #applyNativeDarkMode), and JavaFX derives its + // color scheme from NSApp.effectiveAppearance. Reading FXUtils.DARK_MODE here would therefore read back the + // brightness this launcher itself applied, so the system setting is read directly instead. + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) { + @Nullable Boolean systemDarkMode = MacOSNativeUtils.isSystemInDarkMode(); + if (systemDarkMode != null) { + return systemDarkMode ? Brightness.DARK : Brightness.LIGHT; + } + } + if (FXUtils.DARK_MODE != null) { return FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT; } @@ -1151,13 +1161,32 @@ public void handle(WindowEvent event) { }); } } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) { - MacOSNativeUtils.setAppearance(darkModeProperty().get()); + applyMacOSAppearance(darkModeProperty().get()); - ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance); + ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), Themes::applyMacOSAppearance); stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener); } } + /// Applies the launcher brightness to the macOS application appearance. + /// + /// Setting an explicit appearance is what makes native windows owned by the application - notably the file + /// chooser - follow the launcher brightness instead of the system one. It has a cost: while an appearance is + /// pinned, `NSApp.effectiveAppearance` no longer reports the system appearance, so JavaFX stops reporting + /// system color scheme changes. + /// + /// The appearance is therefore only pinned while the launcher brightness actually differs from the system + /// brightness. When they agree - which is the case whenever the brightness mode follows the system - the + /// appearance is cleared, so the system appearance is inherited and JavaFX keeps observing system changes. + private static void applyMacOSAppearance(boolean dark) { + @Nullable Boolean systemDarkMode = MacOSNativeUtils.isSystemInDarkMode(); + if (systemDarkMode != null && systemDarkMode == dark) { + MacOSNativeUtils.clearAppearance(); + } else { + MacOSNativeUtils.setAppearance(dark); + } + } + /// Prevents instantiation. private Themes() { } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MacOSNativeUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/MacOSNativeUtils.java index 9d83ce300fd..80b6d6f70a9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MacOSNativeUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/MacOSNativeUtils.java @@ -96,6 +96,63 @@ public static void setAppearance(boolean dark, boolean highContrast) { } } + /// Clears the explicit application appearance, so that the application inherits the system appearance again. + /// + /// While an explicit appearance is set, `NSApp.effectiveAppearance` reports that appearance rather than the + /// system one. JavaFX derives [javafx.application.Platform.Preferences#colorSchemeProperty()] from + /// `effectiveAppearance`, so leaving an appearance pinned makes the reported color scheme follow the launcher + /// instead of the system. + public static void clearAppearance() { + if (nsApp == null) return; + + try { + var objc = ObjectiveCRuntime.INSTANCE; + // A null Pointer maps to Objective-C nil, which resets the appearance to inherited. + objc.objc_msgSend(nsApp, objc.sel_registerName("setAppearance:"), (Pointer) null); + } catch (Throwable t) { + LOG.warning("Failed to clear macOS appearance", t); + } + } + + /// Reads whether the system is currently using dark mode, independently of the application appearance. + /// + /// This reads the `AppleInterfaceStyle` user default, which reflects the system setting and is unaffected by + /// [#setAppearance(boolean)]. It must not be derived from `effectiveAppearance` or from the JavaFX color + /// scheme, because both report the pinned application appearance once one has been set. + /// + /// @return whether the system is in dark mode, or `null` if it could not be determined + public static @Nullable Boolean isSystemInDarkMode() { + if (nsApp == null) return null; + + try { + var objc = ObjectiveCRuntime.INSTANCE; + + @Nullable Pointer userDefaults = objc.objc_getClass("NSUserDefaults"); + if (isNull(userDefaults)) return null; + + @Nullable Pointer defaults = objc.objc_msgSend(userDefaults, objc.sel_registerName("standardUserDefaults")); + if (isNull(defaults)) return null; + + @Nullable Pointer nsString = objc.objc_getClass("NSString"); + if (isNull(nsString)) return null; + + @Nullable Pointer key = objc.objc_msgSend(nsString, objc.sel_registerName("stringWithUTF8String:"), "AppleInterfaceStyle"); + if (isNull(key)) return null; + + @Nullable Pointer value = objc.objc_msgSend(defaults, objc.sel_registerName("stringForKey:"), key); + // The key is absent in light mode. + if (isNull(value)) return Boolean.FALSE; + + @Nullable Pointer utf8 = objc.objc_msgSend(value, objc.sel_registerName("UTF8String")); + if (isNull(utf8)) return null; + + return "Dark".equalsIgnoreCase(utf8.getString(0)); + } catch (Throwable t) { + LOG.warning("Failed to read macOS system appearance", t); + return null; + } + } + private MacOSNativeUtils() { } }