refactor(app): isolate desktop settings update and permission flows
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Check } from "lucide-react-native";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions";
|
||||||
|
|
||||||
|
export interface DesktopPermissionRowProps {
|
||||||
|
title: string;
|
||||||
|
status: DesktopPermissionStatus | null;
|
||||||
|
isRequesting: boolean;
|
||||||
|
showBorder?: boolean;
|
||||||
|
onRequest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopPermissionRow({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
isRequesting,
|
||||||
|
showBorder,
|
||||||
|
onRequest,
|
||||||
|
}: DesktopPermissionRowProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const state = status?.state ?? "unknown";
|
||||||
|
const isGranted = state === "granted";
|
||||||
|
const shouldShowDetail =
|
||||||
|
status !== null &&
|
||||||
|
status.detail.trim().length > 0 &&
|
||||||
|
state !== "granted" &&
|
||||||
|
state !== "prompt" &&
|
||||||
|
state !== "not-granted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permissionRowActions}>
|
||||||
|
{isGranted ? (
|
||||||
|
<View style={styles.permissionStatusPill}>
|
||||||
|
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||||
|
{isRequesting ? "Requesting..." : "Request"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{shouldShowDetail ? <Text style={styles.permissionDetailText}>{status?.detail}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
audioRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
audioRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
audioRowContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing[3],
|
||||||
|
},
|
||||||
|
audioRowTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
permissionRowActions: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
permissionStatusPill: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: 4,
|
||||||
|
minWidth: 88,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
permissionStatusText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
permissionDetailText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
maxWidth: 220,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { RotateCw } from "lucide-react-native";
|
||||||
|
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
||||||
|
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
|
||||||
|
|
||||||
|
export function DesktopPermissionsSection() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const {
|
||||||
|
isDesktop,
|
||||||
|
snapshot,
|
||||||
|
isRefreshing,
|
||||||
|
requestingPermission,
|
||||||
|
refreshPermissions,
|
||||||
|
requestPermission,
|
||||||
|
} = useDesktopPermissions();
|
||||||
|
|
||||||
|
if (!isDesktop) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = isRefreshing || requestingPermission !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.permissionSectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Desktop permissions</Text>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.permissionRefreshButton,
|
||||||
|
isBusy && styles.permissionRefreshButtonDisabled,
|
||||||
|
pressed && { opacity: 0.85 },
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
void refreshPermissions();
|
||||||
|
}}
|
||||||
|
disabled={isBusy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh desktop permissions"
|
||||||
|
>
|
||||||
|
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.audioCard}>
|
||||||
|
<DesktopPermissionRow
|
||||||
|
title="Notifications"
|
||||||
|
status={snapshot?.notifications ?? null}
|
||||||
|
isRequesting={requestingPermission === "notifications"}
|
||||||
|
onRequest={() => {
|
||||||
|
void requestPermission("notifications");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DesktopPermissionRow
|
||||||
|
title="Microphone"
|
||||||
|
showBorder
|
||||||
|
status={snapshot?.microphone ?? null}
|
||||||
|
isRequesting={requestingPermission === "microphone"}
|
||||||
|
onRequest={() => {
|
||||||
|
void requestPermission("microphone");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
section: {
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
marginBottom: 0,
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
|
},
|
||||||
|
permissionSectionHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
},
|
||||||
|
permissionRefreshButton: {
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
permissionRefreshButtonDisabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
audioCard: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Alert, Text, View } from "react-native";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
|
import {
|
||||||
|
buildDaemonUpdateDiagnostics,
|
||||||
|
findLikelyLocalDaemonHost,
|
||||||
|
formatVersionWithPrefix,
|
||||||
|
isVersionMismatch,
|
||||||
|
runLocalDaemonUpdate,
|
||||||
|
shouldShowDesktopUpdateSection,
|
||||||
|
} from "@/desktop/updates/desktop-updates";
|
||||||
|
|
||||||
|
export interface DesktopUpdatesSectionProps {
|
||||||
|
appVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps) {
|
||||||
|
const showDesktopUpdatesSection = shouldShowDesktopUpdateSection();
|
||||||
|
const { daemons } = useDaemonRegistry();
|
||||||
|
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
||||||
|
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
||||||
|
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
|
const localDaemonHost = useMemo(() => findLikelyLocalDaemonHost(daemons), [daemons]);
|
||||||
|
const localDaemonServerId = localDaemonHost?.serverId ?? null;
|
||||||
|
const localDaemonVersion = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) =>
|
||||||
|
localDaemonServerId
|
||||||
|
? (state.sessions[localDaemonServerId]?.serverInfo?.version ?? null)
|
||||||
|
: null,
|
||||||
|
[localDaemonServerId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
|
||||||
|
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDesktop: isDesktopUpdaterAvailable,
|
||||||
|
statusText: appUpdateStatusText,
|
||||||
|
availableUpdate,
|
||||||
|
errorMessage: appUpdateError,
|
||||||
|
isChecking: isCheckingAppUpdate,
|
||||||
|
isInstalling: isInstallingAppUpdate,
|
||||||
|
checkForUpdates,
|
||||||
|
installUpdate,
|
||||||
|
} = useDesktopAppUpdater();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkForUpdates({ silent: true });
|
||||||
|
return undefined;
|
||||||
|
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckForAppUpdates = useCallback(() => {
|
||||||
|
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void checkForUpdates();
|
||||||
|
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
|
||||||
|
|
||||||
|
const handleInstallAppUpdate = useCallback(() => {
|
||||||
|
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirmDialog({
|
||||||
|
title: "Install desktop update",
|
||||||
|
message: "This updates Paseo on this computer.",
|
||||||
|
confirmLabel: "Install update",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
})
|
||||||
|
.then((confirmed) => {
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void installUpdate();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to open app update confirmation", error);
|
||||||
|
Alert.alert("Error", "Unable to open the update confirmation dialog.");
|
||||||
|
});
|
||||||
|
}, [installUpdate, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
|
||||||
|
|
||||||
|
const handleUpdateLocalDaemon = useCallback(() => {
|
||||||
|
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUpdatingLocalDaemon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirmDialog({
|
||||||
|
title: "Update local daemon",
|
||||||
|
message:
|
||||||
|
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
||||||
|
confirmLabel: "Update daemon",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
})
|
||||||
|
.then((confirmed) => {
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingLocalDaemon(true);
|
||||||
|
setLocalDaemonUpdateMessage(null);
|
||||||
|
setLocalDaemonUpdateDiagnostics(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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to update local 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdatingLocalDaemon(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to open daemon update confirmation", error);
|
||||||
|
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
||||||
|
});
|
||||||
|
}, [isDesktopUpdaterAvailable, isUpdatingLocalDaemon, showDesktopUpdatesSection]);
|
||||||
|
|
||||||
|
const handleCopyDaemonDiagnostics = useCallback(() => {
|
||||||
|
if (!localDaemonUpdateDiagnostics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
||||||
|
.then(() => {
|
||||||
|
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
||||||
|
Alert.alert("Error", "Unable to copy diagnostics.");
|
||||||
|
});
|
||||||
|
}, [localDaemonUpdateDiagnostics]);
|
||||||
|
|
||||||
|
if (!showDesktopUpdatesSection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Updates</Text>
|
||||||
|
<View style={styles.audioCard}>
|
||||||
|
<View style={styles.audioRow}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>Desktop app version</Text>
|
||||||
|
<Text style={styles.updateHintText}>Paseo installed on this computer.</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>Local daemon version</Text>
|
||||||
|
<Text style={styles.updateHintText}>
|
||||||
|
{localDaemonHost
|
||||||
|
? `Connected host: ${localDaemonHost.label}`
|
||||||
|
: "No local daemon detected yet."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.aboutValue}>{localDaemonVersionText}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>App update status</Text>
|
||||||
|
<Text style={styles.updateStatusText}>{appUpdateStatusText}</Text>
|
||||||
|
{availableUpdate?.latestVersion ? (
|
||||||
|
<Text style={styles.updateHintText}>
|
||||||
|
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{appUpdateError ? <Text style={styles.updateErrorText}>{appUpdateError}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.updateActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleCheckForAppUpdates}
|
||||||
|
disabled={!isDesktopUpdaterAvailable || isCheckingAppUpdate || isInstallingAppUpdate}
|
||||||
|
>
|
||||||
|
{isCheckingAppUpdate ? "Checking..." : "Check for updates"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleInstallAppUpdate}
|
||||||
|
disabled={
|
||||||
|
!isDesktopUpdaterAvailable ||
|
||||||
|
isCheckingAppUpdate ||
|
||||||
|
isInstallingAppUpdate ||
|
||||||
|
!availableUpdate
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isInstallingAppUpdate
|
||||||
|
? "Installing..."
|
||||||
|
: availableUpdate?.latestVersion
|
||||||
|
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
|
||||||
|
: "Update app"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
||||||
|
<View style={styles.audioRowContent}>
|
||||||
|
<Text style={styles.audioRowTitle}>Update local daemon</Text>
|
||||||
|
<Text style={styles.updateHintText}>
|
||||||
|
Updates the daemon on this computer only. Requires a restart.
|
||||||
|
</Text>
|
||||||
|
{localDaemonUpdateMessage ? (
|
||||||
|
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleUpdateLocalDaemon}
|
||||||
|
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon}
|
||||||
|
>
|
||||||
|
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{daemonVersionMismatch ? (
|
||||||
|
<View style={styles.updateWarningCard}>
|
||||||
|
<Text style={styles.updateWarningText}>
|
||||||
|
Desktop app and local daemon versions differ. Keep both on the same version to avoid
|
||||||
|
stability issues or breaking changes.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{localDaemonUpdateDiagnostics ? (
|
||||||
|
<View style={styles.updateDiagnosticsCard}>
|
||||||
|
<View style={styles.updateDiagnosticsHeader}>
|
||||||
|
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text>
|
||||||
|
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
||||||
|
Copy output
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.updateDiagnosticsText} selectable>
|
||||||
|
{localDaemonUpdateDiagnostics}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
section: {
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
|
},
|
||||||
|
audioCard: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
audioRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
audioRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
audioRowContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing[3],
|
||||||
|
},
|
||||||
|
audioRowTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
aboutValue: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
updateHintText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
updateStatusText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
updateActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
updateErrorText: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
updateWarningCard: {
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
updateWarningText: {
|
||||||
|
color: theme.colors.palette.amber[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
updateDiagnosticsCard: {
|
||||||
|
marginTop: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
updateDiagnosticsHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
updateDiagnosticsTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
updateDiagnosticsText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
getDesktopPermissionSnapshot,
|
||||||
|
requestDesktopPermission,
|
||||||
|
shouldShowDesktopPermissionSection,
|
||||||
|
type DesktopPermissionKind,
|
||||||
|
type DesktopPermissionSnapshot,
|
||||||
|
} from "@/desktop/permissions/desktop-permissions";
|
||||||
|
|
||||||
|
export interface UseDesktopPermissionsReturn {
|
||||||
|
isDesktop: boolean;
|
||||||
|
snapshot: DesktopPermissionSnapshot | null;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
requestingPermission: DesktopPermissionKind | null;
|
||||||
|
refreshPermissions: () => Promise<void>;
|
||||||
|
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_NOTIFICATION_STATUS = {
|
||||||
|
state: "unknown" as const,
|
||||||
|
detail: "Notification status has not been checked yet.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_MICROPHONE_STATUS = {
|
||||||
|
state: "unknown" as const,
|
||||||
|
detail: "Microphone status has not been checked yet.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||||
|
const isDesktop = shouldShowDesktopPermissionSection();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPermissions = useCallback(async () => {
|
||||||
|
if (!isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const nextSnapshot = await getDesktopPermissionSnapshot();
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Settings] Failed to load desktop permission status", error);
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isDesktop]);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(
|
||||||
|
async (kind: DesktopPermissionKind) => {
|
||||||
|
if (!isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestingPermission(kind);
|
||||||
|
try {
|
||||||
|
const status = await requestDesktopPermission({ kind });
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot((previous) => {
|
||||||
|
const base: DesktopPermissionSnapshot =
|
||||||
|
previous ?? {
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
notifications: EMPTY_NOTIFICATION_STATUS,
|
||||||
|
microphone: EMPTY_MICROPHONE_STATUS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind === "notifications") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
notifications: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
microphone: status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Settings] Failed to request ${kind} permission`, error);
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setRequestingPermission(null);
|
||||||
|
}
|
||||||
|
await refreshPermissions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDesktop, refreshPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshPermissions();
|
||||||
|
}, [isDesktop, refreshPermissions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDesktop,
|
||||||
|
snapshot,
|
||||||
|
isRefreshing,
|
||||||
|
requestingPermission,
|
||||||
|
refreshPermissions,
|
||||||
|
requestPermission,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getTauri } from "@/utils/tauri";
|
||||||
|
|
||||||
|
export async function invokeDesktopCommand<T>(command: string): Promise<T> {
|
||||||
|
const invoke = getTauri()?.core?.invoke;
|
||||||
|
if (typeof invoke !== "function") {
|
||||||
|
throw new Error("Tauri invoke() is unavailable in this environment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await invoke(command)) as T;
|
||||||
|
}
|
||||||
+1
-9
@@ -1,6 +1,7 @@
|
|||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
import type { HostProfile } from '@/contexts/daemon-registry-context'
|
import type { HostProfile } from '@/contexts/daemon-registry-context'
|
||||||
import { getTauri } from '@/utils/tauri'
|
import { getTauri } from '@/utils/tauri'
|
||||||
|
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
||||||
|
|
||||||
export interface DesktopAppUpdateCheckResult {
|
export interface DesktopAppUpdateCheckResult {
|
||||||
hasUpdate: boolean
|
hasUpdate: boolean
|
||||||
@@ -43,15 +44,6 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
|
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invokeDesktopCommand<T>(command: string): Promise<T> {
|
|
||||||
const invoke = getTauri()?.core?.invoke
|
|
||||||
if (typeof invoke !== 'function') {
|
|
||||||
throw new Error('Tauri invoke() is unavailable in this environment.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await invoke(command)) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
|
function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
|
||||||
const normalized = endpoint.trim().toLowerCase()
|
const normalized = endpoint.trim().toLowerCase()
|
||||||
return (
|
return (
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
shouldShowDesktopUpdateSection,
|
shouldShowDesktopUpdateSection,
|
||||||
type DesktopAppUpdateCheckResult,
|
type DesktopAppUpdateCheckResult,
|
||||||
type DesktopAppUpdateInstallResult,
|
type DesktopAppUpdateInstallResult,
|
||||||
} from '@/utils/desktop-updates'
|
} from '@/desktop/updates/desktop-updates'
|
||||||
|
|
||||||
export type DesktopAppUpdateStatus =
|
export type DesktopAppUpdateStatus =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Clipboard from "expo-clipboard";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
|
||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
|
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
@@ -39,23 +37,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { useDesktopAppUpdater } from "@/hooks/use-desktop-app-updater";
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
import {
|
import { DesktopUpdatesSection } from "@/desktop/components/desktop-updates-section";
|
||||||
buildDaemonUpdateDiagnostics,
|
|
||||||
findLikelyLocalDaemonHost,
|
|
||||||
formatVersionWithPrefix,
|
|
||||||
isVersionMismatch,
|
|
||||||
runLocalDaemonUpdate,
|
|
||||||
shouldShowDesktopUpdateSection,
|
|
||||||
} from "@/utils/desktop-updates";
|
|
||||||
import {
|
|
||||||
getDesktopPermissionSnapshot,
|
|
||||||
requestDesktopPermission,
|
|
||||||
shouldShowDesktopPermissionSection,
|
|
||||||
type DesktopPermissionKind,
|
|
||||||
type DesktopPermissionSnapshot,
|
|
||||||
type DesktopPermissionStatus,
|
|
||||||
} from "@/utils/desktop-permissions";
|
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
@@ -331,119 +314,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
permissionSectionHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
},
|
|
||||||
permissionRefreshButton: {
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
permissionRefreshButtonDisabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
permissionRowActions: {
|
|
||||||
alignItems: "flex-end",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
permissionStatusPill: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: 4,
|
|
||||||
minWidth: 88,
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
permissionStatusText: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
permissionDetailText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
maxWidth: 220,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
aboutValue: {
|
aboutValue: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
updateHintText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
updateStatusText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
updateActions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
updateErrorText: {
|
|
||||||
color: theme.colors.palette.red[300],
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
updateWarningCard: {
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
updateWarningText: {
|
|
||||||
color: theme.colors.palette.amber[500],
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
updateDiagnosticsCard: {
|
|
||||||
marginTop: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
updateDiagnosticsHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
updateDiagnosticsTitle: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
updateDiagnosticsText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontFamily: Platform.select({
|
|
||||||
web: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
||||||
default: "Courier",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// Empty state
|
// Empty state
|
||||||
emptyCard: {
|
emptyCard: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -516,6 +391,19 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
|||||||
return `v${daemonVersion}`;
|
return `v${daemonVersion}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVersionForDisplay(version: string | null | undefined): string {
|
||||||
|
const value = version?.trim();
|
||||||
|
if (!value) {
|
||||||
|
return "\u2014";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("v")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `v${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -539,43 +427,12 @@ export default function SettingsScreen() {
|
|||||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
|
|
||||||
useState<DesktopPermissionSnapshot | null>(null);
|
|
||||||
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
|
|
||||||
useState(false);
|
|
||||||
const [requestingDesktopPermission, setRequestingDesktopPermission] =
|
|
||||||
useState<DesktopPermissionKind | null>(null);
|
|
||||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
|
||||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
|
||||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(null);
|
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading || daemonLoading;
|
||||||
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
|
|
||||||
const showDesktopUpdatesSection = shouldShowDesktopUpdateSection();
|
|
||||||
const {
|
|
||||||
isDesktop: isDesktopUpdaterAvailable,
|
|
||||||
statusText: appUpdateStatusText,
|
|
||||||
availableUpdate,
|
|
||||||
errorMessage: appUpdateError,
|
|
||||||
isChecking: isCheckingAppUpdate,
|
|
||||||
isInstalling: isInstallingAppUpdate,
|
|
||||||
checkForUpdates,
|
|
||||||
installUpdate,
|
|
||||||
} = useDesktopAppUpdater();
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
|
const isDesktop = Platform.OS === "web";
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionForDisplay(appVersion);
|
||||||
const localDaemonHost = useMemo(() => findLikelyLocalDaemonHost(daemons), [daemons]);
|
|
||||||
const localDaemonServerId = localDaemonHost?.serverId ?? null;
|
|
||||||
const localDaemonVersion = useSessionStore(
|
|
||||||
useCallback(
|
|
||||||
(state) =>
|
|
||||||
localDaemonServerId ? (state.sessions[localDaemonServerId]?.serverInfo?.version ?? null) : null,
|
|
||||||
[localDaemonServerId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
|
|
||||||
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
|
|
||||||
const editingServerId = editingDaemon?.serverId ?? null;
|
const editingServerId = editingDaemon?.serverId ?? null;
|
||||||
const editingDaemonLive = editingServerId
|
const editingDaemonLive = editingServerId
|
||||||
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
||||||
@@ -670,198 +527,6 @@ export default function SettingsScreen() {
|
|||||||
pendingEditReopenServerId,
|
pendingEditReopenServerId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const refreshDesktopPermissions = useCallback(async () => {
|
|
||||||
if (!showDesktopPermissionSection) return;
|
|
||||||
|
|
||||||
setIsRefreshingDesktopPermissions(true);
|
|
||||||
try {
|
|
||||||
const snapshot = await getDesktopPermissionSnapshot();
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
setDesktopPermissionSnapshot(snapshot);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Settings] Failed to load desktop permission status", error);
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setIsRefreshingDesktopPermissions(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [showDesktopPermissionSection]);
|
|
||||||
|
|
||||||
const handleRequestDesktopPermission = useCallback(
|
|
||||||
async (kind: DesktopPermissionKind) => {
|
|
||||||
if (!showDesktopPermissionSection) return;
|
|
||||||
|
|
||||||
setRequestingDesktopPermission(kind);
|
|
||||||
try {
|
|
||||||
const status = await requestDesktopPermission({ kind });
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
setDesktopPermissionSnapshot((previous) => {
|
|
||||||
const base: DesktopPermissionSnapshot = previous ?? {
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
notifications: {
|
|
||||||
state: "unknown",
|
|
||||||
detail: "Notification status has not been checked yet.",
|
|
||||||
},
|
|
||||||
microphone: {
|
|
||||||
state: "unknown",
|
|
||||||
detail: "Microphone status has not been checked yet.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return kind === "notifications"
|
|
||||||
? {
|
|
||||||
...base,
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
notifications: status,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...base,
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
microphone: status,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[Settings] Failed to request ${kind} permission`, error);
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setRequestingDesktopPermission(null);
|
|
||||||
}
|
|
||||||
await refreshDesktopPermissions();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refreshDesktopPermissions, showDesktopPermissionSection]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showDesktopPermissionSection) return;
|
|
||||||
void refreshDesktopPermissions();
|
|
||||||
}, [refreshDesktopPermissions, showDesktopPermissionSection]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
void checkForUpdates({ silent: true });
|
|
||||||
return undefined;
|
|
||||||
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection])
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheckForAppUpdates = useCallback(() => {
|
|
||||||
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void checkForUpdates();
|
|
||||||
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
|
|
||||||
|
|
||||||
const handleInstallAppUpdate = useCallback(() => {
|
|
||||||
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmDialog({
|
|
||||||
title: "Install desktop update",
|
|
||||||
message: "This updates Paseo on this computer.",
|
|
||||||
confirmLabel: "Install update",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
})
|
|
||||||
.then((confirmed) => {
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void installUpdate();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to open app update confirmation", error);
|
|
||||||
Alert.alert("Error", "Unable to open the update confirmation dialog.");
|
|
||||||
});
|
|
||||||
}, [installUpdate, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
|
|
||||||
|
|
||||||
const handleUpdateLocalDaemon = useCallback(() => {
|
|
||||||
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isUpdatingLocalDaemon) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmDialog({
|
|
||||||
title: "Update local daemon",
|
|
||||||
message: "This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
|
||||||
confirmLabel: "Update daemon",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
})
|
|
||||||
.then((confirmed) => {
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUpdatingLocalDaemon(true);
|
|
||||||
setLocalDaemonUpdateMessage(null);
|
|
||||||
setLocalDaemonUpdateDiagnostics(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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to update local 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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsUpdatingLocalDaemon(false);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to open daemon update confirmation", error);
|
|
||||||
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
isDesktopUpdaterAvailable,
|
|
||||||
isUpdatingLocalDaemon,
|
|
||||||
showDesktopUpdatesSection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCopyDaemonDiagnostics = useCallback(() => {
|
|
||||||
if (!localDaemonUpdateDiagnostics) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
|
||||||
.then(() => {
|
|
||||||
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
|
||||||
Alert.alert("Error", "Unable to copy diagnostics.");
|
|
||||||
});
|
|
||||||
}, [localDaemonUpdateDiagnostics]);
|
|
||||||
|
|
||||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||||
if (!editingServerId) return;
|
if (!editingServerId) return;
|
||||||
if (isSavingEdit) return;
|
if (isSavingEdit) return;
|
||||||
@@ -1125,167 +790,8 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showDesktopPermissionSection ? (
|
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||||
<View style={styles.section}>
|
{isDesktop ? <DesktopUpdatesSection appVersion={appVersion} /> : null}
|
||||||
<View style={styles.permissionSectionHeader}>
|
|
||||||
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
|
|
||||||
Desktop permissions
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.permissionRefreshButton,
|
|
||||||
(isRefreshingDesktopPermissions ||
|
|
||||||
requestingDesktopPermission !== null) &&
|
|
||||||
styles.permissionRefreshButtonDisabled,
|
|
||||||
pressed && { opacity: 0.85 },
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
void refreshDesktopPermissions();
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
isRefreshingDesktopPermissions ||
|
|
||||||
requestingDesktopPermission !== null
|
|
||||||
}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Refresh desktop permissions"
|
|
||||||
>
|
|
||||||
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<View style={styles.audioCard}>
|
|
||||||
<DesktopPermissionRow
|
|
||||||
title="Notifications"
|
|
||||||
status={desktopPermissionSnapshot?.notifications ?? null}
|
|
||||||
isRequesting={requestingDesktopPermission === "notifications"}
|
|
||||||
onRequest={() => {
|
|
||||||
void handleRequestDesktopPermission("notifications");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DesktopPermissionRow
|
|
||||||
title="Microphone"
|
|
||||||
showBorder
|
|
||||||
status={desktopPermissionSnapshot?.microphone ?? null}
|
|
||||||
isRequesting={requestingDesktopPermission === "microphone"}
|
|
||||||
onRequest={() => {
|
|
||||||
void handleRequestDesktopPermission("microphone");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showDesktopUpdatesSection ? (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Updates</Text>
|
|
||||||
<View style={styles.audioCard}>
|
|
||||||
<View style={styles.audioRow}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Desktop app version</Text>
|
|
||||||
<Text style={styles.updateHintText}>
|
|
||||||
Paseo installed on this computer.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Local daemon version</Text>
|
|
||||||
<Text style={styles.updateHintText}>
|
|
||||||
{localDaemonHost
|
|
||||||
? `Connected host: ${localDaemonHost.label}`
|
|
||||||
: "No local daemon detected yet."}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.aboutValue}>{localDaemonVersionText}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>App update status</Text>
|
|
||||||
<Text style={styles.updateStatusText}>{appUpdateStatusText}</Text>
|
|
||||||
{availableUpdate?.latestVersion ? (
|
|
||||||
<Text style={styles.updateHintText}>
|
|
||||||
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{appUpdateError ? (
|
|
||||||
<Text style={styles.updateErrorText}>
|
|
||||||
{appUpdateError}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.updateActions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleCheckForAppUpdates}
|
|
||||||
disabled={!isDesktopUpdaterAvailable || isCheckingAppUpdate || isInstallingAppUpdate}
|
|
||||||
>
|
|
||||||
{isCheckingAppUpdate ? "Checking..." : "Check for updates"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleInstallAppUpdate}
|
|
||||||
disabled={
|
|
||||||
!isDesktopUpdaterAvailable ||
|
|
||||||
isCheckingAppUpdate ||
|
|
||||||
isInstallingAppUpdate ||
|
|
||||||
!availableUpdate
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isInstallingAppUpdate
|
|
||||||
? "Installing..."
|
|
||||||
: availableUpdate?.latestVersion
|
|
||||||
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
|
|
||||||
: "Update app"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.audioRow, styles.audioRowBorder]}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>Update local daemon</Text>
|
|
||||||
<Text style={styles.updateHintText}>
|
|
||||||
Updates the daemon on this computer only. Requires a restart.
|
|
||||||
</Text>
|
|
||||||
{localDaemonUpdateMessage ? (
|
|
||||||
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleUpdateLocalDaemon}
|
|
||||||
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon}
|
|
||||||
>
|
|
||||||
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{daemonVersionMismatch ? (
|
|
||||||
<View style={styles.updateWarningCard}>
|
|
||||||
<Text style={styles.updateWarningText}>
|
|
||||||
Desktop app and local daemon versions differ. Keep both on the same version to avoid
|
|
||||||
stability issues or breaking changes.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{localDaemonUpdateDiagnostics ? (
|
|
||||||
<View style={styles.updateDiagnosticsCard}>
|
|
||||||
<View style={styles.updateDiagnosticsHeader}>
|
|
||||||
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text>
|
|
||||||
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
|
||||||
Copy output
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.updateDiagnosticsText} selectable>
|
|
||||||
{localDaemonUpdateDiagnostics}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* About */}
|
{/* About */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
@@ -1669,60 +1175,6 @@ function HostDetailModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopPermissionRowProps {
|
|
||||||
title: string;
|
|
||||||
status: DesktopPermissionStatus | null;
|
|
||||||
isRequesting: boolean;
|
|
||||||
showBorder?: boolean;
|
|
||||||
onRequest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopPermissionRow({
|
|
||||||
title,
|
|
||||||
status,
|
|
||||||
isRequesting,
|
|
||||||
showBorder,
|
|
||||||
onRequest,
|
|
||||||
}: DesktopPermissionRowProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const state = status?.state ?? "unknown";
|
|
||||||
const isGranted = state === "granted";
|
|
||||||
const shouldShowDetail =
|
|
||||||
status !== null &&
|
|
||||||
status.detail.trim().length > 0 &&
|
|
||||||
state !== "granted" &&
|
|
||||||
state !== "prompt" &&
|
|
||||||
state !== "not-granted";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
|
||||||
<View style={styles.audioRowContent}>
|
|
||||||
<Text style={styles.audioRowTitle}>{title}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.permissionRowActions}>
|
|
||||||
{isGranted ? (
|
|
||||||
<View style={styles.permissionStatusPill}>
|
|
||||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onPress={onRequest}
|
|
||||||
disabled={isRequesting}
|
|
||||||
>
|
|
||||||
{isRequesting ? "Requesting..." : "Request"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{shouldShowDetail ? (
|
|
||||||
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionRow({
|
function ConnectionRow({
|
||||||
connection,
|
connection,
|
||||||
latencyMs,
|
latencyMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user