Add cross-platform confirm dialog wrapper for daemon restart
This commit is contained in:
@@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont
|
|||||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -982,29 +983,21 @@ function HostDetailModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
void confirmDialog({
|
||||||
const hasBrowserConfirm =
|
title: `Restart ${host.label}`,
|
||||||
typeof globalThis !== "undefined" &&
|
message: restartConfirmationMessage,
|
||||||
typeof (globalThis as any).confirm === "function";
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
const confirmed = hasBrowserConfirm
|
destructive: true,
|
||||||
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
|
}).then((confirmed) => {
|
||||||
: true;
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
if (confirmed) {
|
|
||||||
beginServerRestart();
|
|
||||||
}
|
}
|
||||||
return;
|
beginServerRestart();
|
||||||
}
|
}).catch((error) => {
|
||||||
|
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
|
||||||
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
|
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||||
{ text: "Cancel", style: "cancel" },
|
});
|
||||||
{
|
|
||||||
text: "Restart",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: beginServerRestart,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
||||||
|
|
||||||
// Status display
|
// Status display
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type MockPlatform = "web" | "ios" | "android";
|
||||||
|
|
||||||
|
type AlertButton = {
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||||
|
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
|
||||||
|
alertMock: ReturnType<typeof vi.fn>;
|
||||||
|
}> {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const alertMock = vi.fn();
|
||||||
|
vi.doMock("react-native", () => ({
|
||||||
|
Alert: {
|
||||||
|
alert: alertMock,
|
||||||
|
},
|
||||||
|
Platform: { OS: platform },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const module = await import("./confirm-dialog");
|
||||||
|
return { confirmDialog: module.confirmDialog, alertMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDialogGlobals(): void {
|
||||||
|
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
delete (globalThis as { confirm?: unknown }).confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("confirmDialog", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock("react-native");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
clearDialogGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Tauri dialog.ask on web when available", async () => {
|
||||||
|
const askMock = vi.fn(async () => true);
|
||||||
|
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||||
|
dialog: { ask: askMock },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(alertMock).not.toHaveBeenCalled();
|
||||||
|
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
|
||||||
|
title: "Restart host",
|
||||||
|
okLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
kind: "warning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||||
|
const browserConfirm = vi.fn(() => true);
|
||||||
|
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
|
||||||
|
|
||||||
|
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(browserConfirm).toHaveBeenCalledWith(
|
||||||
|
"Restart host\n\nThis will restart the daemon."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on web when no confirm backend exists", async () => {
|
||||||
|
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses native Alert on iOS/Android", async () => {
|
||||||
|
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
|
||||||
|
alertMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_title: string,
|
||||||
|
_message: string,
|
||||||
|
buttons?: AlertButton[]
|
||||||
|
) => {
|
||||||
|
const confirmButton = buttons?.[1];
|
||||||
|
confirmButton?.onPress?.();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Restart host",
|
||||||
|
message: "This will restart the daemon.",
|
||||||
|
confirmLabel: "Restart",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
expect(alertMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Alert, Platform } from "react-native";
|
||||||
|
|
||||||
|
export interface ConfirmDialogInput {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDialogAskOptions {
|
||||||
|
title?: string;
|
||||||
|
okLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
kind?: "info" | "warning" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDialogApi {
|
||||||
|
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriCoreApi {
|
||||||
|
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriGlobalApi {
|
||||||
|
dialog?: TauriDialogApi;
|
||||||
|
core?: TauriCoreApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmButtonConfig {
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
|
||||||
|
return {
|
||||||
|
confirmLabel: input.confirmLabel ?? "Confirm",
|
||||||
|
cancelLabel: input.cancelLabel ?? "Cancel",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||||
|
const labels = resolveButtonLabels(input);
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
Alert.alert(
|
||||||
|
input.title,
|
||||||
|
input.message,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: labels.cancelLabel,
|
||||||
|
style: "cancel",
|
||||||
|
onPress: () => resolve(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: labels.confirmLabel,
|
||||||
|
style: input.destructive ? "destructive" : "default",
|
||||||
|
onPress: () => resolve(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cancelable: true,
|
||||||
|
onDismiss: () => resolve(false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTauriApi(): TauriGlobalApi | null {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||||
|
return tauriApi ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||||
|
const labels = resolveButtonLabels(input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: input.title,
|
||||||
|
okLabel: labels.confirmLabel,
|
||||||
|
cancelLabel: labels.cancelLabel,
|
||||||
|
kind: input.destructive ? "warning" : "info",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||||
|
const tauriApi = getTauriApi();
|
||||||
|
if (!tauriApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = buildTauriAskOptions(input);
|
||||||
|
const tauriAsk = tauriApi.dialog?.ask;
|
||||||
|
|
||||||
|
if (typeof tauriAsk === "function") {
|
||||||
|
try {
|
||||||
|
return Boolean(await tauriAsk(input.message, options));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriInvoke = tauriApi.core?.invoke;
|
||||||
|
if (typeof tauriInvoke === "function") {
|
||||||
|
try {
|
||||||
|
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||||
|
message: input.message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return result === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
|
||||||
|
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
|
||||||
|
if (typeof browserConfirm !== "function") {
|
||||||
|
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptMessage = `${input.title}\n\n${input.message}`;
|
||||||
|
return browserConfirm(promptMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return showNativeConfirmDialog(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriResult = await showTauriConfirmDialog(input);
|
||||||
|
if (tauriResult !== null) {
|
||||||
|
return tauriResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return showWebConfirmDialog(input);
|
||||||
|
}
|
||||||
Generated
+67
@@ -813,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2517,6 +2519,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
@@ -3114,6 +3117,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3946,6 +3973,46 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 0.9.11+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ serde_json = "1.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.9.5", features = [] }
|
tauri = { version = "2.9.5", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-badge-count",
|
"core:window:allow-set-badge-count",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
"dialog:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"websocket:default"
|
"websocket:default"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
|||||||
Reference in New Issue
Block a user