import { useCallback, useEffect, useState } from 'react' import { ActivityIndicator, Alert, Image, Text, View } from 'react-native' import * as Clipboard from 'expo-clipboard' import * as QRCode from 'qrcode' import { useFocusEffect } from '@react-navigation/native' import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { settingsStyles } from '@/styles/settings' import { ArrowUpRight, Play, Pause, RotateCw, Terminal, Copy, FileText, Smartphone, } 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 { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates' import { getCliShimStatus, getManagedDaemonLogs, getManagedDaemonPairing, getManagedDaemonStatus, installManagedCliShim, restartManagedDaemon, shouldUseManagedDesktopDaemon, startManagedDaemon, stopManagedDaemon, uninstallManagedCliShim, type ManagedDaemonLogs, type ManagedPairingOffer, type ManagedDaemonStatus, type CliManualInstructions, type CliShimStatus, } from '@/desktop/managed-runtime/managed-runtime' export interface LocalDaemonSectionProps { appVersion: string | null } export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { const { theme } = useUnistyles() const showSection = shouldUseManagedDesktopDaemon() const { settings, updateSettings } = useAppSettings() const [managedStatus, setManagedStatus] = useState(null) const [statusError, setStatusError] = useState(null) const [isRestartingDaemon, setIsRestartingDaemon] = useState(false) const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false) const [isInstallingCli, setIsInstallingCli] = useState(false) const [statusMessage, setStatusMessage] = useState(null) const [cliStatusMessage, setCliStatusMessage] = useState(null) const [managedLogs, setManagedLogs] = useState(null) const [cliShimStatus, setCliShimStatus] = 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(), getCliShimStatus()]) .then(([status, logs, shimStatus]) => { setManagedStatus(status) setManagedLogs(logs) setCliShimStatus(shimStatus) 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 daemonStatusStateText = statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running') const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}` const isDaemonManagementPaused = !settings.manageBuiltInDaemon const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon' const daemonActionMessage = managedStatus?.status === 'running' ? 'Restarts the built-in daemon.' : 'Starts the built-in daemon.' const handleUpdateLocalDaemon = useCallback(() => { if (!showSection) { return } if (isRestartingDaemon) { return } void confirmDialog({ title: daemonActionLabel, message: managedStatus?.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 = managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon void action() .then((status) => { setManagedStatus(status) setStatusMessage( managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.' ) return loadManagedStatus() }) .catch((error) => { console.error('[Settings] Failed to change managed 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 managed daemon action confirmation', error) Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') }) }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, 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 = managedStatus?.status === 'running' ? stopManagedDaemon() : Promise.resolve(managedStatus ?? null) void stopPromise .then(() => updateSettings({ manageBuiltInDaemon: false })) .then(() => loadManagedStatus()) .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.') }) }, [ isUpdatingDaemonManagement, loadManagedStatus, managedStatus, settings.manageBuiltInDaemon, updateSettings, ]) const handleToggleCliShim = useCallback(() => { if (!showSection || isInstallingCli) { return } setIsInstallingCli(true) const isInstalling = !cliShimStatus?.path setCliStatusMessage( isInstalling ? 'A permissions popup may appear while Paseo installs the CLI globally.' : null ) const action = cliShimStatus?.path ? 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) }) }, [cliShimStatus?.path, isInstallingCli, loadManagedStatus, 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 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]) 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 Status Only the built-in managed daemon is shown here. {daemonStatusStateText} {daemonStatusDetailText} 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} Command line (CLI) Adds the `paseo` command to your terminal. {cliStatusMessage ? {cliStatusMessage} : null} Log file {managedLogs?.logPath ?? 'Log path unavailable.'} {managedLogs?.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 ?? '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) => ({ sectionHeader: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, sectionLink: { alignItems: 'center', flexDirection: 'row', gap: theme.spacing[1], }, sectionLinkText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, 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], flexWrap: 'wrap', justifyContent: 'flex-end', }, statusValueGroup: { alignItems: 'flex-end', gap: 2, }, rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, valueSubtext: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, 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], }, }))