fix(ui): keep main window closable after a native dialog on Windows (#6319)

A native Windows MessageBox attached to a parent window disables that
window (WS_DISABLED) for its lifetime and re-enables it on dismissal.
When the parent is the main window — whose WindowClosing hook hides
instead of closes — the enable/hide sequence races and leaves the window
unable to process its close (X) button afterwards, so e.g. a rejected
login error dialog left the main window stuck open.

Route all native dialogs through src/lib/dialogs.ts, which forces
Detached: true on Windows (NULL owner, no window ever disabled) and is a
no-op on macOS/Linux (keeps the attached sheet-style presentation).
This commit is contained in:
Zoltan Papp
2026-06-01 17:03:53 +02:00
committed by GitHub
parent 49df24b18c
commit 53d43980ad
13 changed files with 80 additions and 32 deletions

View File

@@ -178,7 +178,9 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
## Dialogs convention
Errors → `Dialogs.Error` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `Dialogs.Warning` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
**Always go through `src/lib/dialogs.ts`**`errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
## Tailwind tokens

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
@@ -90,7 +90,7 @@ export function LanguagePicker() {
try {
await Preferences.SetLanguage(code as LanguageCode);
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -8,7 +8,8 @@ import {
useState,
type ReactNode,
} from "react";
import { Dialogs, Events } from "@wailsio/runtime";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
@@ -66,7 +67,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
})
.catch((e) => {
if (cancelled || isDaemonUnavailable(e)) return;
void Dialogs.Error({
void errorDialog({
Title: i18next.t("update.error.loadStateTitle"),
Message: formatErrorMessage(e),
});
@@ -106,7 +107,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
// produce a result) and surface the error.
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await Dialogs.Error({
await errorDialog({
Title: i18next.t("update.error.triggerTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -5,7 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
@@ -163,7 +163,7 @@ const useDebugBundle = () => {
return;
}
setStage({ kind: "idle" });
await Dialogs.Error({
await errorDialog({
Title: i18next.t("settings.error.debugBundleTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -6,7 +6,8 @@ import {
useState,
type ReactNode,
} from "react";
import { Dialogs, Events } from "@wailsio/runtime";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection,
ProfileSwitcher,
@@ -65,7 +66,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
if (msg.includes("code = Unavailable")) {
return;
}
await Dialogs.Error({
await errorDialog({
Title: i18next.t("profile.error.loadTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Autostart, Settings as SettingsSvc } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
@@ -68,7 +68,7 @@ const useSettingsState = () => {
});
setConfig(c);
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: i18next.t("settings.error.loadTitle"),
Message: errorMessage(e),
});
@@ -98,7 +98,7 @@ const useSettingsState = () => {
username,
});
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: errorMessage(e),
});
@@ -211,7 +211,7 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
await Autostart.SetEnabled(enabled);
} catch (e) {
setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
await Dialogs.Error({
await errorDialog({
Title: i18next.t("settings.general.autostart.errorTitle"),
Message: errorMessage(e),
});

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Dialogs } from "@wailsio/runtime";
import { warningDialog } from "@/lib/dialogs.ts";
import i18next from "@/lib/i18n";
import { useSettings } from "@/contexts/SettingsContext.tsx";
@@ -70,7 +70,7 @@ export function useManagementUrl() {
switchConfirmOpenRef.current = true;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
void Dialogs.Warning({
void warningDialog({
Title: i18next.t("settings.general.management.switchCloudTitle"),
Message: i18next.t("settings.general.management.switchCloudMessage"),
Buttons: [

View File

@@ -0,0 +1,42 @@
import { Dialogs } from "@wailsio/runtime";
import { isWindows } from "@/lib/platform";
// Derived from the runtime rather than deep-imported: the package's exports map
// only exposes the types barrel, not "@wailsio/runtime/types/dialogs".
type MessageDialogOptions = Parameters<typeof Dialogs.Error>[0];
// On Windows a native MessageBox attached to a parent window disables that
// parent (WS_DISABLED) for the lifetime of the dialog and re-enables it on
// dismissal. When the parent is the main window — whose WindowClosing hook
// hides instead of closes (main.go) — the enable/hide sequence can race and
// leave the window unable to process its close (X) button afterwards: the user
// reports the main window can no longer be closed once an error dialog (e.g. a
// rejected login) has been shown. Detaching the dialog gives the MessageBox a
// NULL owner, so no window is ever disabled and the X keeps working.
//
// macOS keeps the attached (sheet-style) presentation — the bug is Windows-only
// and detaching there loses the sheet animation — so we only force Detached on
// Windows and leave any caller-supplied value untouched elsewhere.
function withDetached(options: MessageDialogOptions): MessageDialogOptions {
if (options.Detached !== undefined || !isWindows()) {
return options;
}
return { ...options, Detached: true };
}
export function errorDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Error(withDetached(options));
}
export function warningDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Warning(withDetached(options));
}
export function infoDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Info(withDetached(options));
}
export function questionDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Question(withDetached(options));
}

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Dialogs, Events } from "@wailsio/runtime";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
@@ -25,7 +26,7 @@ export default function LoginWaitingForBrowserDialog() {
const reportOpenFailure = useCallback(
(e: unknown) => {
void Dialogs.Error({
void errorDialog({
Title: t("browserLogin.openFailedTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialogs, Events } from "@wailsio/runtime";
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { errorDialog } from "@/lib/dialogs.ts";
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { useProfile } from "@/contexts/ProfileContext.tsx";
@@ -105,7 +106,7 @@ async function startLogin(): Promise<void> {
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
if (cancelled) return;
await Dialogs.Error({
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: errorMessage(e),
});
@@ -192,7 +193,7 @@ export const MainConnectionStatusSwitch = () => {
} catch (e) {
setAction(null);
await refresh();
await Dialogs.Error({
await errorDialog({
Title: t("connect.error.connectTitle"),
Message: errorMessage(e),
});
@@ -212,7 +213,7 @@ export const MainConnectionStatusSwitch = () => {
} catch (e) {
setAction(null);
await refresh();
await Dialogs.Error({
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});

View File

@@ -1,6 +1,6 @@
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
@@ -36,7 +36,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
try {
await fn();
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});

View File

@@ -1,6 +1,6 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
@@ -44,7 +44,7 @@ export function ProfilesTab() {
try {
await fn();
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
@@ -56,7 +56,7 @@ export function ProfilesTab() {
const handleDeregister = async (name: string) => {
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.deregister.confirm");
const result = await Dialogs.Warning({
const result = await warningDialog({
Title: i18next.t("profile.deregister.title"),
Message: i18next.t("profile.deregister.message", { name }),
Buttons: [
@@ -72,7 +72,7 @@ export function ProfilesTab() {
if (name === DEFAULT_PROFILE) return;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("common.delete");
const result = await Dialogs.Warning({
const result = await warningDialog({
Title: i18next.t("profile.delete.title"),
Message: i18next.t("profile.delete.message", { name }),
Buttons: [
@@ -89,7 +89,7 @@ export function ProfilesTab() {
await addProfile(name);
await switchProfile(name);
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: i18next.t("profile.error.createTitle"),
Message: formatErrorMessage(e),
});

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Dialogs } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { ClockIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
@@ -89,7 +89,7 @@ export default function SessionAboutToExpireDialog() {
}
WindowManager.CloseSessionAboutToExpire().catch(console.error);
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: t("sessionAboutToExpire.extendFailedTitle"),
Message: formatErrorMessage(e),
});
@@ -110,7 +110,7 @@ export default function SessionAboutToExpireDialog() {
});
WindowManager.CloseSessionAboutToExpire().catch(console.error);
} catch (e) {
await Dialogs.Error({
await errorDialog({
Title: t("sessionAboutToExpire.logoutFailedTitle"),
Message: formatErrorMessage(e),
});