Rework exit on windows

This commit is contained in:
crschnick
2025-03-08 01:28:31 +00:00
parent 02ca60ec88
commit e8f4145ed6
10 changed files with 187 additions and 79 deletions

View File

@@ -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<String> 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())));
}
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -56,6 +56,8 @@ public class AppMainWindow {
@Getter
private static final Property<String> 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() {

View File

@@ -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();
}
}

View File

@@ -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<NativeWinWindowControl> byPid(long pid) {
var refs = new ArrayList<NativeWinWindowControl>();
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) {

View File

@@ -10,7 +10,7 @@ public enum CloseBehaviour implements PrefsChoiceValue {
QUIT("app.quit") {
@Override
public void run() {
OperationMode.shutdown(false, false);
OperationMode.shutdown(false);
}
},

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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",