import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as QRCode from "qrcode"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; import { ArrowUpRight } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; import { formatVersionWithPrefix, isVersionMismatch, } from "@/desktop/updates/desktop-updates"; import { getManagedDaemonLogs, getManagedDaemonPairing, getManagedDaemonStatus, installManagedCliShim, restartManagedDaemon, shouldUseManagedDesktopDaemon, uninstallManagedCliShim, type ManagedDaemonLogs, type ManagedPairingOffer, type ManagedDaemonStatus, type CliManualInstructions, } from "@/desktop/managed-runtime/managed-runtime"; export interface LocalDaemonSectionProps { appVersion: string | null; } export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { const showSection = shouldUseManagedDesktopDaemon(); const [managedStatus, setManagedStatus] = useState(null); const [statusError, setStatusError] = useState(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isInstallingCli, setIsInstallingCli] = useState(false); const [statusMessage, setStatusMessage] = useState(null); const [cliStatusMessage, setCliStatusMessage] = useState(null); const [managedLogs, setManagedLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false); const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [pairingOffer, setPairingOffer] = useState(null); const [cliInstallInstructions, setCliInstallInstructions] = useState( null ); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const loadManagedStatus = useCallback(() => { if (!showSection) { return Promise.resolve(); } return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) .then(([status, logs]) => { setManagedStatus(status); setManagedLogs(logs); setStatusError(null); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); setStatusError(message); }); }, [showSection]); useFocusEffect( useCallback(() => { if (!showSection) { return undefined; } void loadManagedStatus(); return undefined; }, [loadManagedStatus, showSection]) ); const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null); const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null); const daemonVersionHint = statusError ?? (managedStatus?.daemonRunning ? "Running." : "Not running."); const handleUpdateLocalDaemon = useCallback(() => { if (!showSection) { return; } if (isRestartingDaemon) { return; } void confirmDialog({ title: "Restart daemon", message: "This will restart the built-in daemon. The app will reconnect automatically.", confirmLabel: "Restart", cancelLabel: "Cancel", }) .then((confirmed) => { if (!confirmed) { return; } setIsRestartingDaemon(true); setStatusMessage(null); void restartManagedDaemon() .then((status) => { setManagedStatus(status); setStatusMessage("Daemon restarted."); return loadManagedStatus(); }) .catch((error) => { console.error("[Settings] Failed to restart managed daemon", error); const message = error instanceof Error ? error.message : String(error); setStatusMessage(`Restart failed: ${message}`); }) .finally(() => { setIsRestartingDaemon(false); }); }) .catch((error) => { console.error("[Settings] Failed to open managed daemon restart confirmation", error); Alert.alert("Error", "Unable to open the restart confirmation dialog."); }); }, [isRestartingDaemon, loadManagedStatus, showSection]); const handleToggleCliShim = useCallback(() => { if (!showSection || isInstallingCli) { return; } setIsInstallingCli(true); const isInstalling = !managedStatus?.cliShimPath; setCliStatusMessage( isInstalling ? "A permissions popup may appear while Paseo installs the CLI globally." : null ); const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim; void action() .then((result) => { setCliStatusMessage(result.message); if (result.manualInstructions) { setCliInstallInstructions(result.manualInstructions); setIsCliInstallModalOpen(true); } else { setCliInstallInstructions(null); setIsCliInstallModalOpen(false); } return loadManagedStatus(); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); setCliStatusMessage(`CLI install failed: ${message}`); }) .finally(() => { setIsInstallingCli(false); }); }, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]); const handleCopyCliInstallCommands = useCallback(() => { if (!cliInstallInstructions?.commands) { return; } void Clipboard.setStringAsync(cliInstallInstructions.commands) .then(() => { Alert.alert("Copied", "CLI install commands copied."); }) .catch((error) => { console.error("[Settings] Failed to copy CLI install commands", error); Alert.alert("Error", "Unable to copy CLI install commands."); }); }, [cliInstallInstructions?.commands]); const handleCopyLogPath = useCallback(() => { const logPath = managedLogs?.logPath ?? managedStatus?.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."); }); }, [managedLogs?.logPath, managedStatus?.logPath]); const handleOpenLogs = useCallback(() => { if (!managedLogs) { return; } setIsLogsModalOpen(true); }, [managedLogs]); const handleOpenPairingModal = useCallback(() => { if (isLoadingPairing) { return; } setIsPairingModalOpen(true); setIsLoadingPairing(true); setPairingStatusMessage(null); void getManagedDaemonPairing() .then((pairing) => { setPairingOffer(pairing); if (!pairing.relayEnabled || !pairing.url) { setPairingStatusMessage("Relay pairing is not available."); } }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); setPairingOffer(null); setPairingStatusMessage(`Unable to load pairing offer: ${message}`); }) .finally(() => { setIsLoadingPairing(false); }); }, [isLoadingPairing]); const handleCopyPairingLink = useCallback(() => { if (!pairingOffer?.url) { return; } void Clipboard.setStringAsync(pairingOffer.url) .then(() => { Alert.alert("Copied", "Pairing link copied."); }) .catch((error) => { console.error("[Settings] Failed to copy pairing link", error); Alert.alert("Error", "Unable to copy pairing link."); }); }, [pairingOffer?.url]); if (!showSection) { return null; } return ( Built-in daemon void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)} style={styles.sectionLink} > Advanced settings Version {daemonVersionHint} {localDaemonVersionText} Restart daemon Restarts the built-in daemon. {statusMessage ? ( {statusMessage} ) : null} Command line (CLI) Adds the `paseo` command to your terminal. {cliStatusMessage ? {cliStatusMessage} : null} Log file {managedLogs?.logPath ?? managedStatus?.logPath ?? "Log path unavailable."} {(managedLogs?.logPath ?? managedStatus?.logPath) ? ( ) : null} Pair device Connect your phone to this computer. {daemonVersionMismatch ? ( App and daemon versions don't match. Update both to the same version for the best experience. ) : null} setIsCliInstallModalOpen(false)} title="Install CLI manually" testID="managed-daemon-cli-install-dialog" > A permissions popup should appear when Paseo installs the CLI globally. If it does not complete, open a terminal and run the commands below. {cliInstallInstructions?.detail ? ( {cliInstallInstructions.detail} ) : null} {cliInstallInstructions?.commands ?? ""} setIsPairingModalOpen(false)} title="Pair device" testID="managed-daemon-pairing-dialog" > setIsLogsModalOpen(false)} title="Daemon logs" testID="managed-daemon-logs-dialog" snapPoints={["70%", "92%"]} > {managedLogs?.logPath ?? managedStatus?.logPath ?? "Log path unavailable."} {managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"} ); } const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; function PairingOfferDialogContent(input: { isLoading: boolean; pairingOffer: ManagedPairingOffer | null; statusMessage: string | null; onCopyLink: () => void; }) { const { isLoading, pairingOffer, statusMessage, onCopyLink } = input; const [qrDataUrl, setQrDataUrl] = useState(null); const [qrError, setQrError] = useState(null); useEffect(() => { let cancelled = false; if (!pairingOffer?.url) { setQrDataUrl(null); setQrError(null); return () => { cancelled = true; }; } setQrError(null); setQrDataUrl(null); void QRCode.toDataURL(pairingOffer.url, { errorCorrectionLevel: "M", margin: 1, width: 320, }) .then((dataUrl) => { if (cancelled) { return; } setQrDataUrl(dataUrl); }) .catch((error) => { if (cancelled) { return; } setQrError(error instanceof Error ? error.message : String(error)); }); return () => { cancelled = true; }; }, [pairingOffer?.url]); if (isLoading) { return ( Loading pairing offer… ); } if (statusMessage) { return ( {statusMessage} ); } if (!pairingOffer?.url) { return ( Pairing offer unavailable. ); } return ( Scan this QR code in Paseo, or copy the pairing link below. {qrDataUrl ? ( ) : qrError ? ( QR unavailable: {qrError} ) : ( )} Pairing link {pairingOffer.url} ); } const styles = StyleSheet.create((theme) => ({ section: { marginBottom: theme.spacing[6], }, sectionHeader: { alignItems: "center", flexDirection: "row", justifyContent: "space-between", marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, sectionTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, sectionLink: { alignItems: "center", flexDirection: "row", gap: theme.spacing[1], }, sectionLinkText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, card: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden", }, row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, rowBorder: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, rowContent: { flex: 1, marginRight: theme.spacing[3], }, actionGroup: { flexDirection: "row", gap: theme.spacing[2], }, rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, hintText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: 2, }, 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], }, pairingState: { alignItems: "center", justifyContent: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[6], }, qrCard: { alignItems: "center", justifyContent: "center", alignSelf: "center", minHeight: 220, minWidth: 220, padding: theme.spacing[4], borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface0, }, qrImage: { width: 220, height: 220, }, linkLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, linkText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18, }, 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], }, }));