Add managed desktop daemon runtime support
This commit is contained in:
@@ -1,64 +1,104 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
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 } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
buildDaemonUpdateDiagnostics,
|
||||
formatVersionWithPrefix,
|
||||
getLocalDaemonVersion,
|
||||
isVersionMismatch,
|
||||
runLocalDaemonUpdate,
|
||||
shouldShowDesktopUpdateSection,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getManagedDaemonLogs,
|
||||
getManagedDaemonPairing,
|
||||
getManagedDaemonStatus,
|
||||
installManagedCliShim,
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
uninstallManagedCliShim,
|
||||
updateManagedDaemonTcpSettings,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
type ManagedDaemonStatus,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null;
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const showSection = shouldShowDesktopUpdateSection();
|
||||
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
|
||||
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
|
||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const showSection = shouldUseManagedDesktopDaemon();
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
|
||||
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
|
||||
|
||||
const loadManagedStatus = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setManagedStatus(status);
|
||||
setManagedLogs(logs);
|
||||
setStatusError(null);
|
||||
const [tcpHost, tcpPort] = splitTcpListen(status.tcpListen);
|
||||
setTcpHostInput(tcpHost);
|
||||
setTcpPortInput(tcpPort);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusError(message);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
void getLocalDaemonVersion().then((result) => {
|
||||
setLocalDaemonVersion(result.version);
|
||||
setLocalDaemonVersionError(result.error);
|
||||
});
|
||||
void loadManagedStatus();
|
||||
return undefined;
|
||||
}, [showSection])
|
||||
}, [loadManagedStatus, showSection])
|
||||
);
|
||||
|
||||
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
|
||||
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionHint =
|
||||
statusError ??
|
||||
(managedStatus?.daemonRunning
|
||||
? managedStatus.transportType === "tcp"
|
||||
? `Managed daemon running on explicit TCP ${managedStatus.transportPath}.`
|
||||
: `Managed daemon running on private ${managedStatus.transportType}.`
|
||||
: "Managed daemon is currently stopped.");
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return;
|
||||
}
|
||||
if (isUpdatingLocalDaemon) {
|
||||
if (isRestartingDaemon) {
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Update local daemon",
|
||||
title: "Restart managed daemon",
|
||||
message:
|
||||
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
||||
confirmLabel: "Update daemon",
|
||||
"This restarts the desktop-managed daemon using its private managed home and socket.",
|
||||
confirmLabel: "Restart daemon",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
@@ -66,71 +106,201 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingLocalDaemon(true);
|
||||
setLocalDaemonUpdateMessage(null);
|
||||
setLocalDaemonUpdateDiagnostics(null);
|
||||
setIsRestartingDaemon(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
void runLocalDaemonUpdate()
|
||||
.then((result) => {
|
||||
const diagnostics = buildDaemonUpdateDiagnostics(result);
|
||||
if (result.exitCode !== 0) {
|
||||
setLocalDaemonUpdateMessage(
|
||||
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
|
||||
);
|
||||
setLocalDaemonUpdateDiagnostics(diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalDaemonUpdateMessage(
|
||||
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
|
||||
);
|
||||
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
|
||||
setLocalDaemonUpdateDiagnostics(diagnostics);
|
||||
}
|
||||
|
||||
void getLocalDaemonVersion().then((versionResult) => {
|
||||
setLocalDaemonVersion(versionResult.version);
|
||||
setLocalDaemonVersionError(versionResult.error);
|
||||
});
|
||||
void restartManagedDaemon()
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setStatusMessage("Managed daemon restarted.");
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update local daemon", error);
|
||||
console.error("[Settings] Failed to restart managed daemon", error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLocalDaemonUpdateMessage(
|
||||
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
|
||||
);
|
||||
setLocalDaemonUpdateDiagnostics(
|
||||
buildDaemonUpdateDiagnostics({
|
||||
exitCode: -1,
|
||||
stdout: "",
|
||||
stderr: message,
|
||||
})
|
||||
);
|
||||
setStatusMessage(`Managed daemon restart failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingLocalDaemon(false);
|
||||
setIsRestartingDaemon(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open daemon update confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
||||
console.error("[Settings] Failed to open managed daemon restart confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the managed daemon restart confirmation dialog.");
|
||||
});
|
||||
}, [isUpdatingLocalDaemon, showSection]);
|
||||
}, [isRestartingDaemon, loadManagedStatus, showSection]);
|
||||
|
||||
const handleCopyDaemonDiagnostics = useCallback(() => {
|
||||
if (!localDaemonUpdateDiagnostics) {
|
||||
const handleToggleCliShim = useCallback(() => {
|
||||
if (!showSection || isInstallingCli) {
|
||||
return;
|
||||
}
|
||||
setIsInstallingCli(true);
|
||||
setStatusMessage(null);
|
||||
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
|
||||
void action()
|
||||
.then((result) => {
|
||||
setStatusMessage(result.message);
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`CLI shim action failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingCli(false);
|
||||
});
|
||||
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
||||
Alert.alert("Copied", "Managed daemon log path copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
||||
Alert.alert("Error", "Unable to copy diagnostics.");
|
||||
console.error("[Settings] Failed to copy managed daemon log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
});
|
||||
}, [localDaemonUpdateDiagnostics]);
|
||||
}, [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 disabled for this managed daemon.");
|
||||
}
|
||||
})
|
||||
.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]);
|
||||
|
||||
const handleOpenTcpModal = useCallback(() => {
|
||||
if (!managedStatus) {
|
||||
return;
|
||||
}
|
||||
const [tcpHost, tcpPort] = splitTcpListen(managedStatus.tcpListen);
|
||||
setTcpHostInput(tcpHost);
|
||||
setTcpPortInput(tcpPort);
|
||||
setIsTcpModalOpen(true);
|
||||
}, [managedStatus]);
|
||||
|
||||
const handleDisableTcp = useCallback(() => {
|
||||
if (isSavingTcpSettings) {
|
||||
return;
|
||||
}
|
||||
setIsSavingTcpSettings(true);
|
||||
setStatusMessage(null);
|
||||
void updateManagedDaemonTcpSettings({
|
||||
enabled: false,
|
||||
host: tcpHostInput.trim() || DEFAULT_TCP_HOST,
|
||||
port: parseTcpPort(tcpPortInput) ?? DEFAULT_TCP_PORT,
|
||||
})
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setStatusMessage("Managed TCP exposure disabled.");
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`Managed TCP update failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingTcpSettings(false);
|
||||
});
|
||||
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
|
||||
|
||||
const handleSaveTcp = useCallback(() => {
|
||||
if (isSavingTcpSettings) {
|
||||
return;
|
||||
}
|
||||
const host = tcpHostInput.trim();
|
||||
const port = parseTcpPort(tcpPortInput);
|
||||
if (!host) {
|
||||
Alert.alert("Host required", "Enter a TCP bind host.");
|
||||
return;
|
||||
}
|
||||
if (port == null || port <= 0) {
|
||||
Alert.alert("Port required", "Enter a valid TCP port.");
|
||||
return;
|
||||
}
|
||||
if (port === 6767) {
|
||||
Alert.alert("Port unavailable", "Managed TCP mode must not claim 127.0.0.1:6767.");
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Enable managed TCP",
|
||||
message:
|
||||
"This exposes the managed daemon on a TCP listener. Relay remains available, but network exposure is no longer private to the desktop app.",
|
||||
confirmLabel: "Enable TCP",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setIsSavingTcpSettings(true);
|
||||
setStatusMessage(null);
|
||||
void updateManagedDaemonTcpSettings({ enabled: true, host, port })
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setIsTcpModalOpen(false);
|
||||
setStatusMessage(`Managed TCP exposure enabled on ${host}:${port}.`);
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`Managed TCP update failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingTcpSettings(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open managed TCP confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the managed TCP confirmation dialog.");
|
||||
});
|
||||
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
@@ -138,7 +308,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Local daemon</Text>
|
||||
<Text style={styles.sectionTitle}>Managed daemon</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
@@ -149,47 +319,309 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Update daemon</Text>
|
||||
<Text style={styles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Updates the daemon on this computer only. Requires a restart.
|
||||
Restarts the desktop-managed daemon without touching `~/.paseo` or `127.0.0.1:6767`.
|
||||
</Text>
|
||||
{localDaemonUpdateMessage ? (
|
||||
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
|
||||
{statusMessage ? (
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isUpdatingLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
||||
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>CLI shim</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Installs `paseo` into your user path and points it at the managed daemon by default.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleToggleCliShim}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli
|
||||
? "Working..."
|
||||
: managedStatus?.cliShimPath
|
||||
? "Uninstall CLI"
|
||||
: "Install CLI"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Managed daemon log path unavailable."}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||
<Button variant="secondary" size="sm" onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!managedLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Show the managed daemon QR code and a copyable pairing link for the mobile app.
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="secondary" size="sm" onPress={handleOpenPairingModal}>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Advanced TCP</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Default off. Use only if you explicitly want a network listener instead of the
|
||||
private managed transport.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{managedStatus?.tcpEnabled
|
||||
? `Enabled on ${managedStatus.tcpListen ?? managedStatus.transportPath}`
|
||||
: "Disabled"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{managedStatus?.tcpEnabled ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleDisableTcp}
|
||||
disabled={isSavingTcpSettings}
|
||||
>
|
||||
{isSavingTcpSettings ? "Working..." : "Disable TCP"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={handleOpenTcpModal}
|
||||
disabled={isSavingTcpSettings}
|
||||
>
|
||||
{managedStatus?.tcpEnabled ? "Edit TCP" : "Enable TCP"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
Desktop app and local daemon versions differ. Keep both on the same version to avoid
|
||||
Desktop app and managed daemon versions differ. Keep both on the same version to avoid
|
||||
stability issues or breaking changes.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{localDaemonUpdateDiagnostics ? (
|
||||
<View style={styles.diagnosticsCard}>
|
||||
<View style={styles.diagnosticsHeader}>
|
||||
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
|
||||
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
||||
Copy output
|
||||
<AdaptiveModalSheet
|
||||
visible={isTcpModalOpen}
|
||||
onClose={() => setIsTcpModalOpen(false)}
|
||||
title="Managed TCP settings"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
TCP is advanced opt-in only. It must stay off unless you explicitly need direct network
|
||||
access, and it must never use port 6767.
|
||||
</Text>
|
||||
<AdaptiveTextInput
|
||||
value={tcpHostInput}
|
||||
onChangeText={setTcpHostInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="127.0.0.1"
|
||||
style={styles.input}
|
||||
/>
|
||||
<AdaptiveTextInput
|
||||
value={tcpPortInput}
|
||||
onChangeText={setTcpPortInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="number-pad"
|
||||
placeholder={String(DEFAULT_TCP_PORT)}
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="secondary" size="sm" onPress={() => setIsTcpModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleSaveTcp} disabled={isSavingTcpSettings}>
|
||||
{isSavingTcpSettings ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.diagnosticsText} selectable>
|
||||
{localDaemonUpdateDiagnostics}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
title="Pair device"
|
||||
testID="managed-daemon-pairing-dialog"
|
||||
>
|
||||
<PairingOfferDialogContent
|
||||
isLoading={isLoadingPairing}
|
||||
pairingOffer={pairingOffer}
|
||||
statusMessage={pairingStatusMessage}
|
||||
onCopyLink={handleCopyPairingLink}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
title="Managed daemon logs"
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Managed daemon log path unavailable."}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_TCP_HOST = "127.0.0.1";
|
||||
const DEFAULT_TCP_PORT = 7771;
|
||||
|
||||
function splitTcpListen(value: string | null): [string, string] {
|
||||
if (!value) {
|
||||
return [DEFAULT_TCP_HOST, String(DEFAULT_TCP_PORT)];
|
||||
}
|
||||
const [host, port] = value.split(":");
|
||||
return [host || DEFAULT_TCP_HOST, port || String(DEFAULT_TCP_PORT)];
|
||||
}
|
||||
|
||||
function parseTcpPort(value: string): number | null {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
pairingOffer: ManagedPairingOffer | null;
|
||||
statusMessage: string | null;
|
||||
onCopyLink: () => void;
|
||||
}) {
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(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 (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="secondary" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -227,6 +659,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -259,28 +695,59 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.palette.amber[500],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
diagnosticsCard: {
|
||||
marginTop: theme.spacing[3],
|
||||
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.surface1,
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
diagnosticsHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
qrImage: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
},
|
||||
diagnosticsTitle: {
|
||||
linkLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
diagnosticsText: {
|
||||
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,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
export type ManagedRuntimeStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
bundledRuntimeRoot: string;
|
||||
installedRuntimeRoot: string;
|
||||
installed: boolean;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
diagnosticsRoot: string;
|
||||
stateFilePath: string;
|
||||
};
|
||||
|
||||
export type ManagedDaemonStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
daemonPid: number | null;
|
||||
daemonRunning: boolean;
|
||||
daemonStatus: string;
|
||||
logPath: string;
|
||||
serverId: string | null;
|
||||
hostname: string | null;
|
||||
relayEnabled: boolean;
|
||||
tcpEnabled: boolean;
|
||||
tcpListen: string | null;
|
||||
cliShimPath: string | null;
|
||||
};
|
||||
|
||||
export type ManagedDaemonLogs = {
|
||||
logPath: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export type ManagedPairingOffer = {
|
||||
relayEnabled: boolean;
|
||||
url: string | null;
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
export type CliShimResult = {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ManagedTcpSettings = {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
};
|
||||
|
||||
type LocalTransportEventPayload = {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed runtime status response.");
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
bundledRuntimeRoot: toStringOrNull(raw.bundledRuntimeRoot) ?? "",
|
||||
installedRuntimeRoot: toStringOrNull(raw.installedRuntimeRoot) ?? "",
|
||||
installed: raw.installed === true,
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
|
||||
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon status response.");
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
daemonPid: toNumberOrNull(raw.daemonPid),
|
||||
daemonRunning: raw.daemonRunning === true,
|
||||
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
serverId: toStringOrNull(raw.serverId),
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
tcpEnabled: raw.tcpEnabled === true,
|
||||
tcpListen: toStringOrNull(raw.tcpListen),
|
||||
cliShimPath: toStringOrNull(raw.cliShimPath),
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon logs response.");
|
||||
}
|
||||
return {
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
contents: typeof raw.contents === "string" ? raw.contents : "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon pairing response.");
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
url: toStringOrNull(raw.url),
|
||||
qr: toStringOrNull(raw.qr),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliShimResult(raw: unknown): CliShimResult {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected CLI shim response.");
|
||||
}
|
||||
return {
|
||||
installed: raw.installed === true,
|
||||
path: toStringOrNull(raw.path),
|
||||
message: toStringOrNull(raw.message) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseManagedDesktopDaemon(): boolean {
|
||||
return isTauriEnvironment() && getTauri() !== null;
|
||||
}
|
||||
|
||||
export async function ensureManagedRuntime(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime"));
|
||||
}
|
||||
|
||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
|
||||
}
|
||||
|
||||
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
|
||||
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
|
||||
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
|
||||
}
|
||||
|
||||
export async function installManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
|
||||
}
|
||||
|
||||
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
|
||||
}
|
||||
|
||||
export async function updateManagedDaemonTcpSettings(
|
||||
settings: ManagedTcpSettings
|
||||
): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(
|
||||
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
|
||||
);
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void;
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
|
||||
|
||||
export async function listenToLocalTransportEvents(
|
||||
handler: LocalTransportEventHandler
|
||||
): Promise<LocalTransportEventUnlisten> {
|
||||
const listen = getTauri()?.event?.listen;
|
||||
if (typeof listen !== "function") {
|
||||
throw new Error("Tauri event API is unavailable.");
|
||||
}
|
||||
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
handler({
|
||||
sessionId: toStringOrNull(payload.sessionId) ?? "",
|
||||
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
|
||||
text: toStringOrNull(payload.text),
|
||||
binaryBase64: toStringOrNull(payload.binaryBase64),
|
||||
code: toNumberOrNull(payload.code),
|
||||
reason: toStringOrNull(payload.reason),
|
||||
error: toStringOrNull(payload.error),
|
||||
});
|
||||
});
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
|
||||
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected local transport session response.");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
binaryBase64?: string;
|
||||
}): Promise<void> {
|
||||
await invokeDesktopCommand("send_local_daemon_transport_message", {
|
||||
sessionId: input.sessionId,
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
||||
}
|
||||
Reference in New Issue
Block a user