Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
@@ -134,9 +134,7 @@ async function saveDesktopAttachmentFromBase64(input: {
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
if (attachment.storageType !== "desktop-file") {
throw new Error(
`Unsupported desktop attachment storage type '${attachment.storageType}'.`
);
throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`);
}
}
@@ -10,14 +10,11 @@ export async function writeDesktopAttachmentBase64(input: {
base64: string;
extension?: string | null;
}): Promise<AttachmentFileResult> {
return await invokeDesktopCommand<AttachmentFileResult>(
"write_attachment_base64",
{
attachmentId: input.attachmentId,
base64: input.base64,
extension: input.extension ?? null,
}
);
return await invokeDesktopCommand<AttachmentFileResult>("write_attachment_base64", {
attachmentId: input.attachmentId,
base64: input.base64,
extension: input.extension ?? null,
});
}
export async function copyDesktopAttachmentFile(input: {
@@ -32,9 +29,7 @@ export async function copyDesktopAttachmentFile(input: {
});
}
export async function deleteDesktopAttachmentFile(input: {
path: string;
}): Promise<boolean> {
export async function deleteDesktopAttachmentFile(input: { path: string }): Promise<boolean> {
return await invokeDesktopCommand<boolean>("delete_attachment_file", {
path: input.path,
});
@@ -19,15 +19,10 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise<string>
});
}
export async function resolveDesktopPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
const base64 = await readDesktopFileBase64(attachment.storageKey);
if (
typeof URL !== "undefined" &&
typeof URL.createObjectURL === "function"
) {
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
const bytes = base64ToUint8Array(base64);
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
@@ -40,9 +35,7 @@ export async function resolveDesktopPreviewUrl(
return `data:${attachment.mimeType};base64,${base64}`;
}
export async function releaseDesktopPreviewUrl(input: {
url: string;
}): Promise<void> {
export async function releaseDesktopPreviewUrl(input: { url: string }): Promise<void> {
if (!activeObjectUrls.has(input.url)) {
return;
}
@@ -65,7 +65,9 @@ export function DesktopPermissionRow({
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? <Text style={styles.permissionDetailText}>{status?.detail}</Text> : null}
{shouldShowDetail ? (
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
) : null}
</View>
</View>
);
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Alert, Image, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import * as QRCode from 'qrcode'
import { useFocusEffect } from '@react-navigation/native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { settingsStyles } from '@/styles/settings'
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import {
ArrowUpRight,
Play,
@@ -14,13 +14,13 @@ import {
Copy,
FileText,
Smartphone,
} from 'lucide-react-native'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Button } from '@/components/ui/button'
import { useAppSettings } from '@/hooks/use-settings'
import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url'
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliSymlinkInstructions,
getDesktopDaemonLogs,
@@ -34,291 +34,289 @@ import {
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from '@/desktop/daemon/desktop-daemon'
} from "@/desktop/daemon/desktop-daemon";
export interface LocalDaemonSectionProps {
appVersion: string | null
showLifecycleControls: boolean
appVersion: string | null;
showLifecycleControls: boolean;
}
export function LocalDaemonSection({
appVersion,
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles()
const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
useState<CliSymlinkInstructions | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve()
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
setDaemonStatus(status)
setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null)
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(version.version);
setStatusError(null);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
setStatusError(message)
})
}, [showSection])
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined
return undefined;
}
void loadDaemonData()
return undefined
}, [loadDaemonData, showSection])
)
void loadDaemonData();
return undefined;
}, [loadDaemonData, showSection]),
);
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon";
const daemonActionMessage =
daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
daemonStatus?.status === "running"
? "Restarts the built-in daemon."
: "Starts the built-in daemon.";
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection || isRestartingDaemon) {
return
return;
}
void confirmDialog({
title: daemonActionLabel,
message:
daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
daemonStatus?.status === "running"
? "This will restart the built-in daemon. The app will reconnect automatically."
: "This will start the built-in daemon.",
confirmLabel: daemonActionLabel,
cancelLabel: 'Cancel',
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return
return;
}
setIsRestartingDaemon(true)
setStatusMessage(null)
setIsRestartingDaemon(true);
setStatusMessage(null);
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
const action =
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action()
.then((status) => {
setDaemonStatus(status)
setDaemonStatus(status);
setStatusMessage(
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadDaemonData()
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
);
return loadDaemonData();
})
.catch((error) => {
console.error('[Settings] Failed to change desktop daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
console.error("[Settings] Failed to change desktop daemon state", error);
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
})
.finally(() => {
setIsRestartingDaemon(false)
})
setIsRestartingDaemon(false);
});
})
.catch((error) => {
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return
return;
}
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true)
setStatusMessage(null)
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => {
setStatusMessage('Built-in daemon management resumed.')
setStatusMessage("Built-in daemon management resumed.");
})
.catch((error) => {
console.error('[Settings] Failed to update built-in daemon management', error)
Alert.alert('Error', 'Unable to update built-in daemon management.')
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false)
})
return
setIsUpdatingDaemonManagement(false);
});
return;
}
void confirmDialog({
title: 'Pause built-in daemon',
title: "Pause built-in daemon",
message:
'This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.',
confirmLabel: 'Pause and stop',
cancelLabel: 'Cancel',
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return
return;
}
setIsUpdatingDaemonManagement(true)
setStatusMessage(null)
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
const stopPromise =
daemonStatus?.status === 'running'
daemonStatus?.status === "running"
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null)
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadDaemonData())
.then(() => {
setStatusMessage('Built-in daemon paused and stopped.')
setStatusMessage("Built-in daemon paused and stopped.");
})
.catch((error) => {
console.error('[Settings] Failed to pause built-in daemon management', error)
Alert.alert('Error', 'Unable to pause built-in daemon management.')
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false)
})
setIsUpdatingDaemonManagement(false);
});
})
.catch((error) => {
console.error('[Settings] Failed to open built-in daemon pause confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
])
]);
const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isLoadingCliSymlinkInstructions) {
return
return;
}
setIsLoadingCliSymlinkInstructions(true)
setCliStatusMessage(null)
setIsLoadingCliSymlinkInstructions(true);
setCliStatusMessage(null);
void getCliSymlinkInstructions()
.then((instructions) => {
setCliSymlinkInstructions(instructions)
setIsCliSymlinkModalOpen(true)
setCliSymlinkInstructions(instructions);
setIsCliSymlinkModalOpen(true);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`)
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
})
.finally(() => {
setIsLoadingCliSymlinkInstructions(false)
})
}, [isLoadingCliSymlinkInstructions, showSection])
setIsLoadingCliSymlinkInstructions(false);
});
}, [isLoadingCliSymlinkInstructions, showSection]);
const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliSymlinkInstructions?.commands) {
return
return;
}
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => {
Alert.alert('Copied', 'CLI symlink commands copied.')
Alert.alert("Copied", "CLI symlink commands copied.");
})
.catch((error) => {
console.error('[Settings] Failed to copy CLI symlink commands', error)
Alert.alert('Error', 'Unable to copy CLI symlink commands.')
})
}, [cliSymlinkInstructions?.commands])
console.error("[Settings] Failed to copy CLI symlink commands", error);
Alert.alert("Error", "Unable to copy CLI symlink commands.");
});
}, [cliSymlinkInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath
const logPath = daemonLogs?.logPath;
if (!logPath) {
return
return;
}
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert('Copied', 'Log path copied.')
Alert.alert("Copied", "Log path copied.");
})
.catch((error) => {
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [daemonLogs?.logPath])
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
});
}, [daemonLogs?.logPath]);
const handleOpenLogs = useCallback(() => {
if (!daemonLogs) {
return
return;
}
setIsLogsModalOpen(true)
}, [daemonLogs])
setIsLogsModalOpen(true);
}, [daemonLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return
return;
}
setIsPairingModalOpen(true)
setIsLoadingPairing(true)
setPairingStatusMessage(null)
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing)
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage('Relay pairing is not available.')
setPairingStatusMessage("Relay pairing is not available.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
setPairingOffer(null)
setPairingStatusMessage(`Unable to load pairing offer: ${message}`)
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false)
})
}, [isLoadingPairing])
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert('Copied', 'Pairing link copied.')
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error('[Settings] Failed to copy pairing link', error)
Alert.alert('Error', 'Unable to copy pairing link.')
})
}, [pairingOffer?.url])
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
if (!showSection) {
return null
return null;
}
return (
@@ -355,8 +353,8 @@ export function LocalDaemonSection({
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
{isDaemonManagementPaused
? 'Paused. The built-in daemon stays stopped until you start it again.'
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
</View>
<Button
@@ -374,11 +372,11 @@ export function LocalDaemonSection({
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? 'Resuming...'
: 'Pausing...'
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? 'Resume'
: 'Pause'}
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
@@ -395,9 +393,9 @@ export function LocalDaemonSection({
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
@@ -416,13 +414,13 @@ export function LocalDaemonSection({
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isLoadingCliSymlinkInstructions ? 'Loading...' : 'Show instructions'}
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
@@ -485,7 +483,7 @@ export function LocalDaemonSection({
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliSymlinkInstructions?.commands ?? ''}
{cliSymlinkInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
@@ -517,67 +515,67 @@ export function LocalDaemonSection({
onClose={() => setIsLogsModalOpen(false)}
title="Daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={['70%', '92%']}
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
</AdaptiveModalSheet>
</View>
)
);
}
const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean
pairingOffer: DesktopPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
isLoading: boolean;
pairingOffer: DesktopPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [qrError, setQrError] = useState<string | null>(null)
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null)
setQrError(null)
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true
}
cancelled = true;
};
}
setQrError(null)
setQrDataUrl(null)
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: 'M',
errorCorrectionLevel: "M",
margin: 1,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
return
return;
}
setQrDataUrl(dataUrl)
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return
return;
}
setQrError(error instanceof Error ? error.message : String(error))
})
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true
}
}, [pairingOffer?.url])
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
@@ -585,7 +583,7 @@ function PairingOfferDialogContent(input: {
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text>
</View>
)
);
}
if (statusMessage) {
@@ -593,7 +591,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
</View>
)
);
}
if (!pairingOffer?.url) {
@@ -601,7 +599,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View>
)
);
}
return (
@@ -630,20 +628,20 @@ function PairingOfferDialogContent(input: {
</Button>
</View>
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: 'center',
flexDirection: 'row',
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionLinkText: {
@@ -651,9 +649,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
@@ -666,13 +664,13 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: 'row',
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: 'wrap',
justifyContent: 'flex-end',
flexWrap: "wrap",
justifyContent: "flex-end",
},
statusValueGroup: {
alignItems: 'flex-end',
alignItems: "flex-end",
gap: 2,
},
rowTitle: {
@@ -703,7 +701,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: 'rgba(245, 158, 11, 0.12)',
backgroundColor: "rgba(245, 158, 11, 0.12)",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
@@ -716,17 +714,17 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
alignItems: "center",
justifyContent: "center",
width: "100%",
aspectRatio: 1,
alignSelf: 'stretch',
alignSelf: "stretch",
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
@@ -734,8 +732,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: '100%',
height: '100%',
width: "100%",
height: "100%",
},
linkSection: {
gap: theme.spacing[2],
@@ -752,13 +750,13 @@ const styles = StyleSheet.create((theme) => ({
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
codeBlock: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -767,8 +765,8 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}))
}));
@@ -1,23 +1,31 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void) | null = null;
let eventHandler:
| ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void)
| null = null;
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
});
const listenToLocalTransportEvents = vi.fn(
async (
handler: typeof eventHandler extends ((...args: infer A) => any) | null
? (...args: A) => void
: never,
) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
},
);
const sendLocalTransportMessage = vi.fn(async () => undefined);
const closeLocalTransportSession = vi.fn(async () => undefined);
@@ -61,7 +69,7 @@ describe("desktop-daemon-transport", () => {
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
}),
);
const mod = await import("./desktop-daemon-transport");
@@ -97,13 +105,13 @@ describe("desktop-daemon-transport", () => {
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
}),
);
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
}),
);
const mod = await import("./desktop-daemon-transport");
@@ -142,15 +142,15 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto
}
if (typeof data === "string") {
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
emitError(error)
emitError(error),
);
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data)
data instanceof ArrayBuffer ? data : new Uint8Array(data),
);
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
emitError(error)
emitError(error),
);
},
close: () => {
@@ -1,214 +1,214 @@
import { getDesktopHost, isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
import { getDesktopHost, isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string
status: DesktopDaemonState
listen: string
hostname: string | null
pid: number | null
home: string
error: string | null
}
serverId: string;
status: DesktopDaemonState;
listen: string;
hostname: string | null;
pid: number | null;
home: string;
error: string | null;
};
export type DesktopDaemonLogs = {
logPath: string
contents: string
}
logPath: string;
contents: string;
};
export type DesktopPairingOffer = {
relayEnabled: boolean
url: string | null
qr: string | null
}
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
export type CliSymlinkInstructions = {
title: string
detail: string
commands: string
}
title: string;
detail: string;
commands: string;
};
export type LocalTransportTarget = {
transportType: 'socket' | 'pipe'
transportPath: string
}
transportType: "socket" | "pipe";
transportPath: string;
};
type LocalTransportEventPayload = {
sessionId: string
kind: 'open' | 'message' | 'close' | 'error'
text?: string | null
binaryBase64?: string | null
code?: number | null
reason?: string | null
error?: string | null
}
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
return typeof value === "object" && value !== null;
}
function toStringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
const normalized = toStringOrNull(value)?.toLowerCase()
const normalized = toStringOrNull(value)?.toLowerCase();
switch (normalized) {
case 'starting':
return 'starting'
case 'running':
return 'running'
case 'errored':
case 'error':
return 'errored'
case 'stopped':
case 'stopping':
case 'unknown':
case "starting":
return "starting";
case "running":
return "running";
case "errored":
case "error":
return "errored";
case "stopped":
case "stopping":
case "unknown":
default:
return 'stopped'
return "stopped";
}
}
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected desktop daemon status response.')
throw new Error("Unexpected desktop daemon status response.");
}
return {
serverId: toStringOrNull(raw.serverId) ?? '',
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '',
listen: toStringOrNull(raw.listen) ?? "",
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
home: toStringOrNull(raw.home) ?? "",
error: toStringOrNull(raw.error),
}
};
}
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) {
throw new Error('Unexpected desktop daemon logs response.')
throw new Error("Unexpected desktop daemon logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? '',
contents: typeof raw.contents === 'string' ? raw.contents : '',
}
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error('Unexpected desktop daemon pairing response.')
throw new Error("Unexpected desktop daemon pairing response.");
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
}
};
}
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) {
return null
return null;
}
return {
title: toStringOrNull(raw.title) ?? '',
detail: toStringOrNull(raw.detail) ?? '',
commands: toStringOrNull(raw.commands) ?? '',
}
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function shouldUseDesktopDaemon(): boolean {
return isDesktop()
return isDesktop();
}
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
}
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
}
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon"));
}
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon"));
}
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
}
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
const instructions = parseCliSymlinkInstructionsInternal(raw)
const instructions = parseCliSymlinkInstructionsInternal(raw);
if (!instructions) {
throw new Error('Unexpected CLI symlink instructions response.')
throw new Error("Unexpected CLI symlink instructions response.");
}
return instructions
return instructions;
}
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
}
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export type LocalTransportEventUnlisten = () => void;
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
handler: LocalTransportEventHandler,
): Promise<LocalTransportEventUnlisten> {
const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') {
throw new Error('Desktop events API is unavailable.')
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
throw new Error("Desktop events API is unavailable.");
}
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
const unlisten = await listen("local-daemon-transport-event", (payload: unknown) => {
if (!isRecord(payload)) {
return
return;
}
handler({
sessionId: toStringOrNull(payload.sessionId) ?? '',
kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'],
sessionId: toStringOrNull(payload.sessionId) ?? "",
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error),
})
})
return typeof unlisten === 'function' ? unlisten : () => {}
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>('open_local_daemon_transport', target)
if (typeof raw !== 'string' || raw.trim().length === 0) {
throw new Error('Unexpected local transport session response.')
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
}
return raw
return raw;
}
export async function sendLocalTransportMessage(input: {
sessionId: string
text?: string
binaryBase64?: string
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
await invokeDesktopCommand('send_local_daemon_transport_message', {
await invokeDesktopCommand("send_local_daemon_transport_message", {
sessionId: input.sessionId,
...(input.text ? { text: input.text } : {}),
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
})
});
}
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand('close_local_daemon_transport', { sessionId })
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}
+1 -1
View File
@@ -8,7 +8,7 @@ type EventEnvelope = {
export async function listenToDesktopEvent<TPayload>(
event: string,
handler: (payload: TPayload) => void
handler: (payload: TPayload) => void,
): Promise<DesktopEventUnlisten> {
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
+1 -1
View File
@@ -2,7 +2,7 @@ import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>(
command: string,
args?: Record<string, unknown>
args?: Record<string, unknown>,
): Promise<T> {
const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") {
+5 -10
View File
@@ -23,15 +23,13 @@ export interface DesktopDialogOpenOptions {
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
open?: (
options?: DesktopDialogOpenOptions
) => Promise<string | string[] | null>;
open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>;
}
export interface DesktopNotificationBridge {
isSupported?: () => Promise<boolean>;
sendNotification?: (
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
payload: string | { title: string; body?: string; data?: Record<string, unknown> },
) => Promise<boolean>;
}
@@ -47,11 +45,11 @@ export interface DesktopWindowBridge {
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
}
@@ -60,10 +58,7 @@ export interface DesktopWindowModuleBridge {
}
export interface DesktopEventsBridge {
on?: (
event: string,
handler: (payload: unknown) => void
) => Promise<() => void> | (() => void);
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
@@ -1,198 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from "vitest";
type MockPlatform = 'web' | 'ios' | 'android'
type MockPlatform = "web" | "ios" | "android";
type GlobalSnapshot = {
Notification: unknown
navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
}
Notification: unknown;
navigatorDescriptor?: PropertyDescriptor;
paseoDesktop: unknown;
};
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
}
typeof window === "undefined" ? undefined : (window as { paseoDesktop?: unknown }).paseoDesktop,
};
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, 'navigator', {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value,
})
});
}
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification;
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
Object.defineProperty(globalThis, "navigator", originalGlobals.navigatorDescriptor);
} else {
delete (globalThis as { navigator?: unknown }).navigator
delete (globalThis as { navigator?: unknown }).navigator;
}
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
if (typeof window !== "undefined") {
(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
vi.resetModules()
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
return import('./desktop-permissions')
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
return import("./desktop-permissions");
}
describe('desktop-permissions', () => {
describe("desktop-permissions", () => {
afterEach(() => {
vi.doUnmock('react-native')
vi.restoreAllMocks()
vi.resetModules()
restoreGlobals()
})
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
restoreGlobals();
});
it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
it("shows section only in desktop web runtime", async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform("web");
expect(shouldShowDesktopPermissionSection()).toBe(false)
expect(shouldShowDesktopPermissionSection()).toBe(false);
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
(window as { paseoDesktop?: unknown }).paseoDesktop = {};
expect(shouldShowDesktopPermissionSection()).toBe(true);
});
it('reads notification and microphone status', async () => {
it("reads notification and microphone status", async () => {
class MockNotification {
static permission = 'default'
static permission = "default";
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
(globalThis as { Notification?: unknown }).Notification = MockNotification;
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
query: vi.fn(async () => ({ state: "granted" })),
},
mediaDevices: {
getUserMedia: vi.fn(),
},
})
});
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
const snapshot = await getDesktopPermissionSnapshot();
expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted')
expect(snapshot.checkedAt).toBeTypeOf('number')
})
expect(snapshot.notifications.state).toBe("prompt");
expect(snapshot.microphone.state).toBe("granted");
expect(snapshot.checkedAt).toBeTypeOf("number");
});
it('queries microphone permission with correct Permissions instance binding', async () => {
it("queries microphone permission with correct Permissions instance binding", async () => {
const permissions = {
query(this: unknown, _descriptor: { name: string }) {
if (this !== permissions) {
throw new TypeError(
'Can only call Permissions.query on instances of Permissions'
)
throw new TypeError("Can only call Permissions.query on instances of Permissions");
}
return Promise.resolve({ state: 'granted' as const })
return Promise.resolve({ state: "granted" as const });
},
}
};
setNavigator({
permissions,
mediaDevices: {
getUserMedia: vi.fn(),
},
})
});
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
const snapshot = await getDesktopPermissionSnapshot();
expect(snapshot.microphone.state).toBe('granted')
})
expect(snapshot.microphone.state).toBe("granted");
});
it('returns a fallback message when runtime blocks Permissions.query', async () => {
it("returns a fallback message when runtime blocks Permissions.query", async () => {
setNavigator({
permissions: {
query: vi.fn(async () => {
throw new TypeError(
'Can only call Permissions.query on instances of Permissions'
)
throw new TypeError("Can only call Permissions.query on instances of Permissions");
}),
},
mediaDevices: {
getUserMedia: vi.fn(),
},
})
});
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
const snapshot = await getDesktopPermissionSnapshot();
expect(snapshot.microphone.state).toBe('unknown')
expect(snapshot.microphone.state).toBe("unknown");
expect(snapshot.microphone.detail).toContain(
'Microphone status API is unavailable in this runtime.'
)
})
"Microphone status API is unavailable in this runtime.",
);
});
it('requests notification permission via the browser Notification API', async () => {
it("requests notification permission via the browser Notification API", async () => {
class MockNotification {
static permission = 'default'
static requestPermission = vi.fn(async () => 'granted')
static permission = "default";
static requestPermission = vi.fn(async () => "granted");
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
(globalThis as { Notification?: unknown }).Notification = MockNotification;
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
const { requestDesktopPermission } = await loadModuleForPlatform("web");
const result = await requestDesktopPermission({ kind: "notifications" });
expect(result.state).toBe('granted')
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
})
expect(result.state).toBe("granted");
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1);
});
it('reads browser Notification permission when available', async () => {
it("reads browser Notification permission when available", async () => {
class MockNotification {
static permission = 'denied'
static permission = "denied";
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({})
(globalThis as { Notification?: unknown }).Notification = MockNotification;
setNavigator({});
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web");
const snapshot = await getDesktopPermissionSnapshot();
expect(snapshot.notifications.state).toBe('denied')
})
expect(snapshot.notifications.state).toBe("denied");
});
it('requests microphone permission and stops acquired tracks', async () => {
const stop = vi.fn()
it("requests microphone permission and stops acquired tracks", async () => {
const stop = vi.fn();
const getUserMedia = vi.fn(async () => ({
getTracks: () => [{ stop }],
}))
}));
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
query: vi.fn(async () => ({ state: "granted" })),
},
mediaDevices: {
getUserMedia,
},
})
});
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'microphone' })
const { requestDesktopPermission } = await loadModuleForPlatform("web");
const result = await requestDesktopPermission({ kind: "microphone" });
expect(result.state).toBe('granted')
expect(getUserMedia).toHaveBeenCalledWith({ audio: true })
expect(stop).toHaveBeenCalledTimes(1)
})
expect(result.state).toBe("granted");
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
expect(stop).toHaveBeenCalledTimes(1);
});
it('maps microphone request denial to denied status', async () => {
it("maps microphone request denial to denied status", async () => {
setNavigator({
mediaDevices: {
getUserMedia: vi.fn(async () => {
throw { name: 'NotAllowedError', message: 'denied' }
throw { name: "NotAllowedError", message: "denied" };
}),
},
})
});
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'microphone' })
const { requestDesktopPermission } = await loadModuleForPlatform("web");
const result = await requestDesktopPermission({ kind: "microphone" });
expect(result.state).toBe('denied')
})
})
expect(result.state).toBe("denied");
});
});
@@ -1,332 +1,332 @@
import { Platform } from 'react-native'
import { getDesktopHost } from '@/desktop/host'
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host";
export type DesktopPermissionKind = 'notifications' | 'microphone'
export type DesktopPermissionKind = "notifications" | "microphone";
export type DesktopPermissionState =
| 'granted'
| 'denied'
| 'prompt'
| 'not-granted'
| 'unavailable'
| 'unknown'
| "granted"
| "denied"
| "prompt"
| "not-granted"
| "unavailable"
| "unknown";
export interface DesktopPermissionStatus {
state: DesktopPermissionState
detail: string
state: DesktopPermissionState;
detail: string;
}
export interface DesktopPermissionSnapshot {
checkedAt: number
notifications: DesktopPermissionStatus
microphone: DesktopPermissionStatus
checkedAt: number;
notifications: DesktopPermissionStatus;
microphone: DesktopPermissionStatus;
}
type NotificationConstructorLike = {
permission?: string
requestPermission?: () => Promise<string>
}
permission?: string;
requestPermission?: () => Promise<string>;
};
type MediaStreamTrackLike = {
stop?: () => void
}
stop?: () => void;
};
type MediaStreamLike = {
getTracks?: () => MediaStreamTrackLike[]
}
getTracks?: () => MediaStreamTrackLike[];
};
type NavigatorLike = {
mediaDevices?: {
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>
}
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>;
};
permissions?: {
query?: (descriptor: { name: string }) => Promise<{ state?: string }>
}
}
query?: (descriptor: { name: string }) => Promise<{ state?: string }>;
};
};
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getDesktopHost() !== null
return Platform.OS === "web" && getDesktopHost() !== null;
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
return input
return input;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
return typeof value === "object" && value !== null;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
if (error instanceof Error && typeof error.message === "string") {
return error.message;
}
return String(error)
return String(error);
}
function getErrorName(error: unknown): string | null {
if (!isObject(error)) {
return null
return null;
}
const name = error.name
return typeof name === 'string' && name.length > 0 ? name : null
const name = error.name;
return typeof name === "string" && name.length > 0 ? name : null;
}
function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
const message = getErrorMessage(error)
const message = getErrorMessage(error);
if (
message.includes('Can only call Permissions.query on instances of Permissions') ||
message.includes('Illegal invocation')
message.includes("Can only call Permissions.query on instances of Permissions") ||
message.includes("Illegal invocation")
) {
return true
return true;
}
return false
return false;
}
function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== 'web') {
return null
if (Platform.OS !== "web") {
return null;
}
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
if (
NotificationConstructor == null ||
(typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object')
(typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object")
) {
return null
return null;
}
return NotificationConstructor as NotificationConstructorLike
return NotificationConstructor as NotificationConstructorLike;
}
function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== 'web') {
return null
if (Platform.OS !== "web") {
return null;
}
const webNavigator = (globalThis as { navigator?: unknown }).navigator
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
if (!isObject(webNavigator)) {
return null
return null;
}
return webNavigator as NavigatorLike
return webNavigator as NavigatorLike;
}
function mapNotificationPermissionString(permission: string): DesktopPermissionStatus {
if (permission === 'granted') {
if (permission === "granted") {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
state: "granted",
detail: "Notifications are allowed by the OS.",
});
}
if (permission === 'denied') {
if (permission === "denied") {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
state: "denied",
detail: "Notifications are denied in system settings.",
});
}
if (permission === 'default') {
if (permission === "default") {
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
state: "prompt",
detail: "Notifications have not been granted yet.",
});
}
return status({
state: 'unknown',
state: "unknown",
detail: `Unexpected notification permission state: ${permission}`,
})
});
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
if (Platform.OS !== "web") {
return status({
state: 'unavailable',
detail: 'Desktop notification status is only available on web runtime.',
})
state: "unavailable",
detail: "Desktop notification status is only available on web runtime.",
});
}
const desktopHost = getDesktopHost()
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
const desktopHost = getDesktopHost();
if (desktopHost && typeof desktopHost.notification?.isSupported === "function") {
try {
const supported = await desktopHost.notification.isSupported()
const supported = await desktopHost.notification.isSupported();
return status({
state: supported ? 'granted' : 'unavailable',
state: supported ? "granted" : "unavailable",
detail: supported
? 'Desktop notifications are supported.'
: 'Desktop notifications are not supported on this platform.',
})
? "Desktop notifications are supported."
: "Desktop notifications are not supported on this platform.",
});
} catch {
// Fall through to web API check
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return mapNotificationPermissionString(NotificationConstructor.permission)
const NotificationConstructor = getWebNotificationConstructor();
if (NotificationConstructor && typeof NotificationConstructor.permission === "string") {
return mapNotificationPermissionString(NotificationConstructor.permission);
}
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
state: "unavailable",
detail: "Web Notification API is unavailable in this environment.",
});
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
if (Platform.OS !== "web") {
return status({
state: 'unavailable',
detail: 'Desktop microphone status is only available on web runtime.',
})
state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.",
});
}
const webNavigator = getNavigatorLike()
const webNavigator = getNavigatorLike();
if (!webNavigator) {
return status({
state: 'unavailable',
detail: 'Navigator is unavailable in this environment.',
})
state: "unavailable",
detail: "Navigator is unavailable in this environment.",
});
}
const permissionsApi = webNavigator.permissions
if (permissionsApi && typeof permissionsApi.query === 'function') {
const permissionsApi = webNavigator.permissions;
if (permissionsApi && typeof permissionsApi.query === "function") {
try {
const result = await permissionsApi.query({ name: 'microphone' })
if (result?.state === 'granted') {
const result = await permissionsApi.query({ name: "microphone" });
if (result?.state === "granted") {
return status({
state: 'granted',
detail: 'Microphone access is granted.',
})
state: "granted",
detail: "Microphone access is granted.",
});
}
if (result?.state === 'denied') {
if (result?.state === "denied") {
return status({
state: 'denied',
detail: 'Microphone access is denied in system settings.',
})
state: "denied",
detail: "Microphone access is denied in system settings.",
});
}
if (result?.state === 'prompt') {
if (result?.state === "prompt") {
return status({
state: 'prompt',
detail: 'Microphone permission has not been granted yet.',
})
state: "prompt",
detail: "Microphone permission has not been granted yet.",
});
}
return status({
state: 'unknown',
detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`,
})
state: "unknown",
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
});
} catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({
state: 'unknown',
state: "unknown",
detail:
'Microphone status API is unavailable in this runtime. Use Request to check access.',
})
"Microphone status API is unavailable in this runtime. Use Request to check access.",
});
}
return status({
state: 'unknown',
state: "unknown",
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
})
});
}
}
if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: 'unavailable',
detail: 'Microphone capture is unavailable in this environment.',
})
state: "unavailable",
detail: "Microphone capture is unavailable in this environment.",
});
}
return status({
state: 'unknown',
detail: 'Permission status API is unavailable. Use Request to check access.',
})
state: "unknown",
detail: "Permission status API is unavailable. Use Request to check access.",
});
}
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
if (Platform.OS !== "web") {
return status({
state: 'unavailable',
detail: 'Desktop notification requests are only available on web runtime.',
})
state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.",
});
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
const NotificationConstructor = getWebNotificationConstructor();
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === "function") {
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
const permission = await NotificationConstructor.requestPermission();
return mapNotificationPermissionString(permission);
} catch (error) {
return status({
state: 'unknown',
state: "unknown",
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
});
}
}
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
state: "unavailable",
detail: "Web Notification API requestPermission() is unavailable.",
});
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
if (Platform.OS !== "web") {
return status({
state: 'unavailable',
detail: 'Desktop microphone requests are only available on web runtime.',
})
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",
});
}
const webNavigator = getNavigatorLike()
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
const webNavigator = getNavigatorLike();
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: 'unavailable',
detail: 'Microphone capture API is unavailable in this environment.',
})
state: "unavailable",
detail: "Microphone capture API is unavailable in this environment.",
});
}
try {
const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true })
const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : []
const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true });
const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : [];
tracks.forEach((track) => {
if (typeof track.stop === 'function') {
track.stop()
if (typeof track.stop === "function") {
track.stop();
}
})
return await getMicrophonePermissionStatus()
});
return await getMicrophonePermissionStatus();
} catch (error) {
const errorName = getErrorName(error)
if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') {
const errorName = getErrorName(error);
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
return status({
state: 'denied',
detail: 'Microphone permission was denied by the user or system.',
})
state: "denied",
detail: "Microphone permission was denied by the user or system.",
});
}
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return status({
state: 'unavailable',
detail: 'No microphone device was found.',
})
state: "unavailable",
detail: "No microphone device was found.",
});
}
return status({
state: 'unknown',
state: "unknown",
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
})
});
}
}
export async function requestDesktopPermission(input: {
kind: DesktopPermissionKind
kind: DesktopPermissionKind;
}): Promise<DesktopPermissionStatus> {
if (input.kind === 'notifications') {
return await requestNotificationPermissionStatus()
if (input.kind === "notifications") {
return await requestNotificationPermissionStatus();
}
return await requestMicrophonePermissionStatus()
return await requestMicrophonePermissionStatus();
}
export async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
const [notifications, microphone] = await Promise.all([
getNotificationPermissionStatus(),
getMicrophonePermissionStatus(),
])
]);
return {
checkedAt: Date.now(),
notifications,
microphone,
}
};
}
@@ -35,7 +35,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null
null,
);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
@@ -80,12 +80,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
}
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot =
previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
};
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
};
if (kind === "notifications") {
return {
@@ -110,7 +109,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktop, refreshPermissions]
[isDesktop, refreshPermissions],
);
const sendTestNotification = useCallback(async () => {
+9 -9
View File
@@ -1,23 +1,23 @@
import { getDesktopHost } from '@/desktop/host'
import { getDesktopHost } from "@/desktop/host";
export async function pickDirectory(): Promise<string | null> {
const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') {
throw new Error('Desktop dialog open() is unavailable in this environment.')
const open = getDesktopHost()?.dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
})
});
if (selection === null) {
return null
return null;
}
if (typeof selection === 'string') {
return selection
if (typeof selection === "string") {
return selection;
}
throw new Error('Unexpected directory picker response.')
throw new Error("Unexpected directory picker response.");
}
@@ -1,95 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from "vitest";
async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
vi.resetModules()
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
return import('./desktop-updates')
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
return import("./desktop-updates");
}
describe('desktop-updates helpers', () => {
describe("desktop-updates helpers", () => {
afterEach(() => {
vi.doUnmock('react-native')
vi.restoreAllMocks()
vi.resetModules()
})
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
});
it('normalizes versions for app-daemon comparisons', async () => {
const { normalizeVersionForComparison } = await loadModuleForPlatform('web')
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()
})
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')
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)
})
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')
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')
})
expect(formatVersionWithPrefix("0.2.0")).toBe("v0.2.0");
expect(formatVersionWithPrefix("v0.2.0")).toBe("v0.2.0");
expect(formatVersionWithPrefix(null)).toBe("\u2014");
});
it('parses valid local daemon version result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
it("parses valid local daemon version result", async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web");
expect(parseLocalDaemonVersionResult({ version: '0.1.15', error: null })).toEqual({
version: '0.1.15',
expect(parseLocalDaemonVersionResult({ version: "0.1.15", error: null })).toEqual({
version: "0.1.15",
error: null,
})
})
});
});
it('parses local daemon version error result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
it("parses local daemon version error result", async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web");
expect(
parseLocalDaemonVersionResult({ version: null, error: 'paseo command not found in PATH' })
parseLocalDaemonVersionResult({ version: null, error: "paseo command not found in PATH" }),
).toEqual({
version: null,
error: 'paseo command not found in PATH',
})
})
error: "paseo command not found in PATH",
});
});
it('parses unexpected local daemon version result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
it("parses unexpected local daemon version result", async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web");
expect(parseLocalDaemonVersionResult(null)).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
error: "Unexpected response from version check.",
});
expect(parseLocalDaemonVersionResult('not an object')).toEqual({
expect(parseLocalDaemonVersionResult("not an object")).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
})
error: "Unexpected response from version check.",
});
});
it('trims whitespace in parsed version', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
it("trims whitespace in parsed version", async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web");
expect(parseLocalDaemonVersionResult({ version: ' 0.1.15 ', error: null })).toEqual({
version: '0.1.15',
expect(parseLocalDaemonVersionResult({ version: " 0.1.15 ", error: null })).toEqual({
version: "0.1.15",
error: null,
})
})
});
});
it('builds copyable daemon update diagnostics', async () => {
const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web')
it("builds copyable daemon update diagnostics", async () => {
const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform("web");
const diagnostics = buildDaemonUpdateDiagnostics({
exitCode: 1,
stdout: 'stdout text',
stderr: 'stderr text',
})
stdout: "stdout text",
stderr: "stderr text",
});
expect(diagnostics).toContain('Exit code: 1')
expect(diagnostics).toContain('STDOUT:\nstdout text')
expect(diagnostics).toContain('STDERR:\nstderr text')
})
})
expect(diagnostics).toContain("Exit code: 1");
expect(diagnostics).toContain("STDOUT:\nstdout text");
expect(diagnostics).toContain("STDERR:\nstderr text");
});
});
@@ -1,77 +1,77 @@
import { Platform } from 'react-native'
import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
import { Platform } from "react-native";
import { isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
body: string | null
date: string | null
hasUpdate: boolean;
currentVersion: string | null;
latestVersion: string | null;
body: string | null;
date: string | null;
}
export interface DesktopAppUpdateInstallResult {
installed: boolean
version: string | null
message: string
installed: boolean;
version: string | null;
message: string;
}
export interface LocalDaemonUpdateResult {
exitCode: number
stdout: string
stderr: string
exitCode: number;
stdout: string;
stderr: string;
}
export interface LocalDaemonVersionResult {
version: string | null
error: string | null
version: string | null;
error: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
return typeof value === "object" && value !== null;
}
function toStringOrNull(value: unknown): string | null {
if (typeof value !== 'string') {
return null
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toStringOrEmpty(value: unknown): string {
return typeof value === 'string' ? value : ''
return typeof value === "string" ? value : "";
}
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;
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && isDesktop()
return Platform.OS === "web" && isDesktop();
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
if (!isRecord(raw)) {
return { version: null, error: 'Unexpected response from version check.' }
return { version: null, error: "Unexpected response from version check." };
}
return {
version: toStringOrNull(raw.version),
error: toStringOrNull(raw.error),
}
};
}
export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult> {
const result = await invokeDesktopCommand<unknown>('get_local_daemon_version')
return parseLocalDaemonVersionResult(result)
const result = await invokeDesktopCommand<unknown>("get_local_daemon_version");
return parseLocalDaemonVersionResult(result);
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>('check_app_update')
const result = await invokeDesktopCommand<unknown>("check_app_update");
if (!isRecord(result)) {
throw new Error('Unexpected response while checking desktop updates.')
throw new Error("Unexpected response while checking desktop updates.");
}
return {
@@ -80,78 +80,70 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
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')
const result = await invokeDesktopCommand<unknown>("install_app_update");
if (!isRecord(result)) {
throw new Error('Unexpected response while installing desktop update.')
throw new Error("Unexpected response while installing desktop update.");
}
return {
installed: result.installed === true,
version: toStringOrNull(result.version),
message: toStringOrNull(result.message) ?? 'Update completed.',
}
message: toStringOrNull(result.message) ?? "Update completed.",
};
}
export async function runLocalDaemonUpdate(): Promise<LocalDaemonUpdateResult> {
const result = await invokeDesktopCommand<unknown>('run_local_daemon_update')
const result = await invokeDesktopCommand<unknown>("run_local_daemon_update");
if (!isRecord(result)) {
throw new Error('Unexpected response while updating local daemon.')
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()
const value = version?.trim();
if (!value) {
return null
return null;
}
return value.replace(/^v/i, '')
return value.replace(/^v/i, "");
}
export function isVersionMismatch(
appVersion: string | null | undefined,
daemonVersion: string | null | undefined
daemonVersion: string | null | undefined,
): boolean {
const app = normalizeVersionForComparison(appVersion)
const daemon = normalizeVersionForComparison(daemonVersion)
const app = normalizeVersionForComparison(appVersion);
const daemon = normalizeVersionForComparison(daemonVersion);
if (!app || !daemon) {
return false
return false;
}
return app !== daemon
return app !== daemon;
}
export function formatVersionWithPrefix(version: string | null | undefined): string {
const value = version?.trim()
const value = version?.trim();
if (!value) {
return '\u2014'
return "\u2014";
}
return value.startsWith('v') ? value : `v${value}`
return value.startsWith("v") ? value : `v${value}`;
}
export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
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')
return [`Exit code: ${result.exitCode}`, "", "STDOUT:", stdout, "", "STDERR:", stderr].join("\n");
}
@@ -10,14 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -46,18 +40,12 @@ export function UpdateBanner() {
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<Pressable
onPress={() => setDismissed(true)}
hitSlop={8}
style={styles.closeButton}
>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>
{isInstalled ? "Update installed" : "Update available"}
</Text>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
@@ -68,10 +56,7 @@ export function UpdateBanner() {
<View style={styles.actions}>
<Pressable
onPress={() => void openExternalUrl(CHANGELOG_URL)}
style={({ pressed }) => [
styles.outlineButton,
pressed && styles.buttonPressed,
]}
style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]}
>
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useRef, useState } from "react";
import {
checkDesktopAppUpdate,
formatVersionWithPrefix,
@@ -6,163 +6,163 @@ import {
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from '@/desktop/updates/desktop-updates'
} from "@/desktop/updates/desktop-updates";
export type DesktopAppUpdateStatus =
| 'idle'
| 'checking'
| 'up-to-date'
| 'available'
| 'installing'
| 'installed'
| 'error'
| "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>
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
if (error instanceof Error && typeof error.message === "string") {
return error.message;
}
return String(error)
return String(error);
}
function formatStatusText(input: {
status: DesktopAppUpdateStatus
availableUpdate: DesktopAppUpdateCheckResult | null
installMessage: string | null
status: DesktopAppUpdateStatus;
availableUpdate: DesktopAppUpdateCheckResult | null;
installMessage: string | null;
}): string {
const { status, availableUpdate, installMessage } = input
const { status, availableUpdate, installMessage } = input;
if (status === 'checking') {
return 'Checking for app updates...'
if (status === "checking") {
return "Checking for app updates...";
}
if (status === 'installing') {
return 'Installing app update...'
if (status === "installing") {
return "Installing app update...";
}
if (status === 'up-to-date') {
return 'App is up to date.'
if (status === "up-to-date") {
return "App is up to date.";
}
if (status === 'available') {
if (status === "available") {
if (availableUpdate?.latestVersion) {
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
}
return 'An app update is available.'
return "An app update is available.";
}
if (status === 'installed') {
return installMessage ?? 'App update installed. Restart required.'
if (status === "installed") {
return installMessage ?? "App update installed. Restart required.";
}
if (status === 'error') {
return 'Failed to update app.'
if (status === "error") {
return "Failed to update app.";
}
return 'Update status has not been checked yet.'
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 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
return null;
}
const requestVersion = requestVersionRef.current + 1
requestVersionRef.current = requestVersion
const requestVersion = requestVersionRef.current + 1;
requestVersionRef.current = requestVersion;
if (!options.silent) {
setStatus('checking')
setStatus("checking");
}
setErrorMessage(null)
setErrorMessage(null);
try {
const result = await checkDesktopAppUpdate()
const result = await checkDesktopAppUpdate();
if (requestVersion !== requestVersionRef.current) {
return result
return result;
}
setInstallMessage(null)
setLastCheckedAt(Date.now())
setInstallMessage(null);
setLastCheckedAt(Date.now());
if (result.hasUpdate) {
setAvailableUpdate(result)
setStatus('available')
setAvailableUpdate(result);
setStatus("available");
} else {
setAvailableUpdate(null)
setStatus('up-to-date')
setAvailableUpdate(null);
setStatus("up-to-date");
}
return result
return result;
} catch (error) {
if (requestVersion !== requestVersionRef.current) {
return null
return null;
}
const message = getErrorMessage(error)
const message = getErrorMessage(error);
if (options.silent) {
console.warn('[DesktopUpdater] Silent update check failed', message)
console.warn("[DesktopUpdater] Silent update check failed", message);
} else {
setStatus('error')
setErrorMessage(message)
setStatus("error");
setErrorMessage(message);
}
return null
return null;
}
},
[isDesktop]
)
[isDesktop],
);
const installUpdate = useCallback(async () => {
if (!isDesktop) {
return null
return null;
}
setStatus('installing')
setErrorMessage(null)
setStatus("installing");
setErrorMessage(null);
try {
const result = await installDesktopAppUpdate()
setLastCheckedAt(Date.now())
const result = await installDesktopAppUpdate();
setLastCheckedAt(Date.now());
if (result.installed) {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('installed')
setAvailableUpdate(null);
setInstallMessage(result.message);
setStatus("installed");
} else {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('up-to-date')
setAvailableUpdate(null);
setInstallMessage(result.message);
setStatus("up-to-date");
}
return result
return result;
} catch (error) {
const message = getErrorMessage(error)
setStatus('error')
setErrorMessage(message)
return null
const message = getErrorMessage(error);
setStatus("error");
setErrorMessage(message);
return null;
}
}, [isDesktop])
}, [isDesktop]);
return {
isDesktop,
@@ -175,9 +175,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
availableUpdate,
errorMessage,
lastCheckedAt,
isChecking: status === 'checking',
isInstalling: status === 'installing',
isChecking: status === "checking",
isInstalling: status === "installing",
checkForUpdates,
installUpdate,
}
};
}