Skip to content

Commit 8503384

Browse files
authored
Add Windows 11 Mica backdrop effect (#84)
Makes the installer look more native on Windows 11 by enabling the subtle Mica backdrop effect as seen in some of Windows' built-in apps, as well as blending the titlebar background with the content background. For the Mica backdrop to apply, the installer needs to be run with Java 22 or newer on Windows 11 with transparency effects enabled in the Windows settings app. The installer continues to work on older Java, older Windows versions and other OSes. Users can run the installer with `-Dforgeinstaller.usewin11mica=false` if they run into issues with the fallback mechanism. An experimental `-Dforgeinstaller.backdroptype` is also offered - set it to 3 for Acrylic or 4 for Mica Alt.
1 parent 3d636c4 commit 8503384

10 files changed

Lines changed: 557 additions & 5 deletions

File tree

build.gradle

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ plugins {
1212
id 'net.minecraftforge.licenser' version '1.2.0'
1313
id 'net.minecraftforge.gradleutils' version '3.4.4'
1414
id 'net.minecraftforge.changelog' version '3.2.1'
15+
id 'net.minecraftforge.multi-release' version '0.1.4'
1516
}
1617

1718
gradleutils.displayName = 'Installer'
@@ -57,7 +58,10 @@ Files.list(Paths.get(projectDir.absolutePath))
5758

5859
tasks.named('jar', Jar) {
5960
manifest {
60-
attributes('Main-Class': 'net.minecraftforge.installer.SimpleInstaller')
61+
attributes([
62+
'Main-Class': 'net.minecraftforge.installer.SimpleInstaller',
63+
'Enable-Native-Access': 'ALL-UNNAMED'
64+
])
6165
attributes([
6266
'Specification-Title': 'Installer',
6367
'Specification-Vendor': 'Forge Development LLC',
@@ -67,9 +71,26 @@ tasks.named('jar', Jar) {
6771
'Implementation-Version': project.version
6872
] as LinkedHashMap, 'net/minecraftforge/installer/')
6973
}
74+
75+
archiveClassifier = 'java8'
76+
}
77+
78+
multiRelease.register {
79+
add(JavaLanguageVersion.of(22), project(':installer-java22'))
80+
}
81+
82+
tasks.named('multiReleaseJar', Jar) {
83+
archiveClassifier = ''
7084
}
7185

7286
tasks.named('shadowJar', ShadowJar) {
87+
dependsOn tasks.named('multiReleaseJar')
88+
89+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
90+
from({
91+
zipTree(tasks.named('multiReleaseJar').get().archiveFile.get().asFile)
92+
})
93+
7394
archiveClassifier = 'fatjar'
7495
minimize()
7596
}

installer-java22/build.gradle

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
plugins {
2+
id 'java'
3+
id 'eclipse'
4+
id 'net.minecraftforge.licenser' version '1.2.0'
5+
id 'net.minecraftforge.gradleutils' version '3.4.4'
6+
}
7+
8+
java.toolchain.languageVersion = JavaLanguageVersion.of(22)
9+
10+
license {
11+
header = rootProject.file('LICENSE-header.txt')
12+
newLine = false
13+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* Copyright (c) Forge Development LLC
3+
* SPDX-License-Identifier: LGPL-2.1-only
4+
*/
5+
package net.minecraftforge.installer;
6+
7+
import java.awt.Color;
8+
import java.awt.Component;
9+
import java.awt.Container;
10+
import java.awt.Dialog;
11+
import java.awt.event.WindowAdapter;
12+
import java.awt.event.WindowEvent;
13+
import java.lang.foreign.Arena;
14+
import java.lang.foreign.FunctionDescriptor;
15+
import java.lang.foreign.Linker;
16+
import java.lang.foreign.MemorySegment;
17+
import java.lang.foreign.SymbolLookup;
18+
import java.lang.foreign.ValueLayout;
19+
import java.lang.invoke.MethodHandle;
20+
import java.util.Locale;
21+
import java.util.concurrent.Callable;
22+
23+
import javax.swing.JComponent;
24+
import javax.swing.JDialog;
25+
import javax.swing.JOptionPane;
26+
import javax.swing.JPanel;
27+
import javax.swing.JRadioButton;
28+
29+
final class Win11MicaEffect {
30+
private static final Color TRANSPARENT = new Color(220, 220, 220, 0);
31+
private static final Color WINDOW_BACKGROUND = new Color(220, 220, 220, 1);
32+
33+
private Win11MicaEffect() {}
34+
35+
/// Swing paints an opaque background by default for all components, which hides the Mica backdrop effect.
36+
/// This method recursively undoes that so that DWM is responsible for painting the window background.
37+
static void prepare(Component component) {
38+
if (isUnsupportedPlatform())
39+
return;
40+
41+
if (component instanceof JPanel || component instanceof JOptionPane || component instanceof JRadioButton) {
42+
JComponent jComponent = (JComponent) component;
43+
jComponent.setOpaque(false);
44+
jComponent.setBackground(TRANSPARENT);
45+
}
46+
47+
if (component instanceof Container container) {
48+
for (Component child : container.getComponents()) {
49+
prepare(child);
50+
}
51+
}
52+
}
53+
54+
static void install(JDialog dialog) throws Exception {
55+
if (isUnsupportedPlatform())
56+
return;
57+
58+
dialog.getRootPane().setOpaque(false);
59+
dialog.getLayeredPane().setOpaque(false);
60+
if (dialog.getContentPane() instanceof JComponent contentPane) {
61+
contentPane.setOpaque(false);
62+
contentPane.setBackground(TRANSPARENT);
63+
}
64+
prepare(dialog.getRootPane());
65+
66+
dialog.setBackground(WINDOW_BACKGROUND);
67+
68+
// In order for the Mica effect to apply correctly on a Swing window, we need to ensure a repaint after applying
69+
// for timing reasons. If the window is already showing, we can repaint immediately, otherwise wait for the
70+
// window to be opened first.
71+
Callable<Void> apply = () -> {
72+
try {
73+
applyTo(dialog);
74+
} catch (Throwable t) {
75+
if (t instanceof Exception e) throw e;
76+
else throw new RuntimeException(t);
77+
}
78+
dialog.invalidate();
79+
dialog.validate();
80+
dialog.repaint();
81+
dialog.getRootPane().repaint();
82+
return null;
83+
};
84+
85+
if (dialog.isShowing()) {
86+
apply.call();
87+
return;
88+
}
89+
90+
dialog.addWindowListener(new WindowAdapter() {
91+
@Override
92+
public void windowOpened(WindowEvent e) {
93+
dialog.removeWindowListener(this);
94+
try {
95+
apply.call();
96+
} catch (Exception ex) {
97+
throw new RuntimeException(ex);
98+
}
99+
}
100+
});
101+
}
102+
103+
private static void applyTo(Dialog dialog) throws Throwable {
104+
if (!dialog.isDisplayable())
105+
return;
106+
107+
var linker = Linker.nativeLinker();
108+
try (Arena arena = Arena.ofConfined()) {
109+
// Lookup the necessary Windows APIs
110+
SymbolLookup user32 = SymbolLookup.libraryLookup("user32", arena);
111+
SymbolLookup dwmapi = SymbolLookup.libraryLookup("dwmapi", arena);
112+
113+
MemorySegment hwnd = findDialogWindow(dialog, arena, user32);
114+
if (MemorySegment.NULL.equals(hwnd))
115+
return;
116+
117+
MethodHandle setWindowPos = linker.downcallHandle(
118+
user32.find("SetWindowPos").orElseThrow(),
119+
FunctionDescriptor.of(
120+
ValueLayout.JAVA_INT,
121+
ValueLayout.ADDRESS,
122+
ValueLayout.ADDRESS,
123+
ValueLayout.JAVA_INT,
124+
ValueLayout.JAVA_INT,
125+
ValueLayout.JAVA_INT,
126+
ValueLayout.JAVA_INT,
127+
ValueLayout.JAVA_INT
128+
)
129+
);
130+
MethodHandle dwmSetWindowAttribute = linker.downcallHandle(
131+
dwmapi.find("DwmSetWindowAttribute").orElseThrow(),
132+
FunctionDescriptor.of(
133+
ValueLayout.JAVA_INT,
134+
ValueLayout.ADDRESS,
135+
ValueLayout.JAVA_INT,
136+
ValueLayout.ADDRESS,
137+
ValueLayout.JAVA_INT
138+
)
139+
);
140+
MethodHandle dwmExtendFrameIntoClientArea = linker.downcallHandle(
141+
dwmapi.find("DwmExtendFrameIntoClientArea").orElseThrow(),
142+
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)
143+
);
144+
145+
promoteToTaskbarWindow(hwnd, user32, setWindowPos);
146+
147+
// Win11 defaults to a rounded corner preference for decorated windows, but for backwards-compatibility it
148+
// defaults to sharp corners for undecorated windows. This tells Win11 that we want rounded corners for our
149+
// undecorated dialog window
150+
MemorySegment cornerPreference = arena.allocate(ValueLayout.JAVA_INT);
151+
cornerPreference.set(ValueLayout.JAVA_INT, 0, 2); // DWMWCP_ROUND
152+
int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
153+
int _ = (int) dwmSetWindowAttribute.invokeExact(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, cornerPreference, Integer.BYTES);
154+
155+
// Tell DWM that we'd like to opt-into the main window backdrop type rather than the backwards-compatible
156+
// default of no backdrop effect. This is the main bit that actually enables the Mica backdrop effect - the
157+
// rest of the code is mostly workarounds for Swing limitations
158+
MemorySegment backdropType = arena.allocate(ValueLayout.JAVA_INT);
159+
// 2 = Mica (DWMSBT_MAINWINDOW), 3 = Acrylic (DWMSBT_TRANSIENTWINDOW), 4 = Mica Alt (DWMSBT_TABBEDWINDOW)
160+
int backdropTypePreference = Integer.getInteger("forgeinstaller.backdroptype", 2);
161+
backdropType.set(ValueLayout.JAVA_INT, 0, backdropTypePreference);
162+
int DWMWA_SYSTEMBACKDROP_TYPE = 38;
163+
int hresult = (int) dwmSetWindowAttribute.invokeExact(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, backdropType, Integer.BYTES);
164+
if (hresult != 0)
165+
throw new RuntimeException("DwmSetWindowAttribute failed with HRESULT 0x" + Integer.toHexString(hresult));
166+
167+
// Tell DWM to paint the entire window background with the Mica brush rather than the default of only the
168+
// title bar.
169+
MemorySegment margins = arena.allocate(4L * Integer.BYTES, Integer.BYTES);
170+
margins.set(ValueLayout.JAVA_INT, 0L, -1);
171+
margins.set(ValueLayout.JAVA_INT, Integer.BYTES, -1);
172+
margins.set(ValueLayout.JAVA_INT, 2L * Integer.BYTES, -1);
173+
margins.set(ValueLayout.JAVA_INT, 3L * Integer.BYTES, -1);
174+
int _ = (int) dwmExtendFrameIntoClientArea.invokeExact(hwnd, margins);
175+
176+
// Comply with the remarks in Windows' API docs to ensure that the style changes apply
177+
refreshWindowFrame(hwnd, setWindowPos);
178+
}
179+
}
180+
181+
/// Attempt to find the window handle (HWND) of the dialog window by querying for a window with its title first,
182+
/// falling back to the foreground window if no window with this dialog's window title is found.
183+
private static MemorySegment findDialogWindow(Dialog dialog, Arena arena, SymbolLookup user32) throws Throwable {
184+
var linker = Linker.nativeLinker();
185+
MethodHandle findWindowW = linker.downcallHandle(
186+
user32.find("FindWindowW").orElseThrow(),
187+
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS)
188+
);
189+
190+
String title = dialog.getTitle();
191+
if (title != null && !title.isBlank()) {
192+
MemorySegment windowTitle = arena.allocateFrom(ValueLayout.JAVA_CHAR, (title + '\0').toCharArray());
193+
MemorySegment hwnd = (MemorySegment) findWindowW.invokeExact(MemorySegment.NULL, windowTitle);
194+
if (!MemorySegment.NULL.equals(hwnd))
195+
return hwnd;
196+
}
197+
198+
MethodHandle getForegroundWindow = linker.downcallHandle(
199+
user32.find("GetForegroundWindow").orElseThrow(),
200+
FunctionDescriptor.of(ValueLayout.ADDRESS)
201+
);
202+
203+
return (MemorySegment) getForegroundWindow.invokeExact();
204+
}
205+
206+
/// Ensures that the dialog box is visible in the taskbar by removing the tool window style and adding the app
207+
/// window style. This is a workaround for Swing hiding the dialog box from the taskbar when undecorated and
208+
/// non-opaque due to it expecting that the window is invisible, however in practice it is visible as DWM will draw
209+
/// the window background for us which is what we want for the Mica effect to be visible.
210+
/// @see [#prepare(Component)
211+
private static void promoteToTaskbarWindow(MemorySegment hwnd, SymbolLookup user32, MethodHandle setWindowPos) throws Throwable {
212+
var linker = Linker.nativeLinker();
213+
MethodHandle getWindowLongW = linker.downcallHandle(
214+
user32.find("GetWindowLongW").orElseThrow(),
215+
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT)
216+
);
217+
MethodHandle setWindowLongW = linker.downcallHandle(
218+
user32.find("SetWindowLongW").orElseThrow(),
219+
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT)
220+
);
221+
222+
int WS_EX_TOOLWINDOW = 0x00000080;
223+
int WS_EX_APPWINDOW = 0x00040000;
224+
int GWL_EXSTYLE = -20;
225+
int exStyle = (int) getWindowLongW.invokeExact(hwnd, GWL_EXSTYLE);
226+
int newExStyle = (exStyle | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW;
227+
if (newExStyle == exStyle)
228+
return;
229+
230+
int _ = (int) setWindowLongW.invokeExact(hwnd, GWL_EXSTYLE, newExStyle);
231+
refreshWindowFrame(hwnd, setWindowPos);
232+
}
233+
234+
/// The [docs](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos#remarks) mention
235+
/// that we should call SetWindowPos with the SWP_FRAMECHANGED flag after calling SetWindowLong to ensure that
236+
/// style updates apply on Windows Vista onwards. This is done after the DWM calls just in case those count too.
237+
/// @see [#promoteToTaskbarWindow(MemorySegment, SymbolLookup, MethodHandle)
238+
private static void refreshWindowFrame(MemorySegment hwnd, MethodHandle setWindowPos) throws Throwable {
239+
// SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED;
240+
int flags = 0x0001 | 0x0002 | 0x0004 | 0x0010 | 0x0020;
241+
int _ = (int) setWindowPos.invokeExact(hwnd, MemorySegment.NULL, 0, 0, 0, 0, flags);
242+
}
243+
244+
/// @return true if the current platform is Windows 11
245+
static boolean isSupported() {
246+
return !isUnsupportedPlatform();
247+
}
248+
249+
private static boolean isUnsupportedPlatform() {
250+
final class LazyInit {
251+
private LazyInit() {}
252+
private static final boolean IS_UNSUPPORTED;
253+
static {
254+
var useMica = true;
255+
boolean forceDisableMica = false;
256+
var isWin11 = System.getProperty("os.name", "").toLowerCase(Locale.ENGLISH).startsWith("windows 11");
257+
if (isWin11) {
258+
// Disable Mica if explicitly requested
259+
forceDisableMica = !Boolean.parseBoolean(System.getProperty("forgeinstaller.usewin11mica", "true"));
260+
if (forceDisableMica)
261+
useMica = false;
262+
} else {
263+
// Mica is only supported on Windows 11
264+
useMica = false;
265+
}
266+
267+
// System.out.println("OS: " + System.getProperty("os.name", "") + ", isWin11: " + isWin11 + ", forceDisableMica: " + forceDisableMica);
268+
IS_UNSUPPORTED = !useMica;
269+
}
270+
}
271+
return LazyInit.IS_UNSUPPORTED;
272+
}
273+
}

