Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user