diff --git a/app/src/main/java/io/xpipe/app/core/AppWindowsShutdown.java b/app/src/main/java/io/xpipe/app/core/AppWindowsShutdown.java new file mode 100644 index 000000000..0d87d3164 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/core/AppWindowsShutdown.java @@ -0,0 +1,88 @@ +package io.xpipe.app.core; + +import com.sun.jna.*; +import com.sun.jna.platform.win32.User32; +import com.sun.jna.platform.win32.WinDef; +import com.sun.jna.platform.win32.WinUser; +import io.xpipe.app.core.mode.OperationMode; +import io.xpipe.app.util.PlatformState; +import io.xpipe.app.util.PlatformThread; +import io.xpipe.app.util.ThreadHelper; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +public class AppWindowsShutdown { + + // Prevent GC + private static final WinShutdownHookProc PROC = new WinShutdownHookProc(); + + public static void registerHook(WinDef.HWND hwnd) { + int windowThreadID = User32.INSTANCE.GetWindowThreadProcessId(hwnd, null); + if (windowThreadID == 0) { + return; + } + + PROC.hwnd = hwnd; + PROC.hhook = User32.INSTANCE.SetWindowsHookEx(4, PROC, null, windowThreadID); + } + + public static class CWPSSTRUCT extends Structure { + public WinDef.LPARAM lParam; + public WinDef.WPARAM wParam; + public WinDef.DWORD message; + public WinDef.HWND hwnd; + + @Override + protected List getFieldOrder() { + return List.of("lParam", "wParam", "message", "hwnd"); + } + } + + public interface WinHookProc extends WinUser.HOOKPROC { + + WinDef.LRESULT callback(int nCode, WinDef.WPARAM wParam, CWPSSTRUCT hookProcStruct); + } + + public static final int WM_ENDSESSION = 0x16; + public static final int WM_QUERYENDSESSION = 0x11; + public static final long ENDSESSION_CRITICAL = 0x40000000L; + + @RequiredArgsConstructor + public static final class WinShutdownHookProc implements WinHookProc { + + @Setter + private WinUser.HHOOK hhook; + @Setter + private WinDef.HWND hwnd; + + @Override + public WinDef.LRESULT callback(int nCode, WinDef.WPARAM wParam, CWPSSTRUCT hookProcStruct) { + if (nCode >= 0 && hookProcStruct.hwnd.equals(hwnd)) { + if (hookProcStruct.message.longValue() == WM_QUERYENDSESSION) { + // Indicates that we need to run the endsession case blocking + return new WinDef.LRESULT(0); + } + + if (hookProcStruct.message.longValue() == WM_ENDSESSION) { + // Instant exit for critical shutdowns + if (hookProcStruct.lParam.longValue() == ENDSESSION_CRITICAL) { + OperationMode.halt(0); + } + + // A shutdown hook will be started in parallel while we exit + // The only thing we have to do is wait for it to exit the platform + while (PlatformState.getCurrent() != PlatformState.EXITED) { + ThreadHelper.sleep(100); + PlatformThread.runNestedLoopIteration(); + } + + return new WinDef.LRESULT(0); + } + } + return User32.INSTANCE.CallNextHookEx(hhook, nCode, wParam, new WinDef.LPARAM(Pointer.nativeValue(hookProcStruct.getPointer()))); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/core/mode/GuiMode.java b/app/src/main/java/io/xpipe/app/core/mode/GuiMode.java index 3e8e4935f..b15c5a942 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/GuiMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/GuiMode.java @@ -4,6 +4,7 @@ import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.util.PlatformThread; +import javafx.application.Platform; import javafx.stage.Stage; public class GuiMode extends PlatformMode { diff --git a/app/src/main/java/io/xpipe/app/core/mode/OperationMode.java b/app/src/main/java/io/xpipe/app/core/mode/OperationMode.java index 308bca8e8..70a9b5542 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/OperationMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/OperationMode.java @@ -19,6 +19,7 @@ import javafx.application.Platform; import lombok.Getter; import lombok.SneakyThrows; +import java.time.Duration; import java.util.List; public abstract class OperationMode { @@ -34,9 +35,6 @@ public abstract class OperationMode { @Getter private static boolean inShutdown; - @Getter - private static boolean inShutdownHook; - private static OperationMode CURRENT = null; public static OperationMode map(XPipeDaemonMode mode) { @@ -73,7 +71,7 @@ public abstract class OperationMode { } TrackEvent.info("Received SIGTERM externally"); - OperationMode.shutdown(true, false); + OperationMode.shutdown(false); })); // Handle uncaught exceptions @@ -174,7 +172,7 @@ public abstract class OperationMode { if (OsType.getLocal() != OsType.LINUX) { OperationMode.switchToSyncOrThrow(OperationMode.GUI); } - OperationMode.shutdown(false, false); + OperationMode.shutdown(false); return; } @@ -275,7 +273,6 @@ public abstract class OperationMode { } inShutdown = true; - inShutdownHook = false; try { if (CURRENT != null) { CURRENT.finalTeardown(); @@ -320,35 +317,20 @@ public abstract class OperationMode { }); } - public static void shutdown(boolean inShutdownHook, boolean hasError) { + @SneakyThrows + public static void shutdown(boolean hasError) { if (isInStartup()) { TrackEvent.info("Received shutdown request while in startup. Halting ..."); OperationMode.halt(1); } - // In case we are stuck while in shutdown, instantly exit this application - if (inShutdown && inShutdownHook) { - TrackEvent.info("Received another shutdown request while in shutdown hook. Halting ..."); - OperationMode.halt(1); - } - if (inShutdown) { return; } - // Run a timer to always exit after some time in case we get stuck - if (!hasError && !AppProperties.get().isDevelopmentEnvironment()) { - ThreadHelper.runAsync(() -> { - ThreadHelper.sleep(25000); - TrackEvent.info("Shutdown took too long. Halting ..."); - OperationMode.halt(1); - }); - } - TrackEvent.info("Starting shutdown ..."); inShutdown = true; - OperationMode.inShutdownHook = inShutdownHook; // Keep a non-daemon thread running var thread = ThreadHelper.createPlatformThread("shutdown", false, () -> { try { @@ -364,6 +346,14 @@ public abstract class OperationMode { OperationMode.halt(hasError ? 1 : 0); }); thread.start(); + + // Use a timer to always exit after some time in case we get stuck + var limit = !hasError && !AppProperties.get().isDevelopmentEnvironment() ? 25000 : Integer.MAX_VALUE; + var exited = thread.join(Duration.ofMillis(limit)); + if (!exited) { + TrackEvent.info("Shutdown took too long. Halting ..."); + OperationMode.halt(1); + } } private static synchronized void set(OperationMode newMode) { @@ -381,7 +371,7 @@ public abstract class OperationMode { try { if (newMode == null) { - shutdown(false, false); + shutdown(false); return; } diff --git a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java index 9053affd9..46224c745 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java @@ -56,6 +56,8 @@ public class AppMainWindow { @Getter private static final Property loadingText = new SimpleObjectProperty<>(); + private boolean shown = false; + private AppMainWindow(Stage stage) { this.stage = stage; } @@ -145,9 +147,12 @@ public class AppMainWindow { public void show() { stage.show(); - if (OsType.getLocal() == OsType.WINDOWS) { - NativeWinWindowControl.MAIN_WINDOW = new NativeWinWindowControl(stage); + if (OsType.getLocal() == OsType.WINDOWS && !shown) { + var ctrl = new NativeWinWindowControl(stage); + NativeWinWindowControl.MAIN_WINDOW = ctrl; + AppWindowsShutdown.registerHook(ctrl.getWindowHandle()); } + shown = true; } private static String createTitle() { diff --git a/app/src/main/java/io/xpipe/app/core/window/ModifiedStage.java b/app/src/main/java/io/xpipe/app/core/window/ModifiedStage.java index 6aef19133..165a33eb2 100644 --- a/app/src/main/java/io/xpipe/app/core/window/ModifiedStage.java +++ b/app/src/main/java/io/xpipe/app/core/window/ModifiedStage.java @@ -1,7 +1,12 @@ package io.xpipe.app.core.window; +import io.xpipe.app.core.AppLogs; +import io.xpipe.app.core.AppWindowsShutdown; +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.PlatformThread; +import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.OsType; import javafx.animation.PauseTransition; @@ -14,8 +19,11 @@ import javafx.stage.StageStyle; import javafx.stage.Window; import javafx.util.Duration; +import lombok.SneakyThrows; import org.apache.commons.lang3.SystemUtils; +import java.awt.*; + public class ModifiedStage extends Stage { public static boolean mergeFrame() { @@ -55,6 +63,7 @@ public class ModifiedStage extends Stage { }); } + @SneakyThrows private static void applyModes(Stage stage) { if (stage.getScene() == null) { return; @@ -71,47 +80,40 @@ public class ModifiedStage extends Stage { return; } - switch (OsType.getLocal()) { - case OsType.Linux linux -> {} - case OsType.MacOs macOs -> { - var ctrl = new NativeMacOsWindowControl(stage); - var seamlessFrame = AppMainWindow.getInstance() != null - && AppMainWindow.getInstance().getStage() == stage - && !AppPrefs.get().performanceMode().get() - && mergeFrame(); - var seamlessFrameApplied = ctrl.setAppearance( - seamlessFrame, AppPrefs.get().theme().getValue().isDark()) - && seamlessFrame; - stage.getScene() - .getRoot() - .pseudoClassStateChanged(PseudoClass.getPseudoClass("seamless-frame"), seamlessFrameApplied); - stage.getScene() - .getRoot() - .pseudoClassStateChanged(PseudoClass.getPseudoClass("separate-frame"), !seamlessFrameApplied); - } - case OsType.Windows windows -> { - var ctrl = new NativeWinWindowControl(stage); - ctrl.setWindowAttribute( - NativeWinWindowControl.DmwaWindowAttribute.DWMWA_USE_IMMERSIVE_DARK_MODE.get(), - AppPrefs.get().theme().getValue().isDark()); - boolean seamlessFrame; - if (AppPrefs.get().performanceMode().get() - || !mergeFrame() - || AppMainWindow.getInstance() == null - || stage != AppMainWindow.getInstance().getStage()) { - seamlessFrame = false; - } else { - // This is not available on Windows 10 - seamlessFrame = ctrl.setWindowBackdrop(NativeWinWindowControl.DwmSystemBackDropType.MICA_ALT) - || SystemUtils.IS_OS_WINDOWS_10; + try { + switch (OsType.getLocal()) { + case OsType.Linux linux -> { + } + case OsType.MacOs macOs -> { + var ctrl = new NativeMacOsWindowControl(stage); + var seamlessFrame = AppMainWindow.getInstance() != null && + AppMainWindow.getInstance().getStage() == stage && + !AppPrefs.get().performanceMode().get() && + mergeFrame(); + var seamlessFrameApplied = ctrl.setAppearance(seamlessFrame, AppPrefs.get().theme().getValue().isDark()) && seamlessFrame; + stage.getScene().getRoot().pseudoClassStateChanged(PseudoClass.getPseudoClass("seamless-frame"), seamlessFrameApplied); + stage.getScene().getRoot().pseudoClassStateChanged(PseudoClass.getPseudoClass("separate-frame"), !seamlessFrameApplied); + } + case OsType.Windows windows -> { + var ctrl = new NativeWinWindowControl(stage); + ctrl.setWindowAttribute(NativeWinWindowControl.DmwaWindowAttribute.DWMWA_USE_IMMERSIVE_DARK_MODE.get(), + AppPrefs.get().theme().getValue().isDark()); + boolean seamlessFrame; + if (AppPrefs.get().performanceMode().get() || + !mergeFrame() || + AppMainWindow.getInstance() == null || + stage != AppMainWindow.getInstance().getStage()) { + seamlessFrame = false; + } else { + // This is not available on Windows 10 + seamlessFrame = ctrl.setWindowBackdrop(NativeWinWindowControl.DwmSystemBackDropType.MICA_ALT) || SystemUtils.IS_OS_WINDOWS_10; + } + stage.getScene().getRoot().pseudoClassStateChanged(PseudoClass.getPseudoClass("seamless-frame"), seamlessFrame); + stage.getScene().getRoot().pseudoClassStateChanged(PseudoClass.getPseudoClass("separate-frame"), !seamlessFrame); } - stage.getScene() - .getRoot() - .pseudoClassStateChanged(PseudoClass.getPseudoClass("seamless-frame"), seamlessFrame); - stage.getScene() - .getRoot() - .pseudoClassStateChanged(PseudoClass.getPseudoClass("separate-frame"), !seamlessFrame); } + } catch (Throwable t) { + ErrorEvent.fromThrowable(t).omit().handle(); } } diff --git a/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java b/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java index 29f84ea67..3252a45fe 100644 --- a/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java +++ b/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java @@ -24,6 +24,21 @@ import java.util.List; @EqualsAndHashCode public class NativeWinWindowControl { + @SneakyThrows + public static WinDef.HWND byWindow(Window window) { + Method tkStageGetter = Window.class.getDeclaredMethod("getPeer"); + tkStageGetter.setAccessible(true); + Object tkStage = tkStageGetter.invoke(window); + Method getPlatformWindow = tkStage.getClass().getDeclaredMethod("getPlatformWindow"); + getPlatformWindow.setAccessible(true); + Object platformWindow = getPlatformWindow.invoke(tkStage); + Method getNativeHandle = platformWindow.getClass().getMethod("getNativeHandle"); + getNativeHandle.setAccessible(true); + Object nativeHandle = getNativeHandle.invoke(platformWindow); + var hwnd = new WinDef.HWND(new Pointer((long) nativeHandle)); + return hwnd; + } + public static List byPid(long pid) { var refs = new ArrayList(); User32.INSTANCE.EnumWindows( @@ -50,17 +65,7 @@ public class NativeWinWindowControl { @SneakyThrows public NativeWinWindowControl(Window stage) { - Method tkStageGetter = Window.class.getDeclaredMethod("getPeer"); - tkStageGetter.setAccessible(true); - Object tkStage = tkStageGetter.invoke(stage); - Method getPlatformWindow = tkStage.getClass().getDeclaredMethod("getPlatformWindow"); - getPlatformWindow.setAccessible(true); - Object platformWindow = getPlatformWindow.invoke(tkStage); - Method getNativeHandle = platformWindow.getClass().getMethod("getNativeHandle"); - getNativeHandle.setAccessible(true); - Object nativeHandle = getNativeHandle.invoke(platformWindow); - var hwnd = new WinDef.HWND(new Pointer((long) nativeHandle)); - this.windowHandle = hwnd; + this.windowHandle = byWindow(stage); } public NativeWinWindowControl(WinDef.HWND windowHandle) { diff --git a/app/src/main/java/io/xpipe/app/prefs/CloseBehaviour.java b/app/src/main/java/io/xpipe/app/prefs/CloseBehaviour.java index a05499bfa..c10e3f759 100644 --- a/app/src/main/java/io/xpipe/app/prefs/CloseBehaviour.java +++ b/app/src/main/java/io/xpipe/app/prefs/CloseBehaviour.java @@ -10,7 +10,7 @@ public enum CloseBehaviour implements PrefsChoiceValue { QUIT("app.quit") { @Override public void run() { - OperationMode.shutdown(false, false); + OperationMode.shutdown(false); } }, diff --git a/app/src/main/java/io/xpipe/app/util/PlatformState.java b/app/src/main/java/io/xpipe/app/util/PlatformState.java index 0bcca95e2..a97a4cd13 100644 --- a/app/src/main/java/io/xpipe/app/util/PlatformState.java +++ b/app/src/main/java/io/xpipe/app/util/PlatformState.java @@ -1,5 +1,6 @@ package io.xpipe.app.util; +import io.xpipe.app.core.AppWindowsShutdown; import io.xpipe.app.core.check.AppSystemFontCheck; import io.xpipe.app.core.window.ModifiedStage; import io.xpipe.app.issue.ErrorEvent; @@ -11,6 +12,7 @@ import javafx.scene.text.Font; import lombok.Getter; import lombok.Setter; +import lombok.SneakyThrows; import org.apache.commons.lang3.SystemUtils; import java.awt.*; @@ -103,6 +105,9 @@ public enum PlatformState { // Check if we have no fonts and set properties to load bundled ones AppSystemFontCheck.init(); + // We use our own shutdown hook + disableToolkitShutdownHook(); + if (AppPrefs.get() != null) { var s = AppPrefs.get().uiScale().getValue(); if (s != null) { @@ -205,4 +210,16 @@ public enum PlatformState { return OptionalInt.empty(); } } + + @SneakyThrows + private static void disableToolkitShutdownHook() { + var tkClass = Class.forName(ModuleLayer.boot().findModule("javafx.graphics").orElseThrow(), "com.sun.javafx.tk.Toolkit"); + var getToolkitMethod = tkClass.getDeclaredMethod("getToolkit"); + getToolkitMethod.setAccessible(true); + var tk = getToolkitMethod.invoke(null); + var shutdownHookField = tk.getClass().getDeclaredField("shutdownHook"); + shutdownHookField.setAccessible(true); + var thread = (Thread) shutdownHookField.get(tk); + Runtime.getRuntime().removeShutdownHook(thread); + } } diff --git a/app/src/main/java/io/xpipe/app/util/PlatformThread.java b/app/src/main/java/io/xpipe/app/util/PlatformThread.java index 4d3d16bf3..2f6859784 100644 --- a/app/src/main/java/io/xpipe/app/util/PlatformThread.java +++ b/app/src/main/java/io/xpipe/app/util/PlatformThread.java @@ -266,8 +266,7 @@ public class PlatformThread { return false; } - // Once the shutdown hooks are run, the toolkit is shutdown, causing it to no longer perform runLater operations - if (OperationMode.isInShutdownHook()) { + if (OperationMode.isInShutdown()) { return false; } diff --git a/build.gradle b/build.gradle index 4e6eaa74c..68856189d 100644 --- a/build.gradle +++ b/build.gradle @@ -136,6 +136,7 @@ project.ext { "--add-exports", "jdk.zipfs/jdk.nio.zipfs=io.xpipe.modulefs", "--add-opens", "javafx.graphics/com.sun.glass.ui=io.xpipe.app", "--add-opens", "javafx.graphics/javafx.stage=io.xpipe.app", + "--add-opens", "javafx.graphics/com.sun.javafx.tk=io.xpipe.app", "--add-opens", "javafx.graphics/com.sun.javafx.tk.quantum=io.xpipe.app", "-Dio.xpipe.app.arch=$rootProject.arch", "-Dfile.encoding=UTF-8",