import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity, } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { useAppSettings } from "@/hooks/use-settings"; import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, getDesktopDaemonLogs, getDesktopDaemonStatus, restartDesktopDaemon, shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, type DesktopDaemonLogs, type DesktopDaemonStatus, } from "@/desktop/daemon/desktop-daemon"; export interface LocalDaemonSectionProps { appVersion: string | null; showLifecycleControls: boolean; } export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); const { settings, updateSettings } = useAppSettings(); const [daemonStatus, setDaemonStatus] = useState(null); const [daemonVersion, setDaemonVersion] = useState(null); const [statusError, setStatusError] = useState(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [statusMessage, setStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const loadDaemonData = useCallback(() => { if (!showSection) { return Promise.resolve(); } return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]) .then(([status, logs]) => { setDaemonStatus(status); setDaemonLogs(logs); setDaemonVersion(status.version); setStatusError(null); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); setStatusError(message); }); }, [showSection]); useFocusEffect( useCallback(() => { if (!showSection) { return undefined; } void loadDaemonData(); return undefined; }, [loadDaemonData, showSection]), ); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; const isDaemonManagementPaused = !settings.manageBuiltInDaemon; const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon"; const daemonActionMessage = daemonStatus?.status === "running" ? "Restarts the built-in daemon." : "Starts the built-in daemon."; const handleUpdateLocalDaemon = useCallback(() => { if (!showSection || isRestartingDaemon) { return; } void confirmDialog({ title: daemonActionLabel, message: daemonStatus?.status === "running" ? "This will restart the built-in daemon. The app will reconnect automatically." : "This will start the built-in daemon.", confirmLabel: daemonActionLabel, cancelLabel: "Cancel", }) .then((confirmed) => { if (!confirmed) { return; } setIsRestartingDaemon(true); setStatusMessage(null); const action = daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; void action() .then((status) => { setDaemonStatus(status); setStatusMessage( daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", ); return loadDaemonData(); }) .catch((error) => { console.error("[Settings] Failed to change desktop daemon state", error); const message = error instanceof Error ? error.message : String(error); setStatusMessage(`${daemonActionLabel} failed: ${message}`); }) .finally(() => { setIsRestartingDaemon(false); }); }) .catch((error) => { console.error("[Settings] Failed to open desktop daemon action confirmation", error); Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]); const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { return; } if (!settings.manageBuiltInDaemon) { setIsUpdatingDaemonManagement(true); setStatusMessage(null); void updateSettings({ manageBuiltInDaemon: true }) .then(() => { setStatusMessage("Built-in daemon management resumed."); }) .catch((error) => { console.error("[Settings] Failed to update built-in daemon management", error); Alert.alert("Error", "Unable to update built-in daemon management."); }) .finally(() => { setIsUpdatingDaemonManagement(false); }); return; } void confirmDialog({ title: "Pause built-in daemon", message: "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", confirmLabel: "Pause and stop", cancelLabel: "Cancel", destructive: true, }) .then((confirmed) => { if (!confirmed) { return; } setIsUpdatingDaemonManagement(true); setStatusMessage(null); const stopPromise = daemonStatus?.status === "running" ? stopDesktopDaemon() : Promise.resolve(daemonStatus ?? null); void stopPromise .then(() => updateSettings({ manageBuiltInDaemon: false })) .then(() => loadDaemonData()) .then(() => { setStatusMessage("Built-in daemon paused and stopped."); }) .catch((error) => { console.error("[Settings] Failed to pause built-in daemon management", error); Alert.alert("Error", "Unable to pause built-in daemon management."); }) .finally(() => { setIsUpdatingDaemonManagement(false); }); }) .catch((error) => { console.error("[Settings] Failed to open built-in daemon pause confirmation", error); Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); }, [ daemonStatus, isUpdatingDaemonManagement, loadDaemonData, settings.manageBuiltInDaemon, updateSettings, ]); const handleCopyLogPath = useCallback(() => { const logPath = daemonLogs?.logPath; if (!logPath) { return; } void Clipboard.setStringAsync(logPath) .then(() => { Alert.alert("Copied", "Log path copied."); }) .catch((error) => { console.error("[Settings] Failed to copy log path", error); Alert.alert("Error", "Unable to copy log path."); }); }, [daemonLogs?.logPath]); const handleOpenLogs = useCallback(() => { if (!daemonLogs) { return; } setIsLogsModalOpen(true); }, [daemonLogs]); const handleOpenCliStatus = useCallback(async () => { setIsLoadingCliStatus(true); try { setCliStatusOutput(await getCliDaemonStatus()); setIsCliStatusModalOpen(true); } catch (error) { const message = error instanceof Error ? error.message : String(error); setCliStatusOutput(`Failed to fetch daemon status: ${message}`); setIsCliStatusModalOpen(true); } finally { setIsLoadingCliStatus(false); } }, []); const handleCopyCliStatus = useCallback(() => { if (!cliStatusOutput) { return; } void Clipboard.setStringAsync(cliStatusOutput) .then(() => { Alert.alert("Copied", "Status copied to clipboard."); }) .catch((error) => { console.error("[Settings] Failed to copy daemon status", error); }); }, [cliStatusOutput]); if (!showSection) { return null; } return ( Built-in daemon Status Only the built-in desktop daemon is shown here. {daemonStatusStateText} {daemonStatusDetailText} {showLifecycleControls ? ( <> Daemon management {isDaemonManagementPaused ? "Paused. The built-in daemon stays stopped until you start it again." : "Enabled. Paseo can manage the built-in daemon from the desktop app."} {daemonActionLabel} {daemonActionMessage} {statusMessage ? {statusMessage} : null} ) : null} Log file {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( ) : null} Full status Runs `paseo daemon status` and shows the output. {daemonVersionMismatch ? ( App and daemon versions don't match. Update both to the same version for the best experience. ) : null} setIsLogsModalOpen(false)} title="Daemon logs" testID="managed-daemon-logs-dialog" snapPoints={["70%", "92%"]} > {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} setIsCliStatusModalOpen(false)} title="Daemon status" testID="daemon-cli-status-dialog" snapPoints={["60%", "85%"]} > {cliStatusOutput ?? ""} ); } const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; const styles = StyleSheet.create((theme) => ({ actionGroup: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", justifyContent: "flex-end", }, statusValueGroup: { alignItems: "flex-end", gap: 2, }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, valueSubtext: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, statusText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, warningCard: { marginTop: theme.spacing[3], borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.palette.amber[500], backgroundColor: "rgba(245, 158, 11, 0.12)", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], }, warningText: { color: theme.colors.palette.amber[500], fontSize: theme.fontSize.xs, }, modalBody: { gap: theme.spacing[3], paddingBottom: theme.spacing[2], }, logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, }, codeBlock: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface0, padding: theme.spacing[3], }, modalActions: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], }, }));