diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index cb94f0dae..9c8764a17 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { measureConnectionLatency } from "@/utils/test-daemon-connection"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { theme as defaultTheme } from "@/styles/theme"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; @@ -982,29 +983,21 @@ function HostDetailModal({ return; } - if (Platform.OS === "web") { - const hasBrowserConfirm = - typeof globalThis !== "undefined" && - typeof (globalThis as any).confirm === "function"; - - const confirmed = hasBrowserConfirm - ? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`) - : true; - - if (confirmed) { - beginServerRestart(); + void confirmDialog({ + title: `Restart ${host.label}`, + message: restartConfirmationMessage, + confirmLabel: "Restart", + cancelLabel: "Cancel", + destructive: true, + }).then((confirmed) => { + if (!confirmed) { + return; } - return; - } - - Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [ - { text: "Cancel", style: "cancel" }, - { - text: "Restart", - style: "destructive", - onPress: beginServerRestart, - }, - ]); + beginServerRestart(); + }).catch((error) => { + console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); + Alert.alert("Error", "Unable to open the restart confirmation dialog."); + }); }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); // Status display diff --git a/packages/app/src/utils/confirm-dialog.test.ts b/packages/app/src/utils/confirm-dialog.test.ts new file mode 100644 index 000000000..df739d40d --- /dev/null +++ b/packages/app/src/utils/confirm-dialog.test.ts @@ -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; +}> { + 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(); + }); +}); diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts new file mode 100644 index 000000000..fdc7adf39 --- /dev/null +++ b/packages/app/src/utils/confirm-dialog.ts @@ -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; +} + +interface TauriCoreApi { + invoke?: (command: string, args?: Record) => Promise; +} + +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 { + const labels = resolveButtonLabels(input); + + return new Promise((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 { + 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 { + if (Platform.OS !== "web") { + return showNativeConfirmDialog(input); + } + + const tauriResult = await showTauriConfirmDialog(input); + if (tauriResult !== null) { + return tauriResult; + } + + return showWebConfirmDialog(input); +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 6838aa7b4..9b8df883e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -813,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -2517,6 +2519,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-websocket", @@ -3114,6 +3117,30 @@ dependencies = [ "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]] name = "ring" version = "0.17.14" @@ -3946,6 +3973,46 @@ dependencies = [ "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]] name = "tauri-plugin-log" version = "2.8.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index ca0747f7e..b69373ca7 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } +tauri-plugin-dialog = "2" tauri-plugin-log = "2" tauri-plugin-opener = "2" tauri-plugin-websocket = "2" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index a8efa741f..21bd32027 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:window:allow-start-dragging", "core:window:allow-set-badge-count", "core:window:allow-toggle-maximize", + "dialog:default", "opener:default", "websocket:default" ] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index c9b89f573..7c12e3e32 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_websocket::init()) .setup(|app| {