Merge pull request #69 from getpaseo/feat/auto-update-daemon-app
Add desktop app + local daemon update controls in Settings
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,398 @@
|
||||
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,
|
||||
isLikelyLocalHost,
|
||||
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 hasLikelyLocalDaemon = isLikelyLocalHost(localDaemonHost);
|
||||
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 daemonVersionTitle = hasLikelyLocalDaemon
|
||||
? "Local daemon version"
|
||||
: "Connected daemon version";
|
||||
const daemonHostHint = localDaemonHost
|
||||
? hasLikelyLocalDaemon
|
||||
? `Connected host: ${localDaemonHost.label}`
|
||||
: `No local daemon detected. Showing connected host: ${localDaemonHost.label}.`
|
||||
: "No local daemon detected yet.";
|
||||
|
||||
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}>{daemonVersionTitle}</Text>
|
||||
<Text style={styles.updateHintText}>{daemonHostHint}</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;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostProfile } from '@/contexts/daemon-registry-context'
|
||||
|
||||
async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
|
||||
vi.resetModules()
|
||||
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
|
||||
return import('./desktop-updates')
|
||||
}
|
||||
|
||||
function createHost(input: {
|
||||
serverId: string
|
||||
endpoint: string
|
||||
type?: 'direct' | 'relay'
|
||||
}): HostProfile {
|
||||
const now = new Date(0).toISOString()
|
||||
|
||||
if (input.type === 'relay') {
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: `${input.serverId}-relay`,
|
||||
type: 'relay',
|
||||
relayEndpoint: input.endpoint,
|
||||
daemonPublicKeyB64: 'test-key',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `${input.serverId}-relay`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: `${input.serverId}-direct`,
|
||||
type: 'direct',
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `${input.serverId}-direct`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
describe('desktop-updates helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('react-native')
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('normalizes versions for app-daemon comparisons', async () => {
|
||||
const { normalizeVersionForComparison } = await loadModuleForPlatform('web')
|
||||
|
||||
expect(normalizeVersionForComparison(' v0.1.15 ')).toBe('0.1.15')
|
||||
expect(normalizeVersionForComparison('0.1.15')).toBe('0.1.15')
|
||||
expect(normalizeVersionForComparison(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('detects version mismatch after normalization', async () => {
|
||||
const { isVersionMismatch } = await loadModuleForPlatform('web')
|
||||
|
||||
expect(isVersionMismatch('v0.1.15', '0.1.15')).toBe(false)
|
||||
expect(isVersionMismatch('0.1.15', '0.1.16')).toBe(true)
|
||||
expect(isVersionMismatch('0.1.15', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('formats display versions with v prefix and unavailable fallback', async () => {
|
||||
const { formatVersionWithPrefix } = await loadModuleForPlatform('web')
|
||||
|
||||
expect(formatVersionWithPrefix('0.2.0')).toBe('v0.2.0')
|
||||
expect(formatVersionWithPrefix('v0.2.0')).toBe('v0.2.0')
|
||||
expect(formatVersionWithPrefix(null)).toBe('\u2014')
|
||||
})
|
||||
|
||||
it('prefers localhost direct host when selecting local daemon', async () => {
|
||||
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
|
||||
const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
|
||||
const local = createHost({ serverId: 'local', endpoint: 'localhost:6767' })
|
||||
|
||||
expect(findLikelyLocalDaemonHost([remote, local])?.serverId).toBe('local')
|
||||
})
|
||||
|
||||
it('falls back to first host when no localhost endpoint exists', async () => {
|
||||
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
|
||||
const relay = createHost({ serverId: 'relay', endpoint: 'relay.paseo.sh:443', type: 'relay' })
|
||||
const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
|
||||
|
||||
expect(findLikelyLocalDaemonHost([relay, remote])?.serverId).toBe('relay')
|
||||
})
|
||||
|
||||
it('builds copyable daemon update diagnostics', async () => {
|
||||
const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web')
|
||||
const diagnostics = buildDaemonUpdateDiagnostics({
|
||||
exitCode: 1,
|
||||
stdout: 'stdout text',
|
||||
stderr: 'stderr text',
|
||||
})
|
||||
|
||||
expect(diagnostics).toContain('Exit code: 1')
|
||||
expect(diagnostics).toContain('STDOUT:\nstdout text')
|
||||
expect(diagnostics).toContain('STDERR:\nstderr text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Platform } from 'react-native'
|
||||
import type { HostProfile } from '@/contexts/daemon-registry-context'
|
||||
import { getTauri } from '@/utils/tauri'
|
||||
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
hasUpdate: boolean
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
body: string | null
|
||||
date: string | null
|
||||
}
|
||||
|
||||
export interface DesktopAppUpdateInstallResult {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LocalDaemonUpdateResult {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function toStringOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function toNumberOr(defaultValue: number, value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
|
||||
}
|
||||
|
||||
function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
|
||||
const normalized = endpoint.trim().toLowerCase()
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized.startsWith('localhost:') ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized.startsWith('127.0.0.1:') ||
|
||||
normalized === '::1' ||
|
||||
normalized.startsWith('::1:') ||
|
||||
normalized === '[::1]' ||
|
||||
normalized.startsWith('[::1]:')
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpdateSection(): boolean {
|
||||
return Platform.OS === 'web' && getTauri() !== null
|
||||
}
|
||||
|
||||
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>('check_app_update')
|
||||
if (!isRecord(result)) {
|
||||
throw new Error('Unexpected response while checking desktop updates.')
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate: result.hasUpdate === true,
|
||||
currentVersion: toStringOrNull(result.currentVersion),
|
||||
latestVersion: toStringOrNull(result.latestVersion),
|
||||
body: toStringOrNull(result.body),
|
||||
date: toStringOrNull(result.date),
|
||||
}
|
||||
}
|
||||
|
||||
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
|
||||
const result = await invokeDesktopCommand<unknown>('install_app_update')
|
||||
if (!isRecord(result)) {
|
||||
throw new Error('Unexpected response while installing desktop update.')
|
||||
}
|
||||
|
||||
return {
|
||||
installed: result.installed === true,
|
||||
version: toStringOrNull(result.version),
|
||||
message: toStringOrNull(result.message) ?? 'Update completed.',
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLocalDaemonUpdate(): Promise<LocalDaemonUpdateResult> {
|
||||
const result = await invokeDesktopCommand<unknown>('run_local_daemon_update')
|
||||
if (!isRecord(result)) {
|
||||
throw new Error('Unexpected response while updating local daemon.')
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: toNumberOr(1, result.exitCode),
|
||||
stdout: toStringOrEmpty(result.stdout),
|
||||
stderr: toStringOrEmpty(result.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeVersionForComparison(version: string | null | undefined): string | null {
|
||||
const value = version?.trim()
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
export function isVersionMismatch(
|
||||
appVersion: string | null | undefined,
|
||||
daemonVersion: string | null | undefined
|
||||
): boolean {
|
||||
const app = normalizeVersionForComparison(appVersion)
|
||||
const daemon = normalizeVersionForComparison(daemonVersion)
|
||||
|
||||
if (!app || !daemon) {
|
||||
return false
|
||||
}
|
||||
|
||||
return app !== daemon
|
||||
}
|
||||
|
||||
export function formatVersionWithPrefix(version: string | null | undefined): string {
|
||||
const value = version?.trim()
|
||||
if (!value) {
|
||||
return '\u2014'
|
||||
}
|
||||
|
||||
return value.startsWith('v') ? value : `v${value}`
|
||||
}
|
||||
|
||||
export function findLikelyLocalDaemonHost(daemons: HostProfile[]): HostProfile | null {
|
||||
if (daemons.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const localhostDaemon = daemons.find((daemon) => isLikelyLocalHost(daemon))
|
||||
|
||||
return localhostDaemon ?? daemons[0] ?? null
|
||||
}
|
||||
|
||||
export function isLikelyLocalHost(host: HostProfile | null | undefined): boolean {
|
||||
if (!host) {
|
||||
return false
|
||||
}
|
||||
|
||||
return host.connections.some(
|
||||
(connection) =>
|
||||
connection.type === 'direct' && isLikelyLocalDirectEndpoint(connection.endpoint)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
|
||||
const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
|
||||
const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
|
||||
|
||||
return [
|
||||
`Exit code: ${result.exitCode}`,
|
||||
'',
|
||||
'STDOUT:',
|
||||
stdout,
|
||||
'',
|
||||
'STDERR:',
|
||||
stderr,
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
checkDesktopAppUpdate,
|
||||
formatVersionWithPrefix,
|
||||
installDesktopAppUpdate,
|
||||
shouldShowDesktopUpdateSection,
|
||||
type DesktopAppUpdateCheckResult,
|
||||
type DesktopAppUpdateInstallResult,
|
||||
} from '@/desktop/updates/desktop-updates'
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'up-to-date'
|
||||
| 'available'
|
||||
| 'installing'
|
||||
| 'installed'
|
||||
| 'error'
|
||||
|
||||
export interface UseDesktopAppUpdaterReturn {
|
||||
isDesktop: boolean
|
||||
status: DesktopAppUpdateStatus
|
||||
statusText: string
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null
|
||||
errorMessage: string | null
|
||||
lastCheckedAt: number | null
|
||||
isChecking: boolean
|
||||
isInstalling: boolean
|
||||
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>
|
||||
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function formatStatusText(input: {
|
||||
status: DesktopAppUpdateStatus
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null
|
||||
installMessage: string | null
|
||||
}): string {
|
||||
const { status, availableUpdate, installMessage } = input
|
||||
|
||||
if (status === 'checking') {
|
||||
return 'Checking for app updates...'
|
||||
}
|
||||
|
||||
if (status === 'installing') {
|
||||
return 'Installing app update...'
|
||||
}
|
||||
|
||||
if (status === 'up-to-date') {
|
||||
return 'App is up to date.'
|
||||
}
|
||||
|
||||
if (status === 'available') {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
|
||||
}
|
||||
return 'An app update is available.'
|
||||
}
|
||||
|
||||
if (status === 'installed') {
|
||||
return installMessage ?? 'App update installed. Restart required.'
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return 'Failed to update app.'
|
||||
}
|
||||
|
||||
return 'Update status has not been checked yet.'
|
||||
}
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktop = shouldShowDesktopUpdateSection()
|
||||
const requestVersionRef = useRef(0)
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>('idle')
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [installMessage, setInstallMessage] = useState<string | null>(null)
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null)
|
||||
|
||||
const checkForUpdates = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!isDesktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requestVersion = requestVersionRef.current + 1
|
||||
requestVersionRef.current = requestVersion
|
||||
|
||||
if (!options.silent) {
|
||||
setStatus('checking')
|
||||
}
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const result = await checkDesktopAppUpdate()
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return result
|
||||
}
|
||||
|
||||
setInstallMessage(null)
|
||||
setLastCheckedAt(Date.now())
|
||||
|
||||
if (result.hasUpdate) {
|
||||
setAvailableUpdate(result)
|
||||
setStatus('available')
|
||||
} else {
|
||||
setAvailableUpdate(null)
|
||||
setStatus('up-to-date')
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const message = getErrorMessage(error)
|
||||
if (options.silent) {
|
||||
console.warn('[DesktopUpdater] Silent update check failed', message)
|
||||
} else {
|
||||
setStatus('error')
|
||||
setErrorMessage(message)
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
[isDesktop]
|
||||
)
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
setStatus('installing')
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const result = await installDesktopAppUpdate()
|
||||
setLastCheckedAt(Date.now())
|
||||
|
||||
if (result.installed) {
|
||||
setAvailableUpdate(null)
|
||||
setInstallMessage(result.message)
|
||||
setStatus('installed')
|
||||
} else {
|
||||
setAvailableUpdate(null)
|
||||
setInstallMessage(result.message)
|
||||
setStatus('up-to-date')
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
setStatus('error')
|
||||
setErrorMessage(message)
|
||||
return null
|
||||
}
|
||||
}, [isDesktop])
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
status,
|
||||
statusText: formatStatusText({
|
||||
status,
|
||||
availableUpdate,
|
||||
installMessage,
|
||||
}),
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
lastCheckedAt,
|
||||
isChecking: status === 'checking',
|
||||
isInstalling: status === 'installing',
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { router, useLocalSearchParams } from "expo-router";
|
||||
import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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 { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
@@ -37,14 +37,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
shouldShowDesktopPermissionSection,
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
type DesktopPermissionStatus,
|
||||
} from "@/utils/desktop-permissions";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { DesktopUpdatesSection } from "@/desktop/components/desktop-updates-section";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -320,54 +314,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
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: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -445,6 +391,19 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
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() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -468,17 +427,12 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
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 isLoading = settingsLoading || daemonLoading;
|
||||
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = Platform.OS === "web";
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionForDisplay(appVersion);
|
||||
const editingServerId = editingDaemon?.serverId ?? null;
|
||||
const editingDaemonLive = editingServerId
|
||||
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
||||
@@ -573,73 +527,6 @@ export default function SettingsScreen() {
|
||||
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]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||
if (!editingServerId) return;
|
||||
if (isSavingEdit) return;
|
||||
@@ -903,54 +790,8 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showDesktopPermissionSection ? (
|
||||
<View style={styles.section}>
|
||||
<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}
|
||||
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||
{isDesktop ? <DesktopUpdatesSection appVersion={appVersion} /> : null}
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
@@ -960,7 +801,7 @@ export default function SettingsScreen() {
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Version</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1334,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({
|
||||
connection,
|
||||
latencyMs,
|
||||
|
||||
Generated
+246
-2
@@ -75,6 +75,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -756,6 +765,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1027,6 +1047,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.8"
|
||||
@@ -1322,8 +1353,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1333,9 +1366,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1597,6 +1632,23 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
@@ -1990,6 +2042,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"redox_syscall 0.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2022,6 +2075,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2092,6 +2151,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2397,6 +2462,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2482,6 +2559,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2531,7 +2622,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2549,6 +2640,7 @@ dependencies = [
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
|
||||
@@ -2903,6 +2995,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
@@ -3049,6 +3196,15 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3132,16 +3288,21 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3151,6 +3312,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3236,6 +3398,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3278,6 +3446,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3673,7 +3842,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -3872,6 +4041,17 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4112,6 +4292,38 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.4.2"
|
||||
@@ -4936,6 +5148,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.1"
|
||||
@@ -5583,6 +5805,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -5747,6 +5979,18 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.15"
|
||||
|
||||
@@ -30,7 +30,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dialog:default",
|
||||
"notification:default",
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"websocket:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::menu::AboutMetadata;
|
||||
use tauri::Manager;
|
||||
use tauri::WebviewWindow;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
|
||||
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
|
||||
@@ -18,13 +20,148 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
||||
let _ = webview.set_zoom(clamped);
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DaemonUpdateCommandResult {
|
||||
exit_code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AppUpdateCheckResult {
|
||||
has_update: bool,
|
||||
current_version: String,
|
||||
latest_version: Option<String>,
|
||||
body: Option<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AppUpdateInstallResult {
|
||||
installed: bool,
|
||||
version: Option<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn resolve_login_shell() -> String {
|
||||
std::env::var("SHELL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "/bin/zsh".to_string())
|
||||
}
|
||||
|
||||
fn execute_local_daemon_update(shell: &str) -> DaemonUpdateCommandResult {
|
||||
let script = r#"if command -v paseo >/dev/null 2>&1; then
|
||||
paseo daemon update
|
||||
else
|
||||
echo "paseo command not found in PATH. Ensure Paseo CLI is installed for this user." >&2
|
||||
exit 127
|
||||
fi"#;
|
||||
|
||||
match Command::new(shell).arg("-lc").arg(script).output() {
|
||||
Ok(output) => DaemonUpdateCommandResult {
|
||||
exit_code: output.status.code().unwrap_or(1),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
},
|
||||
Err(error) => DaemonUpdateCommandResult {
|
||||
exit_code: -1,
|
||||
stdout: String::new(),
|
||||
stderr: format!("Failed to run daemon update command: {error}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_local_daemon_update() -> DaemonUpdateCommandResult {
|
||||
let shell = resolve_login_shell();
|
||||
tauri::async_runtime::spawn_blocking(move || execute_local_daemon_update(&shell))
|
||||
.await
|
||||
.unwrap_or_else(|error| DaemonUpdateCommandResult {
|
||||
exit_code: -1,
|
||||
stdout: String::new(),
|
||||
stderr: format!("Daemon update task failed: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_app_update(app: AppHandle) -> Result<AppUpdateCheckResult, String> {
|
||||
let current_version = app.package_info().version.to_string();
|
||||
let updater = app
|
||||
.updater()
|
||||
.map_err(|error| format!("Failed to initialize updater: {error}"))?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("Failed to check for updates: {error}"))?;
|
||||
|
||||
if let Some(update) = update {
|
||||
return Ok(AppUpdateCheckResult {
|
||||
has_update: true,
|
||||
current_version,
|
||||
latest_version: Some(update.version.to_string()),
|
||||
body: update.body,
|
||||
date: update.date.map(|date| date.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AppUpdateCheckResult {
|
||||
has_update: false,
|
||||
current_version,
|
||||
latest_version: None,
|
||||
body: None,
|
||||
date: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_app_update(app: AppHandle) -> Result<AppUpdateInstallResult, String> {
|
||||
let updater = app
|
||||
.updater()
|
||||
.map_err(|error| format!("Failed to initialize updater: {error}"))?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("Failed to check for updates: {error}"))?;
|
||||
|
||||
let Some(update) = update else {
|
||||
return Ok(AppUpdateInstallResult {
|
||||
installed: false,
|
||||
version: None,
|
||||
message: "No update is currently available.".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
let version = update.version.to_string();
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|error| format!("Failed to download and install update: {error}"))?;
|
||||
|
||||
Ok(AppUpdateInstallResult {
|
||||
installed: true,
|
||||
version: Some(version),
|
||||
message: "Update installed. Restart Paseo to finish applying it.".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
run_local_daemon_update,
|
||||
check_app_update,
|
||||
install_app_update
|
||||
])
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -44,5 +45,14 @@
|
||||
"entitlements": "Entitlements.plist",
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/getpaseo/paseo/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "REPLACE_WITH_TAURI_UPDATER_PUBLIC_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user