chore: finalize electron desktop migration
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
+20
-20
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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;
|
||||
+49
-67
@@ -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 : () => {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+3
-4
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", "."],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user