settings.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ dependencyResolutionManagement {
2828
}
2929

3030
rootProject.name = 'Installer'
31+
32+
include 'installer-java22'

src/main/java/net/minecraftforge/installer/DownloadUtils.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public static boolean downloadLibrary(ProgressCallback monitor, Mirror mirror, L
4848
download.setPath(artifact.getPath());
4949
}
5050

51-
monitor.message(String.format("Considering library %s", artifact.getDescriptor()));
51+
monitor.message("Considering library " + artifact.getDescriptor());
5252

5353
if (target.exists()) {
5454
if (download.getSha1() != null) {
@@ -279,7 +279,7 @@ public static List<String> getIps(String host) {
279279
}
280280

281281
public static String getIpString(String host) {
282-
return getIps(host).stream().collect(Collectors.joining(", "));
282+
return String.join(", ", getIps(host));
283283
}
284284

285285
public static boolean checkCertificate(String host) {

src/main/java/net/minecraftforge/installer/InstallerPanel.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,15 @@ private void updateFilePath() {
255255
public void run(ProgressCallback monitor) {
256256
JOptionPane optionPane = new JOptionPane(this, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION);
257257

258-
dialog = optionPane.createDialog("Forge Installer");
258+
try {
259+
dialog = Win11MicaEffect.isSupported()
260+
? Win11Dialog.create(optionPane, this, "Forge Installer")
261+
: optionPane.createDialog("Forge Installer");
262+
} catch (Exception e) {
263+
System.out.println("Failed to setup Win11 dialog, falling back to Swing default:");
264+
e.printStackTrace();
265+
dialog = optionPane.createDialog("Forge Installer");
266+
}
259267
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
260268
dialog.setVisible(true);
261269
int result = (Integer) (optionPane.getValue() != null ? optionPane.getValue() : -1);

src/main/java/net/minecraftforge/installer/SimpleInstaller.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public static void main(String[] args) throws IOException, URISyntaxException {
9999
"sessionserver.mojang.com",
100100
"authserver.mojang.com",
101101
}) {
102-
monitor.message("Host: " + host + " [" + DownloadUtils.getIpString(host) + "]");
102+
monitor.message("Host: " + host + " [" + DownloadUtils.getIpString(host) + ']');
103103
}
104104

105105
for (String host : new String[] {
@@ -178,6 +178,7 @@ private static void launchGui(ProgressCallback monitor, File installer, String b
178178
private static void launchGui(ProgressCallback monitor, File installer, String badCerts, OptionParser parser) {
179179
try {
180180
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
181+
if (Win11MicaEffect.isSupported()) SwingUtil.applyGlobalFont("Segoe UI");
181182
} catch (HeadlessException headless) {
182183
// if ran in a headless CLI environment with no args, show some help text and exit gracefully
183184
if (parser == null) {

0 commit comments

Comments
 (0)