chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions
+46 -29
View File
@@ -53,11 +53,10 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -65,19 +64,18 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { attachConsole } from "@/utils/tauri-attach-console";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
@@ -86,33 +84,37 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? ((payload as { data: Record<string, unknown> }).data)
: undefined;
router.push(buildNotificationRoute(data) as any);
}
);
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
});
}
const target = globalThis as unknown as EventTarget;
@@ -126,7 +128,10 @@ function PushNotificationRouter() {
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
@@ -476,12 +481,24 @@ function OfferLinkListener({
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
useFaviconStatus();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
return;
}
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
@@ -57,7 +57,7 @@ export default function Index() {
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => {
const timer = setTimeout(() => {
+2 -2
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
@@ -6,9 +6,9 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsTauriMac,
getIsDesktopMac,
} from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps {
@@ -37,18 +37,21 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsTauriMac()
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
// On Tauri macOS, enable window dragging and double-click to maximize
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
dragRegionStyle,
]}
{...dragHandlers}
>
<View style={[styles.left, leftStyle]}>{left}</View>
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri();
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
[isMac, isTauri]
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
);
return (
+4 -4
View File
@@ -20,7 +20,7 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
@@ -130,7 +130,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
@@ -448,9 +448,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -30,7 +30,7 @@ import { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { getIsTauri } from '@/constants/layout'
import { getIsDesktop } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostWorkspaceRouteWithOpenIntent,
@@ -1407,10 +1407,10 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map()
)
const isTauri = getIsTauri()
const isDesktopApp = getIsDesktop()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown)
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
+25 -29
View File
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -14,60 +14,56 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
// Tauri desktop app constants for macOS traffic light buttons
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Check if running in Tauri desktop app (any OS)
function isTauri(): boolean {
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
return getTauri() !== null;
return isDesktop();
}
// Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean {
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false;
if (getTauri() === null) return false;
// Check for macOS via user agent
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
return isDesktopMac();
}
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
let _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null;
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
export function getIsTauriMac(): boolean {
if (_isTauriMacCached === true) {
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
return true;
}
const result = isTauriMac();
const result = isDesktopEnvironmentMac();
if (result) {
_isTauriMacCached = true;
_isDesktopMacCached = true;
}
return result;
}
export function getIsTauri(): boolean {
if (_isTauriCached === true) {
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
return true;
}
const result = isTauri();
const result = isDesktopEnvironment();
if (result) {
_isTauriCached = true;
_isDesktopCached = true;
}
return result;
}
// Get traffic light padding values (only non-zero on Tauri macOS)
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsTauriMac()) {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: TAURI_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -1,4 +1,4 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
interface AttachmentFileResult {
path: string;
@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
}));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));
@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
@@ -20,21 +20,21 @@ 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 { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import {
getCliSymlinkInstructions,
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from '@/desktop/managed-runtime/managed-runtime'
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from '@/desktop/daemon/desktop-daemon'
export interface LocalDaemonSectionProps {
appVersion: string | null
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon()
const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
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 [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | 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<ManagedPairingOffer | null>(null)
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => {
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve()
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status)
setManagedLogs(logs)
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
setDaemonStatus(status)
setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null)
})
.catch((error) => {
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
if (!showSection) {
return undefined
}
void loadManagedStatus()
void loadDaemonData()
return undefined
}, [loadManagedStatus, showSection])
}, [loadDaemonData, showSection])
)
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonStatusStateText =
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage =
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return
}
if (isRestartingDaemon) {
if (!showSection || isRestartingDaemon) {
return
}
void confirmDialog({
title: daemonActionLabel,
message:
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel,
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
setIsRestartingDaemon(true)
setStatusMessage(null)
const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
void action()
.then((status) => {
setManagedStatus(status)
setDaemonStatus(status)
setStatusMessage(
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadManagedStatus()
return loadDaemonData()
})
.catch((error) => {
console.error('[Settings] Failed to change managed daemon state', error)
console.error('[Settings] Failed to change desktop daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
})
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
})
})
.catch((error) => {
console.error('[Settings] Failed to open managed daemon action confirmation', error)
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
setStatusMessage(null)
const stopPromise =
managedStatus?.status === 'running'
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
daemonStatus?.status === 'running'
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null)
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => loadDaemonData())
.then(() => {
setStatusMessage('Built-in daemon paused and stopped.')
})
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
])
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
}, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath
const logPath = daemonLogs?.logPath
if (!logPath) {
return
}
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [managedLogs?.logPath])
}, [daemonLogs?.logPath])
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
if (!daemonLogs) {
return
}
setIsLogsModalOpen(true)
}, [managedLogs])
}, [daemonLogs])
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
setIsLoadingPairing(true)
setPairingStatusMessage(null)
void getManagedDaemonPairing()
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) {
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? managedStatus?.status === 'running'
? daemonStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
</View>
<View style={styles.actionGroup}>
{managedLogs?.logPath ? (
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!managedLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
snapPoints={['70%', '92%']}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
</Text>
</View>
</AdaptiveModalSheet>
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: {
isLoading: boolean
pairingOffer: ManagedPairingOffer | null
pairingOffer: DesktopPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
}) {
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -40,32 +40,32 @@ const managedRuntimeMock = vi.hoisted(() => {
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
vi.mock("./desktop-daemon", () => ({
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
describe("desktop-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
desktopDaemonMock.openLocalTransportSession.mockReset();
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
desktopDaemonMock.sendLocalTransportMessage.mockClear();
desktopDaemonMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -75,7 +75,7 @@ describe("managed-tauri-daemon-transport", () => {
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
desktopDaemonMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
@@ -93,21 +93,21 @@ describe("managed-tauri-daemon-transport", () => {
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
@@ -121,7 +121,7 @@ describe("managed-tauri-daemon-transport", () => {
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
@@ -5,7 +5,7 @@ import {
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
} from "./desktop-daemon";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -49,7 +49,7 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
@@ -1,29 +1,24 @@
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
import { getDesktopHost, isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export type ManagedRuntimeStatus = {
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
export type ManagedDaemonStatus = {
runtimeId: string
runtimeVersion: string
export type DesktopDaemonStatus = {
serverId: string
status: string
status: DesktopDaemonState
listen: string
hostname: string | null
pid: number | null
home: string
error: string | null
}
export type ManagedDaemonLogs = {
export type DesktopDaemonLogs = {
logPath: string
contents: string
}
export type ManagedPairingOffer = {
export type DesktopPairingOffer = {
relayEnabled: boolean
url: string | null
qr: string | null
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
commands: string
}
export type ManagedTcpSettings = {
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = {
transportType: 'socket' | 'pipe'
transportPath: string
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed runtime status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
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':
default:
return 'stopped'
}
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon status response.')
throw new Error('Unexpected desktop daemon status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown',
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
error: toStringOrNull(raw.error),
}
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon logs response.')
throw new Error('Unexpected desktop daemon logs response.')
}
return {
logPath: toStringOrNull(raw.logPath) ?? '',
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.')
throw new Error('Unexpected desktop daemon pairing response.')
}
return {
relayEnabled: raw.relayEnabled === true,
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null
export function shouldUseDesktopDaemon(): boolean {
return isDesktop()
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
)
}
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen
const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') {
throw new Error('Tauri event API is unavailable.')
throw new Error('Desktop events API is unavailable.')
}
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) {
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
if (!isRecord(payload)) {
return
}
handler({
@@ -0,0 +1,27 @@
import { getDesktopHost } from "@/desktop/host";
export type DesktopEventUnlisten = () => void;
type EventEnvelope = {
payload?: unknown;
};
export async function listenToDesktopEvent<TPayload>(
event: string,
handler: (payload: TPayload) => void
): Promise<DesktopEventUnlisten> {
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
throw new Error("Desktop event API is unavailable.");
}
const unlisten = await listen(event, (rawEvent: unknown) => {
const payload =
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
? (rawEvent as EventEnvelope).payload
: rawEvent;
handler(payload as TPayload);
});
return typeof unlisten === "function" ? unlisten : () => {};
}
+12
View File
@@ -0,0 +1,12 @@
import type { DesktopHostBridge } from "@/desktop/host";
export function getElectronHost(): DesktopHostBridge | null {
if (typeof window === "undefined") {
return null;
}
const host = window.paseoDesktop;
if (!host || typeof host !== "object") {
return null;
}
return host;
}
@@ -1,13 +1,12 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const invoke = getTauri()?.core?.invoke;
const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri invoke() is unavailable in this environment.");
throw new Error("Desktop invoke() is unavailable in this environment.");
}
return (await invoke(command, args)) as T;
}
@@ -0,0 +1,37 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}
export async function startDesktopDragging(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.startDragging !== "function") {
return;
}
await win.startDragging();
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
return;
}
await win.toggleMaximize();
}
export async function isDesktopFullscreen(): Promise<boolean> {
const win = getDesktopWindow();
if (!win || typeof win.isFullscreen !== "function") {
return false;
}
return await win.isFullscreen();
}
+111
View File
@@ -0,0 +1,111 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
export type DesktopNotificationPermission = "granted" | "denied" | "default";
export interface DesktopDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
open?: (
options?: DesktopDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface DesktopNotificationBridge {
isSupported?: () => Promise<boolean>;
sendNotification?: (
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
) => Promise<boolean>;
}
export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopWindowModuleBridge {
getCurrentWindow?: () => DesktopWindowBridge;
}
export interface DesktopEventsBridge {
on?: (
event: string,
handler: (payload: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
}
declare global {
interface Window {
paseoDesktop?: DesktopHostBridge;
}
}
export function getDesktopHost(): DesktopHostBridge | null {
if (Platform.OS !== "web") {
return null;
}
return getElectronHost();
}
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {
return false;
}
const hostPlatform = getDesktopHost()?.platform?.toLowerCase();
if (hostPlatform === "darwin" || hostPlatform === "mac" || hostPlatform === "macos") {
return true;
}
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliSymlinkInstructions", () => {
it("parses CLI symlink instructions from the desktop backend", () => {
expect(
parseCliSymlinkInstructions({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
})
).toEqual({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
});
});
it("rejects non-object payloads", () => {
expect(() => parseCliSymlinkInstructions(null)).toThrow(
"Unexpected CLI symlink instructions response."
);
});
});
@@ -1,58 +0,0 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
}
function setNavigator(value: unknown): void {
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else {
delete (globalThis as { navigator?: unknown }).navigator
}
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
restoreGlobals()
})
it('shows section only in Tauri web runtime', async () => {
it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { isPermissionGranted },
class MockNotification {
static permission = 'default'
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted')
expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number')
})
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
)
})
it('requests notification permission via Tauri', async () => {
const requestPermission = vi.fn(async () => 'granted')
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { requestPermission },
it('requests notification permission via the browser Notification API', async () => {
class MockNotification {
static permission = 'default'
static requestPermission = vi.fn(async () => 'granted')
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted')
expect(requestPermission).toHaveBeenCalledTimes(1)
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
})
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
it('reads browser Notification permission when available', async () => {
class MockNotification {
static permission = 'denied'
}
@@ -1,5 +1,5 @@
import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export type DesktopPermissionKind = 'notifications' | 'microphone'
@@ -45,7 +45,7 @@ type NavigatorLike = {
}
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && getDesktopHost() !== null
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
})
}
function mapTauriNotificationPermissionResult(
permission: TauriNotificationPermission
): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
const desktopHost = getDesktopHost()
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
try {
const granted = await tauriNotification.isPermissionGranted()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
const supported = await desktopHost.notification.isSupported()
return status({
state: 'not-granted',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
})
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
state: supported ? 'granted' : 'unavailable',
detail: supported
? '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 status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return mapNotificationPermissionString(NotificationConstructor.permission)
}
return mapNotificationPermissionString(NotificationConstructor.permission)
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
try {
const permission = await tauriNotification.requestPermission()
return mapTauriNotificationPermissionResult(permission)
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, delivery works. Click it to verify the open flow.",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");
+3 -3
View File
@@ -1,9 +1,9 @@
import { getTauri } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export async function pickDirectory(): Promise<string | null> {
const open = getTauri()?.dialog?.open
const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') {
throw new Error('Tauri dialog open() is unavailable in this environment.')
throw new Error('Desktop dialog open() is unavailable in this environment.')
}
const selection = await open({
@@ -1,6 +1,6 @@
import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && isDesktop()
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -1,21 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({
const desktopHostState = vi.hoisted(() => ({
api: null as any,
}));
vi.mock("@/utils/tauri", () => ({
getTauri: () => tauriState.api,
vi.mock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
} from "./image-attachment-picker";
describe("image-attachment-picker", () => {
beforeEach(() => {
tauriState.api = null;
desktopHostState.api = null;
});
it("normalizes a picked File into a blob source", async () => {
@@ -69,13 +69,13 @@ describe("image-attachment-picker", () => {
expect(result[0]?.mimeType).toBe("image/png");
});
it("uses the tauri dialog api when available", async () => {
it("uses the desktop dialog api when available", async () => {
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = {
desktopHostState.api = {
dialog: { open },
};
const result = await openImagePathsWithTauriDialog();
const result = await openImagePathsWithDesktopDialog();
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
@@ -87,21 +87,11 @@ describe("image-attachment-picker", () => {
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("falls back to core invoke for the tauri dialog plugin", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
tauriState.api = {
core: { invoke },
};
it("throws when desktop dialog API is not available", async () => {
desktopHostState.api = {};
const result = await openImagePathsWithTauriDialog();
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
"Desktop dialog API is not available."
);
});
});
@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
@@ -77,15 +77,15 @@ export async function normalizePickedImageAssets(
);
}
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
function normalizeDesktopDialogSelection(selection: string | string[] | null): string[] {
if (!selection) {
return [];
}
return Array.isArray(selection) ? selection : [selection];
}
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
const tauri = getTauri();
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
const desktop = getDesktopHost();
const options = {
directory: false,
multiple: true,
@@ -93,18 +93,10 @@ export async function openImagePathsWithTauriDialog(): Promise<string[]> {
title: "Attach images",
};
const dialogOpen = tauri?.dialog?.open;
if (typeof dialogOpen === "function") {
return normalizeTauriDialogSelection(await dialogOpen(options));
const dialogOpen = desktop?.dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const invoke = tauri?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri dialog API is not available.");
}
const result = await invoke("plugin:dialog|open", { options });
return normalizeTauriDialogSelection(
Array.isArray(result) || typeof result === "string" || result === null ? result : null
);
return normalizeDesktopDialogSelection(await dialogOpen(options));
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,20 +157,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -158,17 +158,17 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
}
if (!secureContext && isTauri) {
if (!secureContext && isDesktopApp) {
console.warn(
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
);
}
+6 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -92,15 +92,15 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
return;
}
try {
await tauriWindow.setBadgeCount(count);
await desktopWindow.setBadgeCount(count);
} catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
}
+16 -15
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
@@ -33,7 +33,7 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".tiff": "image/tiff",
};
type TauriDragDropPayload =
type DesktopDragDropPayload =
| {
type: "enter";
paths: string[];
@@ -49,8 +49,8 @@ type TauriDragDropPayload =
type: "leave";
};
type TauriDragDropEvent = {
payload: TauriDragDropPayload;
type DesktopDragDropEvent = {
payload: DesktopDragDropPayload;
};
function isImageFile(file: File): boolean {
@@ -121,26 +121,27 @@ export function useFileDropZone({
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
}
}
async function setupTauriDragDrop(): Promise<boolean> {
if (getTauri() === null) {
async function setupDesktopDragDrop(): Promise<boolean> {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
return false;
}
const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
const desktopWindow = desktopHost.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") {
return false;
}
try {
const unlisten = await tauriWindow.onDragDropEvent(
(event: TauriDragDropEvent) => {
const unlisten = await desktopWindow.onDragDropEvent(
(event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
@@ -185,7 +186,7 @@ export function useFileDropZone({
cleanup = unlisten;
return true;
} catch (error) {
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
return false;
}
}
@@ -269,8 +270,8 @@ export function useFileDropZone({
}
void (async () => {
const tauriListenersAttached = await setupTauriDragDrop();
if (disposed || tauriListenersAttached) {
const desktopListenersAttached = await setupDesktopDragDrop();
if (disposed || desktopListenersAttached) {
return;
}
setupDomDragDrop();
@@ -2,10 +2,10 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isTauriEnvironment } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
@@ -42,8 +42,8 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isTauriEnvironment()) {
const selectedPaths = await openImagePathsWithTauriDialog();
if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;
}
@@ -1,15 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
import { isTauriEnvironment } from '@/utils/tauri'
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const
interface ManagedDaemonServerIdResult {
interface DesktopDaemonServerIdResult {
serverId: string | null
}
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
const status = await getManagedDaemonStatus()
async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
const status = await getDesktopDaemonStatus()
const serverId = status.serverId.trim()
return {
serverId: serverId.length > 0 ? serverId : null,
@@ -18,14 +17,15 @@ async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim()
const isDesktop = isTauriEnvironment()
const isDesktop = shouldUseDesktopDaemon()
const query = useQuery({
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadManagedDaemonServerId,
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktop,
staleTime: Infinity,
gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -50,7 +50,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const isDesktopApp = getIsDesktop();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
@@ -259,7 +259,7 @@ export function useKeyboardShortcuts({
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
}
if (key === "Shift") {
@@ -278,7 +278,7 @@ export function useKeyboardShortcuts({
event,
context: {
isMac,
isTauri,
isDesktop: isDesktopApp,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: canToggleFileExplorerShortcut({
@@ -314,7 +314,7 @@ export function useKeyboardShortcuts({
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
if (isDesktopApp && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
}
};
@@ -23,7 +23,7 @@ function shortcutContext(
): KeyboardShortcutContext {
return {
isMac: false,
isTauri: false,
isDesktop: false,
focusScope: "other",
commandCenterOpen: false,
hasSelectedAgent: true,
@@ -84,7 +84,7 @@ type HelpSectionCase = {
name: string;
context: {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
};
expectedKeys: Record<string, string[]>;
};
@@ -106,42 +106,42 @@ describe("keyboard-shortcuts", () => {
{
name: "matches workspace index jump on web via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.navigate.index",
payload: { index: 2 },
},
{
name: "matches workspace index jump on tauri via Mod+digit",
name: "matches workspace index jump on desktop via Mod+digit",
event: { key: "2", code: "Digit2", metaKey: true },
context: { isMac: true, isTauri: true },
context: { isMac: true, isDesktop: true },
action: "workspace.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on tauri via Alt+digit",
name: "matches tab index jump on desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isTauri: true },
context: { isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on web via Alt+Shift+digit",
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches workspace relative navigation on web via Alt+[",
event: { key: "[", code: "BracketLeft", altKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches workspace relative navigation on tauri via Mod+]",
name: "matches workspace relative navigation on desktop via Mod+]",
event: { key: "]", code: "BracketRight", ctrlKey: true },
context: { isTauri: true },
context: { isDesktop: true },
action: "workspace.navigate.relative",
payload: { delta: 1 },
},
@@ -160,13 +160,13 @@ describe("keyboard-shortcuts", () => {
{
name: "matches Alt+Shift+W to close current tab on web",
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
context: { isTauri: false },
context: { isDesktop: false },
action: "workspace.tab.close.current",
},
{
name: "matches Mod+W to close current tab on tauri",
name: "matches Mod+W to close current tab on desktop",
event: { key: "w", code: "KeyW", metaKey: true },
context: { isMac: true, isTauri: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current",
},
{
@@ -312,7 +312,7 @@ describe("keyboard-shortcut help sections", () => {
const helpCases: HelpSectionCase[] = [
{
name: "uses web defaults for workspace and tab jump",
context: { isMac: true, isTauri: false },
context: { isMac: true, isDesktop: false },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
@@ -324,8 +324,8 @@ describe("keyboard-shortcut help sections", () => {
},
},
{
name: "uses tauri defaults for workspace and tab jump",
context: { isMac: true, isTauri: true },
name: "uses desktop defaults for workspace and tab jump",
context: { isMac: true, isDesktop: true },
expectedKeys: {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
@@ -338,7 +338,7 @@ describe("keyboard-shortcut help sections", () => {
},
{
name: "uses mod+period as non-mac left sidebar shortcut",
context: { isMac: false, isTauri: false },
context: { isMac: false, isDesktop: false },
expectedKeys: {
"toggle-left-sidebar": ["mod", "."],
},
+25 -25
View File
@@ -8,7 +8,7 @@ import type {
export type KeyboardShortcutContext = {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
focusScope: KeyboardFocusScope;
commandCenterOpen: boolean;
hasSelectedAgent: boolean;
@@ -36,7 +36,7 @@ export type KeyboardShortcutHelpSection = {
type KeyboardShortcutPlatformContext = {
isMac: boolean;
isTauri: boolean;
isDesktop: boolean;
};
type KeyboardShortcutHelpEntry = {
@@ -158,20 +158,20 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
},
{
id: "workspace-tab-close-current-mod-w-tauri",
id: "workspace-tab-close-current-mod-w-desktop",
action: "workspace.tab.close.current",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["mod", "W"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -183,17 +183,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.altKey &&
event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["alt", "shift", "W"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
id: "workspace-navigate-index-mod-digit-tauri",
id: "workspace-navigate-index-mod-digit-desktop",
action: "workspace.navigate.index",
matches: (event) =>
isMod(event) &&
@@ -201,13 +201,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-jump-index",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -220,17 +220,17 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-jump-index",
section: "global",
label: "Jump to workspace",
keys: ["alt", "1-9"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
id: "workspace-tab-navigate-index-alt-digit-tauri",
id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index",
matches: (event) =>
!event.metaKey &&
@@ -239,13 +239,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-jump-index",
section: "global",
label: "Jump to tab",
keys: ["alt", "1-9"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -258,13 +258,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.shiftKey &&
hasDigit(event),
payload: withIndexPayload,
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-tab-jump-index",
section: "global",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
@@ -276,13 +276,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-prev",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -294,13 +294,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1),
when: (context) => context.isTauri && !context.commandCenterOpen,
when: (context) => context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-next",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
when: (context) => context.isTauri,
when: (context) => context.isDesktop,
},
},
{
@@ -313,13 +313,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketLeft" || event.key === "["),
payload: withRelativeDelta(-1),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-prev",
section: "global",
label: "Previous workspace",
keys: ["alt", "["],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
@@ -332,13 +332,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
!event.shiftKey &&
(event.code === "BracketRight" || event.key === "]"),
payload: withRelativeDelta(1),
when: (context) => !context.isTauri && !context.commandCenterOpen,
when: (context) => !context.isDesktop && !context.commandCenterOpen,
help: {
id: "workspace-next",
section: "global",
label: "Next workspace",
keys: ["alt", "]"],
when: (context) => !context.isTauri,
when: (context) => !context.isDesktop,
},
},
{
+22 -43
View File
@@ -16,9 +16,9 @@ import {
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from "@/desktop/managed-runtime/managed-runtime";
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import {
buildDaemonWebSocketUrl,
@@ -32,8 +32,8 @@ import {
} from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport";
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -432,7 +432,7 @@ function probeIntervalForConnection(
function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = {
suppressSendErrors: true,
clientId,
@@ -1188,7 +1188,7 @@ export class HostRuntimeStore {
return;
}
if (shouldUseManagedDesktopDaemon()) {
if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop();
} else {
await this.bootstrapLocalhost();
@@ -1197,43 +1197,22 @@ export class HostRuntimeStore {
private async bootstrapDesktop(): Promise<void> {
try {
await Promise.allSettled([
(async () => {
const daemon = await startManagedDaemon();
if (!daemon.serverId) {
return;
}
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
})().catch((error) => {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}),
(async () => {
const { client, serverId, hostname } = await connectToDaemon(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: "directTcp",
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
);
await this.upsertDirectConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
existingClient: client,
});
})().catch(() => undefined),
]);
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error);
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
}
@@ -47,7 +47,7 @@ import type {
} from '@server/server/agent/agent-sdk-types'
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useDesktopDragHandlers } from '@/utils/desktop-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout
)
const tauriDragHandlers = useTauriDragHandlers()
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
const draftIdRef = useRef(generateDraftId())
const draftAgentIdRef = useRef(generateDraftId())
@@ -974,7 +974,7 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
const mainContent = (
<View style={styles.container} {...tauriDragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View
@@ -7,8 +7,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { getIsTauriMac } from "@/constants/layout";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
@@ -19,9 +19,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openProjectPicker = useOpenProjectPicker(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
useEffect(() => {
if (!isMobile) {
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
+2 -1
View File
@@ -41,6 +41,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
@@ -513,7 +514,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web";
const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1,7 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
}));
export function StartupSplashScreen() {
const dragHandlers = useTauriDragHandlers();
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text>
</View>
+53 -24
View File
@@ -80,6 +80,17 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean
return JSON.stringify(left) === JSON.stringify(right)
}
function dedupeHostConnections(connections: HostConnection[]): HostConnection[] {
const next: HostConnection[] = []
for (const connection of connections) {
if (next.some((existing) => hostConnectionEquals(existing, connection))) {
continue
}
next.push(connection)
}
return next
}
export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
@@ -96,9 +107,17 @@ export function upsertHostConnectionInProfiles(input: {
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
if (
daemon.serverId === serverId ||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
) {
matches.push(index)
}
return matches
}, [])
if (idx === -1) {
if (matchingIndexes.length === 0) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
@@ -111,31 +130,37 @@ export function upsertHostConnectionInProfiles(input: {
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const matchedProfiles = matchingIndexes.map((index) => existing[index]!)
const prev =
matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]!
const nextConnections = dedupeHostConnections([
...matchedProfiles.flatMap((daemon) => daemon.connections),
input.connection,
])
const nextLifecycle = prev.lifecycle
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const nextLabel =
labelTrimmed || (prev.label === prev.serverId ? derivedLabel : prev.label)
const nextPreferredConnectionId =
prev.preferredConnectionId &&
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
? prev.preferredConnectionId
: input.connection.id
const nextCreatedAt = matchedProfiles.reduce(
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
prev.createdAt
)
const changed =
matchingIndexes.length > 1 ||
prev.serverId !== serverId ||
nextCreatedAt !== prev.createdAt ||
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
nextConnections.length !== prev.connections.length ||
nextConnections.some((connection, index) => {
const previousConnection = prev.connections[index]
return !previousConnection || !hostConnectionEquals(connection, previousConnection)
})
if (!changed) {
return existing
@@ -143,15 +168,19 @@ export function upsertHostConnectionInProfiles(input: {
const nextProfile: HostProfile = {
...prev,
serverId,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
createdAt: nextCreatedAt,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
const firstIndex = matchingIndexes[0]!
const matchingIndexSet = new Set(matchingIndexes)
const next = existing.filter((_daemon, index) => !matchingIndexSet.has(index))
next.splice(firstIndex, 0, nextProfile)
return next
}
+15 -4
View File
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const desktopHostState = {
api: null as {
dialog?: {
ask?: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
} | null,
};
type MockPlatform = "web" | "ios" | "android";
type AlertButton = {
@@ -19,13 +27,16 @@ async function loadModuleForPlatform(platform: MockPlatform): Promise<{
},
Platform: { OS: platform },
}));
vi.doMock("@/desktop/host", () => ({
getDesktopHost: () => desktopHostState.api,
}));
const module = await import("./confirm-dialog");
return { confirmDialog: module.confirmDialog, alertMock };
}
function clearDialogGlobals(): void {
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
desktopHostState.api = null;
delete (globalThis as { confirm?: unknown }).confirm;
}
@@ -37,13 +48,13 @@ describe("confirmDialog", () => {
clearDialogGlobals();
});
it("uses Tauri dialog.ask on web when available", async () => {
it("uses the desktop dialog bridge on web when available", async () => {
const askMock = vi.fn(async () => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {
activeElement: { blur: blurMock },
} as unknown as Document;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
desktopHostState.api = {
dialog: { ask: askMock },
};
@@ -67,7 +78,7 @@ describe("confirmDialog", () => {
});
});
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
it("falls back to browser confirm on web when desktop APIs are unavailable", async () => {
const browserConfirm = vi.fn(() => true);
const blurMock = vi.fn();
(globalThis as { document?: unknown }).document = {
+15 -32
View File
@@ -1,5 +1,5 @@
import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
export interface ConfirmDialogInput {
title: string;
@@ -48,14 +48,14 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
});
}
function getTauriApi() {
function getDesktopApi() {
if (Platform.OS !== "web") {
return null;
}
return getTauri();
return getDesktopHost();
}
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOptions {
const labels = resolveButtonLabels(input);
return {
@@ -74,35 +74,18 @@ function blurActiveWebElement(): void {
(activeElement as HTMLElement | null)?.blur?.();
}
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi();
if (!tauriApi) {
async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const desktopApi = getDesktopApi();
if (!desktopApi) {
return null;
}
blurActiveWebElement();
const options = buildTauriAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask;
const options = buildDesktopAskOptions(input);
const desktopAsk = desktopApi.dialog?.ask;
if (typeof tauriAsk === "function") {
try {
return Boolean(await tauriAsk(input.message, options));
} catch (error) {
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
}
}
const tauriInvoke = tauriApi.core?.invoke;
if (typeof tauriInvoke === "function") {
try {
const result = await tauriInvoke("plugin:dialog|ask", {
message: input.message,
...options,
});
return result === true;
} catch (error) {
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
}
if (typeof desktopAsk === "function") {
return Boolean(await desktopAsk(input.message, options));
}
return null;
@@ -124,9 +107,9 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
return showNativeConfirmDialog(input);
}
const tauriResult = await showTauriConfirmDialog(input);
if (tauriResult !== null) {
return tauriResult;
const desktopResult = await showDesktopConfirmDialog(input);
if (desktopResult !== null) {
return desktopResult;
}
return showWebConfirmDialog(input);
@@ -134,5 +117,5 @@ export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean>
export const __private__ = {
blurActiveWebElement,
buildTauriAskOptions,
buildDesktopAskOptions,
};
+117
View File
@@ -0,0 +1,117 @@
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import { Platform, type ViewStyle } from "react-native";
import {
getIsDesktopMac,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
type DesktopDragHandlers = {
style?: CSSProperties & ViewStyle;
};
const DESKTOP_DRAG_REGION_STYLE = {
WebkitAppRegion: "drag",
} as CSSProperties & ViewStyle;
export async function startDragging() {
const win = getDesktopWindow();
if (win && typeof win.startDragging === "function") {
try {
await win.startDragging();
} catch (error) {
console.warn("[DesktopWindow] startDragging failed", error);
}
}
}
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error);
}
}
}
export function useDesktopDragHandlers(): DesktopDragHandlers {
if (Platform.OS !== "web" || !isDesktop()) {
return {};
}
return {
style: DESKTOP_DRAG_REGION_STYLE,
};
}
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const fullscreen =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
return () => {
disposed = true;
runCleanup();
};
}, []);
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
+2 -2
View File
@@ -1,10 +1,10 @@
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") {
const opener = getTauri()?.opener?.openUrl;
const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
return;
+81 -17
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockNotificationOptions = {
body?: string;
data?: Record<string, unknown>;
icon?: string;
};
type MockNotificationInstance = {
@@ -18,7 +19,6 @@ type GlobalSnapshot = {
dispatchEvent: unknown;
focus: unknown;
location: unknown;
__TAURI__: unknown;
};
const originalGlobals: GlobalSnapshot = {
@@ -27,12 +27,34 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
};
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
async function loadModuleForPlatform(
platform: "web" | "ios" | "android",
options?: {
desktopHost?: {
notification?: {
sendNotification?: (payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>;
};
} | null;
}
) {
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
vi.doMock("@/desktop/host", () => ({
getDesktopHost: () => options?.desktopHost ?? null,
}));
vi.doMock("expo-asset", () => ({
Asset: {
fromModule: vi.fn(() => ({
uri: "http://localhost:8081/packages/app/assets/images/notification-icon.png",
})),
},
}));
return import("./os-notifications");
}
@@ -42,7 +64,6 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
}
describe("sendOsNotification", () => {
@@ -71,7 +92,6 @@ describe("sendOsNotification", () => {
});
afterEach(() => {
vi.doUnmock("@/desktop/notifications/desktop-notifications");
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
@@ -168,14 +188,8 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
});
it("uses the desktop notification bridge when Tauri is available", async () => {
const sendDesktopNotification = vi.fn(async () => true);
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
sendDesktopNotification,
}));
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
it("returns false when the Notification API is unavailable", async () => {
(globalThis as { Notification?: unknown }).Notification = undefined;
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
@@ -183,11 +197,61 @@ describe("sendOsNotification", () => {
data: { serverId: "srv-1", agentId: "agent-1" },
});
expect(sent).toBe(false);
});
it("does not attach a click handler when there is no route target", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
static permission = "granted";
static requestPermission = vi.fn(async () => "granted");
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
created.push(this);
}
}
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = vi.fn();
(globalThis as { location?: unknown }).location = { assign: vi.fn() };
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
});
expect(sent).toBe(true);
expect(sendDesktopNotification).toHaveBeenCalledWith({
title: "Agent finished",
body: "Done",
data: { serverId: "srv-1", agentId: "agent-1" },
expect(created).toHaveLength(1);
expect(created[0]?.onclick).toBeNull();
});
it("uses the desktop notification bridge when available", async () => {
const sendNotification = vi.fn(async () => true);
const { sendOsNotification } = await loadModuleForPlatform("web", {
desktopHost: {
notification: {
sendNotification,
},
},
});
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
data: { serverId: "srv-1" },
});
expect(sent).toBe(true);
expect(sendNotification).toHaveBeenCalledWith({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
data: { serverId: "srv-1" },
});
});
});
+54 -81
View File
@@ -1,7 +1,10 @@
import { Asset } from "expo-asset";
import { Platform } from "react-native";
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
import {
buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
type OsNotificationPayload = {
title: string;
@@ -15,28 +18,41 @@ export type WebNotificationClickDetail = {
type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null;
onclose?: ((event: Event) => void) | null;
addEventListener?: (type: string, listener: (event: Event) => void) => void;
removeEventListener?: (type: string, listener: (event: Event) => void) => void;
close?: () => void;
};
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
const activeWebNotifications = new Set<WebNotificationInstance>();
let notificationIconUrl: string | null | undefined;
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
return null;
}
return getTauri()?.notification ?? null;
function getDesktopNotificationSender():
| ((payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
| null {
const sendNotification = getDesktopHost()?.notification?.sendNotification;
return typeof sendNotification === "function"
? (sendNotification as (payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
: null;
}
function getWebNotificationConstructor(): {
permission: string;
requestPermission?: () => Promise<string>;
new (title: string, options?: { body?: string; data?: Record<string, unknown> }): unknown;
new (
title: string,
options?: {
body?: string;
data?: Record<string, unknown>;
icon?: string;
}
): unknown;
} | null {
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
return NotificationConstructor ?? null;
@@ -70,49 +86,31 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") {
return false;
}
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
return await ensureTauriNotificationPermission(tauriNotification);
}
return await ensureNotificationPermission();
}
async function ensureTauriNotificationPermission(
notificationModule: TauriNotificationApi
): Promise<boolean> {
if (typeof notificationModule.isPermissionGranted === "function") {
try {
const granted = await notificationModule.isPermissionGranted();
if (granted) {
return true;
}
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to check notification permission",
error
);
}
}
function hasNotificationClickTarget(
data: Record<string, unknown> | undefined
): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
}
if (typeof notificationModule.requestPermission !== "function") {
console.warn(
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
);
return false;
function getWebNotificationIconUrl(): string | undefined {
if (notificationIconUrl !== undefined) {
return notificationIconUrl ?? undefined;
}
try {
const result = await notificationModule.requestPermission();
return result === "granted";
} catch (error) {
console.warn(
"[OSNotifications][Tauri] Failed to request notification permission",
error
const asset = Asset.fromModule(
require("../../assets/images/notification-icon.png")
);
return false;
notificationIconUrl = asset.uri ?? null;
} catch {
notificationIconUrl = null;
}
return notificationIconUrl ?? undefined;
}
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
@@ -156,41 +154,12 @@ function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined
): void {
activeWebNotifications.add(notification);
const cleanup = () => {
activeWebNotifications.delete(notification);
};
const onClick = () => {
const focus = (globalThis as { focus?: () => void }).focus;
if (typeof focus === "function") {
focus();
}
notification.onclick = () => {
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
cleanup();
if (typeof notification.close === "function") {
notification.close();
}
};
const onClose = () => {
cleanup();
};
if (typeof notification.addEventListener === "function") {
notification.addEventListener("click", onClick);
notification.addEventListener("close", onClose);
return;
}
notification.onclick = onClick;
notification.onclose = onClose;
}
export async function sendOsNotification(
@@ -201,8 +170,9 @@ export async function sendOsNotification(
return false;
}
if (getTauri()) {
return await sendDesktopNotification(payload);
const desktopNotificationSender = getDesktopNotificationSender();
if (desktopNotificationSender) {
return await desktopNotificationSender(payload);
}
const NotificationConstructor = getWebNotificationConstructor();
@@ -212,8 +182,11 @@ export async function sendOsNotification(
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
icon: getWebNotificationIconUrl(),
}) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data);
if (hasNotificationClickTarget(payload.data)) {
attachWebClickHandler(notification, payload.data);
}
return true;
}
}
+2 -3
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsTauriMac()) return "mac";
if (getIsDesktopMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
@@ -15,4 +15,3 @@ export function getShortcutOs(): ShortcutOs {
/Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
}
@@ -1,55 +0,0 @@
import { getTauri } from "@/utils/tauri";
let attached = false;
export function attachConsole(): void {
if (attached || !getTauri()) {
return;
}
attached = true;
const originalLog = console.log;
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
const originalDebug = console.debug;
function formatArgs(args: unknown[]): string {
return args
.map((a) =>
typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
)
.join(" ");
}
function forwardToRust(level: number, args: unknown[]): void {
const invoke = getTauri()?.core?.invoke;
if (typeof invoke !== "function") return;
try {
void invoke("webview_log", { level, message: formatArgs(args) });
} catch {
// silently ignore IPC errors
}
}
console.log = (...args: unknown[]) => {
originalLog.apply(console, args);
forwardToRust(1, args); // info
};
console.info = (...args: unknown[]) => {
originalInfo.apply(console, args);
forwardToRust(1, args); // info
};
console.warn = (...args: unknown[]) => {
originalWarn.apply(console, args);
forwardToRust(2, args); // warn
};
console.error = (...args: unknown[]) => {
originalError.apply(console, args);
forwardToRust(3, args); // error
};
console.debug = (...args: unknown[]) => {
originalDebug.apply(console, args);
forwardToRust(0, args); // debug
};
}
-178
View File
@@ -1,178 +0,0 @@
import { Platform } from "react-native";
import { useState, useEffect } from "react";
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
let tauriWindow: any = null;
const NON_DRAGGABLE_SELECTOR = [
"button",
"a",
"input",
"textarea",
"select",
"[role='button']",
"[role='link']",
"[role='textbox']",
"[role='combobox']",
"[contenteditable='true']",
].join(", ");
async function getTauriWindow() {
if (tauriWindow) return tauriWindow;
// Double-check: both environment check AND platform check
if (!isTauriEnvironment()) {
return null;
}
try {
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
// the native (Hermes) builds and break parsing/runtime on mobile.
const tauri = getTauri();
if (!tauri) {
return null;
}
// Prefer the public global window module.
const publicWindow = getCurrentTauriWindow();
if (publicWindow) {
tauriWindow = publicWindow;
return tauriWindow;
}
// Fallback: core invoke (should exist when withGlobalTauri is enabled).
// We assume the main window label is "main" (default in Tauri when not specified).
const invoke = tauri?.core?.invoke;
if (typeof invoke === "function") {
tauriWindow = {
label: "main",
startDragging: () => invoke("plugin:window|start_dragging", { label: "main" }),
toggleMaximize: () => invoke("plugin:window|toggle_maximize", { label: "main" }),
isFullscreen: () => invoke("plugin:window|is_fullscreen", { label: "main" }),
onResized: (handler: (e: unknown) => void) => tauri?.event?.listen?.("tauri://resize", handler),
};
return tauriWindow;
}
return null;
} catch {
return null;
}
}
export async function startDragging() {
const win = await getTauriWindow();
if (win) {
try {
await win.startDragging();
} catch (error) {
console.warn("[TauriWindow] startDragging failed", error);
}
}
}
export async function toggleMaximize() {
const win = await getTauriWindow();
if (win) {
try {
await win.toggleMaximize();
} catch (error) {
console.warn("[TauriWindow] toggleMaximize failed", error);
}
}
}
// Returns event handlers for drag region behavior
export function useTauriDragHandlers() {
// Dragging should work on any desktop OS when running under Tauri.
if (Platform.OS !== "web" || !isTauriEnvironment()) {
return {};
}
return {
onMouseDown: (e: React.MouseEvent) => {
// Only handle primary button, ignore if clicking on interactive elements.
// Tauri docs recommend using `e.detail` on mousedown for double-click maximize.
if (e.defaultPrevented) return;
if (e.button !== 0) return;
const target = e.target instanceof Element ? e.target : null;
if (target?.closest(NON_DRAGGABLE_SELECTOR)) return;
// Prevent text selection when dragging
e.preventDefault();
// Double click to maximize, otherwise drag.
if (e.detail === 2) {
toggleMaximize();
} else {
startDragging();
}
},
};
}
// Hook that returns traffic light padding, accounting for fullscreen state
// In fullscreen, traffic lights are hidden so no padding is needed
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[TauriWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[TauriWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = await getTauriWindow();
if (!win) return;
// Check initial fullscreen state
const fullscreen = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fullscreen);
// Listen for resize events which include fullscreen changes
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
return () => {
disposed = true;
runCleanup();
};
}, []);
if (!getIsTauriMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return {
left: TAURI_TRAFFIC_LIGHT_WIDTH,
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
};
}
-106
View File
@@ -1,106 +0,0 @@
export type TauriNotificationPermission = "granted" | "denied" | "default";
export interface TauriDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface TauriDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
open?: (
options?: TauriDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface TauriCoreApi {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
convertFileSrc?: (path: string) => string;
}
export interface TauriEventApi {
listen?: (
event: string,
handler: (event: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowApi {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface TauriWindowModule {
getCurrentWindow?: () => TauriWindowApi;
}
export interface TauriOpenerApi {
openUrl?: (url: string) => Promise<void>;
}
export interface TauriNotificationApi {
isPermissionGranted?: () => Promise<boolean>;
requestPermission?: () => Promise<TauriNotificationPermission>;
sendNotification?: (
payload: string | { title: string; body?: string }
) => Promise<void>;
}
export interface TauriWebSocketApi {
connect?: (url: string, config?: unknown) => Promise<unknown>;
}
export interface TauriApi {
core?: TauriCoreApi;
dialog?: TauriDialogApi;
event?: TauriEventApi;
notification?: TauriNotificationApi;
opener?: TauriOpenerApi;
websocket?: TauriWebSocketApi;
window?: TauriWindowModule;
}
export function getTauri(): TauriApi | null {
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
if (!tauri || typeof tauri !== "object") {
return null;
}
return tauri as TauriApi;
}
export function isTauriEnvironment(): boolean {
return getTauri() !== null;
}
export function getCurrentTauriWindow(): TauriWindowApi | null {
const getter = getTauri()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}
@@ -5,8 +5,8 @@ import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport";
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -49,7 +49,7 @@ export async function buildClientConfig(
serverId?: string
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
+3 -3
View File
@@ -1,4 +1,4 @@
import { getTauri } from "@/utils/tauri";
import { isDesktop } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
@@ -300,12 +300,12 @@ export function createAudioEngine(
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown";
const isTauri = getTauri() !== null;
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isTauri) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);