diff --git a/package.json b/package.json index d8d7356b2..e5dbb7d7a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "ios": "npm run ios --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", - "build:desktop": "npm run build --workspace=@getpaseo/desktop", + "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop", "cli": "npx tsx packages/cli/src/index.js", "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dac040cba..eee47d6aa 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client"; import { WEB_NOTIFICATION_CLICK_EVENT, type WebNotificationClickDetail, + ensureOsNotificationPermission, } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildHostAgentDraftRoute, parseHostAgentRouteFromPathname, } from "@/utils/host-routes"; +import { getTauri } from "@/utils/tauri"; polyfillCrypto(); @@ -57,6 +59,15 @@ function PushNotificationRouter() { useEffect(() => { if (Platform.OS === "web") { + if (getTauri()) { + void ensureOsNotificationPermission().then((granted) => { + console.log( + "[OSNotifications][Tauri] Startup permission preflight result:", + granted ? "granted" : "not-granted" + ); + }); + } + const target = globalThis as unknown as EventTarget; const openFromWebClick = (event: Event) => { const customEvent = event as CustomEvent; diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 391c880de..6a901a92c 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,4 +1,5 @@ import { Platform } from "react-native"; +import { getTauri } from "@/utils/tauri"; export const FOOTER_HEIGHT = 75; @@ -19,21 +20,20 @@ export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56; // Check if running in Tauri desktop app (any OS) function isTauri(): boolean { if (Platform.OS !== "web") return false; - if (typeof window === "undefined") return false; - return "__TAURI__" in window; + return getTauri() !== null; } // Check if running in Tauri desktop app on macOS function isTauriMac(): boolean { if (Platform.OS !== "web") return false; if (typeof window === "undefined") return false; - if (!("__TAURI__" in window)) 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"); } -// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later) +// 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; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e203c0457..8a218525c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -351,6 +351,15 @@ export function SessionProvider({ const isActive = appState ? appState === "active" : true; const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId; if (!isAwayFromAgent) { + console.log( + "[OSNotifications] Skipping attention notification: user already focused on agent", + { + agentId: params.agentId, + reason: params.reason, + appState, + focusedAgentId, + } + ); return; } diff --git a/packages/app/src/hooks/use-audio-recorder.web.ts b/packages/app/src/hooks/use-audio-recorder.web.ts index a458bfc25..bc9fe5bc7 100644 --- a/packages/app/src/hooks/use-audio-recorder.web.ts +++ b/packages/app/src/hooks/use-audio-recorder.web.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; +import { getTauri } from "@/utils/tauri"; export interface AudioCaptureConfig { sampleRate?: number; @@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[AudioRecorder][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + + if (!secureContext && !isTauri) { throw new Error( `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` ); } + if (!secureContext && isTauri) { + console.warn( + "[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const options = configRef.current; const constraints: MediaStreamConstraints = { diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index 46fdd34da..d3c737b03 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getTauri } from "@/utils/tauri"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; @@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[DictationAudio][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + if (!secureContext && !isTauri) { throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); } + if (!secureContext && isTauri) { + console.warn( + "[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 52431df35..812b04491 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { getIsTauriMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; +import { getCurrentTauriWindow } from "@/utils/tauri"; type FaviconStatus = "none" | "running" | "attention"; type ColorScheme = "dark" | "light"; @@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme { } async function updateMacDockBadge(count?: number) { - if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return; + if (Platform.OS !== "web" || !getIsTauriMac()) return; - const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + const tauriWindow = getCurrentTauriWindow(); if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") { return; } diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index b58e93e0a..138b95af1 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,6 +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"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -52,10 +53,6 @@ function isImageFile(file: File): boolean { return file.type.startsWith("image/"); } -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; -} - function getFileExtension(path: string): string { const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; const extensionIndex = normalizedPath.lastIndexOf("."); @@ -72,7 +69,7 @@ function isImagePath(path: string): boolean { function filePathToImageAttachment(path: string): ImageAttachment { const extension = getFileExtension(path); const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; - const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; + const convertFileSrc = getTauri()?.core?.convertFileSrc; const uri = typeof convertFileSrc === "function" ? convertFileSrc(path) : path; @@ -145,11 +142,11 @@ export function useFileDropZone({ } async function setupTauriDragDrop(): Promise { - if (!isTauriEnvironment()) { + if (getTauri() === null) { return false; } - const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + const tauriWindow = getCurrentTauriWindow(); if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { return false; } diff --git a/packages/app/src/hooks/use-speechmatics-audio.web.ts b/packages/app/src/hooks/use-speechmatics-audio.web.ts index d05661f5a..8f6f312cb 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.web.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.web.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SpeechSegmenter } from "@/voice/speech-segmenter"; +import { getTauri } from "@/utils/tauri"; export interface SpeechmaticsAudioConfig { onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; @@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat ? window.isSecureContext : true; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isTauri = getTauri() !== null; try { if (missingNavigator) { throw new Error("Microphone capture is not supported in this environment"); } - if (!secureContext) { + console.log("[Voice][Web] Microphone preflight", { + secureContext, + currentOrigin, + isTauri, + hasMediaDevices: + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === "function", + }); + if (!secureContext && !isTauri) { throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); } + if (!secureContext && isTauri) { + console.warn( + "[Voice][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", + { currentOrigin } + ); + } const AudioContextCtor = getAudioContextCtor(); if (!AudioContextCtor) { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9c8764a17..939ee4ba6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -13,7 +13,7 @@ import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useQueries } from "@tanstack/react-query"; -import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; +import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; @@ -35,6 +35,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { + getDesktopPermissionSnapshot, + requestDesktopPermission, + shouldShowDesktopPermissionSection, + type DesktopPermissionKind, + type DesktopPermissionSnapshot, + type DesktopPermissionStatus, +} from "@/utils/desktop-permissions"; const delay = (ms: number) => new Promise((resolve) => { @@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - letterSpacing: 0.6, marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, @@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - letterSpacing: 0.4, marginBottom: theme.spacing[2], }, input: { @@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, - audioRowDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: 2, - }, - // Footer - footer: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[4], + permissionSectionHeader: { + flexDirection: "row", alignItems: "center", - gap: theme.spacing[3], + justifyContent: "space-between", + gap: theme.spacing[2], + marginBottom: theme.spacing[3], }, - footerAppInfo: { + permissionRefreshButton: { + width: 34, + height: 34, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, alignItems: "center", + justifyContent: "center", + }, + permissionRefreshButtonDisabled: { + opacity: theme.opacity[50], + }, + permissionRowActions: { + alignItems: "flex-end", gap: theme.spacing[1], }, - footerText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + permissionStatusPill: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + minWidth: 88, + justifyContent: "center", }, - footerVersion: { + permissionStatusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + permissionDetailText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, + maxWidth: 220, + textAlign: "right", }, - resetButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - resetButtonText: { - color: theme.colors.palette.red[500], + aboutValue: { + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, }, // Empty state emptyCard: { @@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({ // Theme toggle themeToggleContainer: { flexDirection: "row", - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - padding: 4, - gap: 4, + backgroundColor: theme.colors.surface3, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + padding: 2, + gap: 2, }, themeToggleButton: { - flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + gap: theme.spacing[1], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.sm, }, themeToggleButtonActive: { - backgroundColor: theme.colors.surface3, + backgroundColor: theme.colors.surface2, }, themeToggleText: { - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, @@ -417,7 +443,7 @@ export default function SettingsScreen() { const insets = useSafeAreaInsets(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; - const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); + const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { daemons, isLoading: daemonLoading, @@ -436,7 +462,14 @@ export default function SettingsScreen() { const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); + const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] = + useState(null); + const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] = + useState(false); + const [requestingDesktopPermission, setRequestingDesktopPermission] = + useState(null); const isLoading = settingsLoading || daemonLoading; + const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; @@ -534,6 +567,73 @@ export default function SettingsScreen() { pendingEditReopenServerId, ]); + const refreshDesktopPermissions = useCallback(async () => { + if (!showDesktopPermissionSection) return; + + setIsRefreshingDesktopPermissions(true); + try { + const snapshot = await getDesktopPermissionSnapshot(); + if (!isMountedRef.current) return; + setDesktopPermissionSnapshot(snapshot); + } catch (error) { + console.error("[Settings] Failed to load desktop permission status", error); + } finally { + if (isMountedRef.current) { + setIsRefreshingDesktopPermissions(false); + } + } + }, [showDesktopPermissionSection]); + + const handleRequestDesktopPermission = useCallback( + async (kind: DesktopPermissionKind) => { + if (!showDesktopPermissionSection) return; + + setRequestingDesktopPermission(kind); + try { + const status = await requestDesktopPermission({ kind }); + if (!isMountedRef.current) return; + setDesktopPermissionSnapshot((previous) => { + const base: DesktopPermissionSnapshot = previous ?? { + checkedAt: Date.now(), + notifications: { + state: "unknown", + detail: "Notification status has not been checked yet.", + }, + microphone: { + state: "unknown", + detail: "Microphone status has not been checked yet.", + }, + }; + + return kind === "notifications" + ? { + ...base, + checkedAt: Date.now(), + notifications: status, + } + : { + ...base, + checkedAt: Date.now(), + microphone: status, + }; + }); + } catch (error) { + console.error(`[Settings] Failed to request ${kind} permission`, error); + } finally { + if (isMountedRef.current) { + setRequestingDesktopPermission(null); + } + await refreshDesktopPermissions(); + } + }, + [refreshDesktopPermissions, showDesktopPermissionSection] + ); + + useEffect(() => { + if (!showDesktopPermissionSection) return; + void refreshDesktopPermissions(); + }, [refreshDesktopPermissions, showDesktopPermissionSection]); + const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { if (!editingServerId) return; if (isSavingEdit) return; @@ -590,34 +690,6 @@ export default function SettingsScreen() { [updateSettings] ); - function handleReset() { - Alert.alert( - "Reset settings", - "Are you sure you want to reset all settings to defaults?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Reset", - style: "destructive", - onPress: async () => { - try { - await resetSettings(); - Alert.alert( - "Settings reset", - "All settings have been reset to defaults." - ); - } catch (error) { - Alert.alert( - "Error", - "Failed to reset settings. Please try again." - ); - } - }, - }, - ] - ); - } - const restartConfirmationMessage = "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; @@ -804,55 +876,113 @@ export default function SettingsScreen() { {/* Appearance */} Appearance - - handleThemeChange("light")} - > - - - Light - - - handleThemeChange("dark")} - > - - - Dark - - - handleThemeChange("auto")} - > - - - System - - + + + + Theme + + + handleThemeChange("light")} + > + + + Light + + + handleThemeChange("dark")} + > + + + Dark + + + handleThemeChange("auto")} + > + + + System + + + + - {/* Footer */} - - - Paseo - Version {appVersion} + {showDesktopPermissionSection ? ( + + + + Desktop permissions + + [ + styles.permissionRefreshButton, + (isRefreshingDesktopPermissions || + requestingDesktopPermission !== null) && + styles.permissionRefreshButtonDisabled, + pressed && { opacity: 0.85 }, + ]} + onPress={() => { + void refreshDesktopPermissions(); + }} + disabled={ + isRefreshingDesktopPermissions || + requestingDesktopPermission !== null + } + accessibilityRole="button" + accessibilityLabel="Refresh desktop permissions" + > + + + + + { + void handleRequestDesktopPermission("notifications"); + }} + /> + { + void handleRequestDesktopPermission("microphone"); + }} + /> + + + ) : null} + + {/* About */} + + About + + + + Version + + {appVersion} + - - Reset to defaults - @@ -1228,6 +1358,60 @@ function HostDetailModal({ ); } +interface DesktopPermissionRowProps { + title: string; + status: DesktopPermissionStatus | null; + isRequesting: boolean; + showBorder?: boolean; + onRequest: () => void; +} + +function DesktopPermissionRow({ + title, + status, + isRequesting, + showBorder, + onRequest, +}: DesktopPermissionRowProps) { + const { theme } = useUnistyles(); + const state = status?.state ?? "unknown"; + const isGranted = state === "granted"; + const shouldShowDetail = + status !== null && + status.detail.trim().length > 0 && + state !== "granted" && + state !== "prompt" && + state !== "not-granted"; + + return ( + + + {title} + + + {isGranted ? ( + + + Granted + + ) : ( + + )} + {shouldShowDetail ? ( + {status?.detail} + ) : null} + + + ); +} + function ConnectionRow({ connection, latencyMs, diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts index fdc7adf39..15a3515a4 100644 --- a/packages/app/src/utils/confirm-dialog.ts +++ b/packages/app/src/utils/confirm-dialog.ts @@ -1,4 +1,5 @@ import { Alert, Platform } from "react-native"; +import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri"; export interface ConfirmDialogInput { title: string; @@ -8,26 +9,6 @@ export interface ConfirmDialogInput { destructive?: boolean; } -interface TauriDialogAskOptions { - title?: string; - okLabel?: string; - cancelLabel?: string; - kind?: "info" | "warning" | "error"; -} - -interface TauriDialogApi { - ask?: (message: string, options?: TauriDialogAskOptions) => Promise; -} - -interface TauriCoreApi { - invoke?: (command: string, args?: Record) => Promise; -} - -interface TauriGlobalApi { - dialog?: TauriDialogApi; - core?: TauriCoreApi; -} - interface ConfirmButtonConfig { confirmLabel: string; cancelLabel: string; @@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise ({ Platform: { OS: platform } })) + return import('./desktop-permissions') +} + +describe('desktop-permissions', () => { + afterEach(() => { + vi.doUnmock('react-native') + vi.restoreAllMocks() + vi.resetModules() + restoreGlobals() + }) + + it('shows section only in Tauri web runtime', async () => { + const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web') + + expect(shouldShowDesktopPermissionSection()).toBe(false) + + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} } + expect(shouldShowDesktopPermissionSection()).toBe(true) + }) + + it('reads notification and microphone status', async () => { + const isPermissionGranted = vi.fn(async () => false) + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { isPermissionGranted }, + } + setNavigator({ + permissions: { + query: vi.fn(async () => ({ state: 'granted' })), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.notifications.state).toBe('not-granted') + expect(snapshot.microphone.state).toBe('granted') + expect(isPermissionGranted).toHaveBeenCalledTimes(1) + expect(snapshot.checkedAt).toBeTypeOf('number') + }) + + it('queries microphone permission with correct Permissions instance binding', async () => { + const permissions = { + query(this: unknown, _descriptor: { name: string }) { + if (this !== permissions) { + throw new TypeError( + 'Can only call Permissions.query on instances of Permissions' + ) + } + return Promise.resolve({ state: 'granted' as const }) + }, + } + + setNavigator({ + permissions, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.microphone.state).toBe('granted') + }) + + it('returns a fallback message when runtime blocks Permissions.query', async () => { + setNavigator({ + permissions: { + query: vi.fn(async () => { + throw new TypeError( + 'Can only call Permissions.query on instances of Permissions' + ) + }), + }, + mediaDevices: { + getUserMedia: vi.fn(), + }, + }) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.microphone.state).toBe('unknown') + expect(snapshot.microphone.detail).toContain( + 'Microphone status API is unavailable in this runtime.' + ) + }) + + it('requests notification permission via Tauri', async () => { + const requestPermission = vi.fn(async () => 'granted') + ;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { requestPermission }, + } + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'notifications' }) + + expect(result.state).toBe('granted') + expect(requestPermission).toHaveBeenCalledTimes(1) + }) + + it('falls back to browser Notification permission when Tauri API is unavailable', async () => { + class MockNotification { + static permission = 'denied' + } + ;(globalThis as { Notification?: unknown }).Notification = MockNotification + setNavigator({}) + + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') + const snapshot = await getDesktopPermissionSnapshot() + + expect(snapshot.notifications.state).toBe('denied') + }) + + it('requests microphone permission and stops acquired tracks', async () => { + const stop = vi.fn() + const getUserMedia = vi.fn(async () => ({ + getTracks: () => [{ stop }], + })) + setNavigator({ + permissions: { + query: vi.fn(async () => ({ state: 'granted' })), + }, + mediaDevices: { + getUserMedia, + }, + }) + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'microphone' }) + + expect(result.state).toBe('granted') + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('maps microphone request denial to denied status', async () => { + setNavigator({ + mediaDevices: { + getUserMedia: vi.fn(async () => { + throw { name: 'NotAllowedError', message: 'denied' } + }), + }, + }) + + const { requestDesktopPermission } = await loadModuleForPlatform('web') + const result = await requestDesktopPermission({ kind: 'microphone' }) + + expect(result.state).toBe('denied') + }) +}) diff --git a/packages/app/src/utils/desktop-permissions.ts b/packages/app/src/utils/desktop-permissions.ts new file mode 100644 index 000000000..7632fa89c --- /dev/null +++ b/packages/app/src/utils/desktop-permissions.ts @@ -0,0 +1,387 @@ +import { Platform } from 'react-native' +import { getTauri, type TauriNotificationPermission } from '@/utils/tauri' + +export type DesktopPermissionKind = 'notifications' | 'microphone' + +export type DesktopPermissionState = + | 'granted' + | 'denied' + | 'prompt' + | 'not-granted' + | 'unavailable' + | 'unknown' + +export interface DesktopPermissionStatus { + state: DesktopPermissionState + detail: string +} + +export interface DesktopPermissionSnapshot { + checkedAt: number + notifications: DesktopPermissionStatus + microphone: DesktopPermissionStatus +} + +type NotificationConstructorLike = { + permission?: string + requestPermission?: () => Promise +} + +type MediaStreamTrackLike = { + stop?: () => void +} + +type MediaStreamLike = { + getTracks?: () => MediaStreamTrackLike[] +} + +type NavigatorLike = { + mediaDevices?: { + getUserMedia?: (constraints: { audio: boolean }) => Promise + } + permissions?: { + query?: (descriptor: { name: string }) => Promise<{ state?: string }> + } +} + +export function shouldShowDesktopPermissionSection(): boolean { + return Platform.OS === 'web' && getTauri() !== null +} + +function status(input: DesktopPermissionStatus): DesktopPermissionStatus { + return input +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === 'string') { + return error.message + } + return String(error) +} + +function getErrorName(error: unknown): string | null { + if (!isObject(error)) { + return null + } + const name = error.name + return typeof name === 'string' && name.length > 0 ? name : null +} + +function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { + const message = getErrorMessage(error) + if ( + message.includes('Can only call Permissions.query on instances of Permissions') || + message.includes('Illegal invocation') + ) { + return true + } + return false +} + +function getWebNotificationConstructor(): NotificationConstructorLike | null { + if (Platform.OS !== 'web') { + return null + } + const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification + if ( + NotificationConstructor == null || + (typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object') + ) { + return null + } + return NotificationConstructor as NotificationConstructorLike +} + +function getNavigatorLike(): NavigatorLike | null { + if (Platform.OS !== 'web') { + return null + } + const webNavigator = (globalThis as { navigator?: unknown }).navigator + if (!isObject(webNavigator)) { + return null + } + return webNavigator as NavigatorLike +} + +function mapNotificationPermissionString(permission: string): 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.', + }) + } + if (permission === 'default') { + return status({ + state: 'prompt', + detail: 'Notifications have not been granted yet.', + }) + } + return status({ + state: 'unknown', + detail: `Unexpected notification permission state: ${permission}`, + }) +} + +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 { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop notification status is only available on web runtime.', + }) + } + + const tauriNotification = getTauri()?.notification + if (tauriNotification) { + if (typeof tauriNotification.isPermissionGranted !== 'function') { + return status({ + state: 'unavailable', + detail: 'Tauri notification plugin is missing isPermissionGranted().', + }) + } + + try { + const granted = await tauriNotification.isPermissionGranted() + if (granted) { + return status({ + state: 'granted', + detail: 'Tauri reports notifications are granted.', + }) + } + 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)}`, + }) + } + } + + const NotificationConstructor = getWebNotificationConstructor() + if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') { + return status({ + state: 'unavailable', + detail: 'Web Notification API is unavailable in this environment.', + }) + } + + return mapNotificationPermissionString(NotificationConstructor.permission) +} + +async function getMicrophonePermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop microphone status is only available on web runtime.', + }) + } + + const webNavigator = getNavigatorLike() + if (!webNavigator) { + return status({ + state: 'unavailable', + detail: 'Navigator is unavailable in this environment.', + }) + } + + const permissionsApi = webNavigator.permissions + if (permissionsApi && typeof permissionsApi.query === 'function') { + try { + const result = await permissionsApi.query({ name: 'microphone' }) + if (result?.state === 'granted') { + return status({ + state: 'granted', + detail: 'Microphone access is granted.', + }) + } + if (result?.state === 'denied') { + return status({ + state: 'denied', + detail: 'Microphone access is denied in system settings.', + }) + } + if (result?.state === 'prompt') { + return status({ + state: 'prompt', + detail: 'Microphone permission has not been granted yet.', + }) + } + return status({ + state: 'unknown', + detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`, + }) + } catch (error) { + if (isPermissionsQueryRuntimeUnsupported(error)) { + return status({ + state: 'unknown', + detail: + 'Microphone status API is unavailable in this runtime. Use Request to check access.', + }) + } + return status({ + state: 'unknown', + detail: `Failed to query microphone status: ${getErrorMessage(error)}`, + }) + } + } + + if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + return status({ + state: 'unavailable', + detail: 'Microphone capture is unavailable in this environment.', + }) + } + + return status({ + state: 'unknown', + detail: 'Permission status API is unavailable. Use Request to check access.', + }) +} + +async function requestNotificationPermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop notification requests are only available on web runtime.', + }) + } + + const tauriNotification = getTauri()?.notification + if (tauriNotification) { + if (typeof tauriNotification.requestPermission !== 'function') { + return status({ + state: 'unavailable', + detail: 'Tauri notification plugin is missing requestPermission().', + }) + } + + try { + const permission = await tauriNotification.requestPermission() + return mapTauriNotificationPermissionResult(permission) + } catch (error) { + return status({ + state: 'unknown', + detail: `Failed to request notification permission: ${getErrorMessage(error)}`, + }) + } + } + + 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)}`, + }) + } +} + +async function requestMicrophonePermissionStatus(): Promise { + if (Platform.OS !== 'web') { + return status({ + state: 'unavailable', + detail: 'Desktop microphone requests are only available on web runtime.', + }) + } + + const webNavigator = getNavigatorLike() + if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + return status({ + state: 'unavailable', + detail: 'Microphone capture API is unavailable in this environment.', + }) + } + + try { + const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }) + const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : [] + tracks.forEach((track) => { + if (typeof track.stop === 'function') { + track.stop() + } + }) + return await getMicrophonePermissionStatus() + } catch (error) { + const errorName = getErrorName(error) + if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') { + return status({ + state: 'denied', + detail: 'Microphone permission was denied by the user or system.', + }) + } + if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') { + return status({ + state: 'unavailable', + detail: 'No microphone device was found.', + }) + } + return status({ + state: 'unknown', + detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, + }) + } +} + +export async function requestDesktopPermission(input: { + kind: DesktopPermissionKind +}): Promise { + if (input.kind === 'notifications') { + return await requestNotificationPermissionStatus() + } + return await requestMicrophonePermissionStatus() +} + +export async function getDesktopPermissionSnapshot(): Promise { + const [notifications, microphone] = await Promise.all([ + getNotificationPermissionStatus(), + getMicrophonePermissionStatus(), + ]) + + return { + checkedAt: Date.now(), + notifications, + microphone, + } +} diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index ee8318456..f27307a70 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -1,23 +1,13 @@ import * as Linking from "expo-linking"; import { Platform } from "react-native"; -import { getIsTauri } from "@/constants/layout"; - -interface TauriWindowWithOpener { - __TAURI__?: { - opener?: { - openUrl?: (url: string) => Promise; - }; - }; -} +import { getTauri } from "@/utils/tauri"; export async function openExternalUrl(url: string): Promise { if (Platform.OS === "web") { - if (typeof window !== "undefined" && getIsTauri()) { - const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; - if (typeof opener === "function") { - await opener(url); - return; - } + const opener = getTauri()?.opener?.openUrl; + if (typeof opener === "function") { + await opener(url); + return; } window.open(url, "_blank", "noopener,noreferrer"); diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index 2d3ed3f03..2f53dfc6c 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -18,6 +18,7 @@ type GlobalSnapshot = { dispatchEvent: unknown; focus: unknown; location: unknown; + __TAURI__: unknown; }; const originalGlobals: GlobalSnapshot = { @@ -26,6 +27,7 @@ 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") { @@ -40,6 +42,7 @@ 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", () => { @@ -163,4 +166,33 @@ describe("sendOsNotification", () => { expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); }); + + it("uses Tauri notification module when available", async () => { + const isPermissionGranted = vi.fn(async () => false); + const requestPermission = vi.fn(async () => "granted"); + const sendNotification = vi.fn(async () => undefined); + + (globalThis as { __TAURI__?: unknown }).__TAURI__ = { + notification: { + isPermissionGranted, + requestPermission, + sendNotification, + }, + }; + + const { sendOsNotification } = await loadModuleForPlatform("web"); + const sent = await sendOsNotification({ + title: "Agent finished", + body: "Done", + data: { serverId: "srv-1", agentId: "agent-1" }, + }); + + expect(sent).toBe(true); + expect(isPermissionGranted).toHaveBeenCalledTimes(1); + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledWith({ + title: "Agent finished", + body: "Done", + }); + }); }); diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index 8c3629568..e951e688c 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,5 +1,6 @@ import { Platform } from "react-native"; import { buildNotificationRoute } from "./notification-routing"; +import { getTauri, type TauriNotificationApi } from "@/utils/tauri"; type OsNotificationPayload = { title: string; @@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click"; let permissionRequest: Promise | null = null; +function getTauriNotificationModule(): TauriNotificationApi | null { + if (Platform.OS !== "web") { + return null; + } + return getTauri()?.notification ?? null; +} + function getWebNotificationConstructor(): { permission: string; requestPermission?: () => Promise; @@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise { return result; } +export async function ensureOsNotificationPermission(): Promise { + if (Platform.OS !== "web") { + return false; + } + + const tauriNotification = getTauriNotificationModule(); + if (tauriNotification) { + return await ensureTauriNotificationPermission(tauriNotification); + } + + return await ensureNotificationPermission(); +} + +async function ensureTauriNotificationPermission( + notificationModule: TauriNotificationApi +): Promise { + if (typeof notificationModule.isPermissionGranted === "function") { + try { + const granted = await notificationModule.isPermissionGranted(); + if (granted) { + console.log("[OSNotifications][Tauri] Permission already granted"); + return true; + } + } catch (error) { + console.warn( + "[OSNotifications][Tauri] Failed to check notification permission", + error + ); + } + } + + if (typeof notificationModule.requestPermission !== "function") { + console.warn( + "[OSNotifications][Tauri] notification.requestPermission is unavailable" + ); + return false; + } + + try { + const result = await notificationModule.requestPermission(); + console.log("[OSNotifications][Tauri] requestPermission result:", result); + return result === "granted"; + } catch (error) { + console.warn( + "[OSNotifications][Tauri] Failed to request notification permission", + error + ); + return false; + } +} + +async function sendTauriNotification( + payload: OsNotificationPayload, + notificationModule: TauriNotificationApi +): Promise { + if (typeof notificationModule.sendNotification !== "function") { + console.warn( + "[OSNotifications][Tauri] notification.sendNotification is unavailable" + ); + return false; + } + + const granted = await ensureTauriNotificationPermission(notificationModule); + if (!granted) { + console.log("[OSNotifications][Tauri] Permission not granted"); + return false; + } + + try { + await notificationModule.sendNotification({ + title: payload.title, + body: payload.body, + }); + return true; + } catch (error) { + console.warn("[OSNotifications][Tauri] Failed to send notification", error); + return false; + } +} + function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean { const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent; const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }) @@ -127,12 +215,28 @@ export async function sendOsNotification( return false; } + const tauriNotification = getTauriNotificationModule(); + if (tauriNotification) { + console.log("[OSNotifications] Using Tauri notification module"); + return await sendTauriNotification(payload, tauriNotification); + } + const NotificationConstructor = getWebNotificationConstructor(); if (!NotificationConstructor) { + console.log( + "[OSNotifications][Web] Notification constructor unavailable", + typeof window !== "undefined" && window.location + ? window.location.origin + : "unknown-origin" + ); return false; } - const granted = await ensureNotificationPermission(); + const granted = await ensureOsNotificationPermission(); if (!granted) { + console.log( + "[OSNotifications][Web] Permission not granted:", + NotificationConstructor.permission + ); return false; } const notification = new NotificationConstructor(payload.title, { diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts index 1f97193af..c23a07cbe 100644 --- a/packages/app/src/utils/tauri-daemon-transport.ts +++ b/packages/app/src/utils/tauri-daemon-transport.ts @@ -1,4 +1,5 @@ import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client"; +import { getTauri } from "@/utils/tauri"; type TauriWebSocketMessage = | { type: "Text"; data: string } @@ -29,15 +30,8 @@ function toTauriOutgoingMessage( return Array.from(data); } -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; -} - function getTauriWebSocketModule(): TauriWebSocketModule | null { - if (!isTauriEnvironment()) { - return null; - } - const ws = (window as any).__TAURI__?.websocket; + const ws = getTauri()?.websocket; if (ws && typeof ws.connect === "function") { return ws as TauriWebSocketModule; } @@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null { } export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { - if (!isTauriEnvironment()) { + if (getTauri() === null) { return null; } @@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | } if (!module) { throw new Error( - "Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" + "Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" ); } diff --git a/packages/app/src/utils/tauri-window.ts b/packages/app/src/utils/tauri-window.ts index 41a8f86aa..4ebd60235 100644 --- a/packages/app/src/utils/tauri-window.ts +++ b/packages/app/src/utils/tauri-window.ts @@ -1,15 +1,10 @@ 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; -// Runtime check for Tauri environment -function isTauriEnvironment(): boolean { - return typeof window !== "undefined" && - (window as any).__TAURI__ !== undefined; -} - async function getTauriWindow() { if (tauriWindow) return tauriWindow; @@ -19,15 +14,18 @@ async function getTauriWindow() { } try { - // When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`. + // 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 = (window as any).__TAURI__ as any; + const tauri = getTauri(); + if (!tauri) { + return null; + } // Prefer the public global window module. - const windowModule = tauri?.window; - if (windowModule && typeof windowModule.getCurrentWindow === "function") { - tauriWindow = windowModule.getCurrentWindow(); + const publicWindow = getCurrentTauriWindow(); + if (publicWindow) { + tauriWindow = publicWindow; return tauriWindow; } diff --git a/packages/app/src/utils/tauri.ts b/packages/app/src/utils/tauri.ts new file mode 100644 index 000000000..a62221e98 --- /dev/null +++ b/packages/app/src/utils/tauri.ts @@ -0,0 +1,92 @@ +export type TauriNotificationPermission = "granted" | "denied" | "default"; + +export interface TauriDialogAskOptions { + title?: string; + okLabel?: string; + cancelLabel?: string; + kind?: "info" | "warning" | "error"; +} + +export interface TauriDialogApi { + ask?: (message: string, options?: TauriDialogAskOptions) => Promise; +} + +export interface TauriCoreApi { + invoke?: (command: string, args?: Record) => Promise; + convertFileSrc?: (path: string) => string; +} + +export interface TauriEventApi { + listen?: ( + event: string, + handler: (event: unknown) => void + ) => Promise<() => void> | (() => void); +} + +export interface TauriWindowApi { + label?: string; + startDragging?: () => Promise; + toggleMaximize?: () => Promise; + isFullscreen?: () => Promise; + onResized?: ( + handler: (event: TEvent) => void + ) => Promise<() => void> | (() => void); + setBadgeCount?: (count?: number) => Promise; + onDragDropEvent?: ( + handler: (event: TEvent) => void + ) => Promise<() => void> | (() => void); +} + +export interface TauriWindowModule { + getCurrentWindow?: () => TauriWindowApi; +} + +export interface TauriOpenerApi { + openUrl?: (url: string) => Promise; +} + +export interface TauriNotificationApi { + isPermissionGranted?: () => Promise; + requestPermission?: () => Promise; + sendNotification?: ( + payload: string | { title: string; body?: string } + ) => Promise; +} + +export interface TauriWebSocketApi { + connect?: (url: string, config?: unknown) => Promise; +} + +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; + } +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 9b8df883e..507ee0f13 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2028,6 +2028,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2164,6 +2176,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2512,7 +2538,7 @@ dependencies = [ [[package]] name = "paseo" -version = "0.1.0" +version = "0.1.13" dependencies = [ "log", "serde", @@ -2521,6 +2547,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-websocket", ] @@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2858,6 +2885,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4035,6 +4071,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4178,6 +4233,18 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.25.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index b69373ca7..3c9cf0704 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "paseo" -version = "0.1.0" +version = "0.1.13" description = "Paseo Desktop" authors = ["moboudra"] license = "MIT" @@ -28,5 +28,7 @@ log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-dialog = "2" tauri-plugin-log = "2" +tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-websocket = "2" + diff --git a/packages/desktop/src-tauri/Entitlements.plist b/packages/desktop/src-tauri/Entitlements.plist new file mode 100644 index 000000000..d637c5893 --- /dev/null +++ b/packages/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/packages/desktop/src-tauri/Info.plist b/packages/desktop/src-tauri/Info.plist index 343992bb6..f39ec3ac4 100644 --- a/packages/desktop/src-tauri/Info.plist +++ b/packages/desktop/src-tauri/Info.plist @@ -7,9 +7,9 @@ CFBundleName Paseo CFBundleShortVersionString - 0.1.0 + 0.1.13 CFBundleVersion - 0.1.0 + 0.1.13 NSMicrophoneUsageDescription Paseo needs access to your microphone for voice dictation and voice mode. diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 21bd32027..8e304233a 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -17,6 +17,7 @@ "core:window:allow-set-badge-count", "core:window:allow-toggle-maximize", "dialog:default", + "notification:default", "opener:default", "websocket:default" ] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 7c12e3e32..b10ebbe1b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu}; +#[cfg(target_os = "macos")] +use tauri::menu::AboutMetadata; use tauri::Manager; use tauri::WebviewWindow; @@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_websocket::init()) .setup(|app| { @@ -38,6 +41,30 @@ pub fn run() { // responder-chain shortcuts across the whole app. let menu = Menu::default(app.handle())?; + #[cfg(target_os = "macos")] + { + let app_menu = menu.items()?.into_iter().find_map(|item| match item { + MenuItemKind::Submenu(submenu) => Some(submenu), + _ => None, + }); + + if let Some(submenu) = app_menu { + // Tauri's default about item sets only `version`, which macOS renders as + // "Version ()". Set only `short_version` instead. + let about_metadata = AboutMetadata { + name: Some(app.package_info().name.clone()), + short_version: Some(app.package_info().version.to_string()), + copyright: app.config().bundle.copyright.clone(), + ..Default::default() + }; + let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?; + + if submenu.remove_at(0)?.is_some() { + submenu.insert(&about, 0)?; + } + } + } + let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In") .accelerator("CmdOrCtrl+=") .build(app)?; diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 8c38d1778..549683263 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2,8 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle), -// so we must embed an Info.plist containing `NSMicrophoneUsageDescription` for WebKit -// microphone access to trigger the OS permission prompt in dev. +// so we must embed an Info.plist containing usage descriptions for WebKit media +// permission prompts in dev. #[cfg(all(target_os = "macos", debug_assertions))] tauri::embed_plist::embed_info_plist!("../Info.plist"); diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index d357f33ac..f69d75b0d 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Paseo", - "version": "0.1.0", + "version": "0.1.13", "identifier": "dev.paseo.desktop", "build": { "frontendDist": "../../app/dist", @@ -20,7 +20,10 @@ "fullscreen": false, "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 16, "y": 22 } + "trafficLightPosition": { + "x": 16, + "y": 22 + } } ], "security": { @@ -38,6 +41,7 @@ "icons/icon.ico" ], "macOS": { + "entitlements": "Entitlements.plist", "infoPlist": "Info.plist" } } diff --git a/scripts/sync-workspace-versions.mjs b/scripts/sync-workspace-versions.mjs index cecb16a34..e1f6201ef 100644 --- a/scripts/sync-workspace-versions.mjs +++ b/scripts/sync-workspace-versions.mjs @@ -23,6 +23,106 @@ const dependencySections = [ const touched = [] +function syncDesktopTauriConfigVersion(version) { + const tauriConfigPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'tauri.conf.json' + ) + + if (!existsSync(tauriConfigPath)) { + return + } + + const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8')) + if (tauriConfig.version === version) { + return + } + + tauriConfig.version = version + writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`) + touched.push(path.relative(rootDir, tauriConfigPath)) +} + +function syncDesktopCargoPackageVersion(version) { + const cargoTomlPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'Cargo.toml' + ) + + if (!existsSync(cargoTomlPath)) { + return + } + + const cargoLines = readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/) + let inPackageSection = false + let updated = false + + const nextLines = cargoLines.map((line) => { + if (/^\[package\]\s*$/.test(line)) { + inPackageSection = true + return line + } + + if (inPackageSection && /^\[/.test(line)) { + inPackageSection = false + return line + } + + if (inPackageSection && /^version\s*=\s*".*"\s*$/.test(line)) { + const expectedLine = `version = "${version}"` + if (line === expectedLine) { + return line + } + updated = true + return expectedLine + } + + return line + }) + + if (!updated) { + return + } + + writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`) + touched.push(path.relative(rootDir, cargoTomlPath)) +} + +function syncDesktopInfoPlistVersion(version) { + const infoPlistPath = path.join( + rootDir, + 'packages', + 'desktop', + 'src-tauri', + 'Info.plist' + ) + + if (!existsSync(infoPlistPath)) { + return + } + + const current = readFileSync(infoPlistPath, 'utf8') + + const shortVersionPattern = /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/string>)/ + const bundleVersionPattern = /(CFBundleVersion<\/key>\s*)([^<]+)(<\/string>)/ + + let next = current.replace(shortVersionPattern, `$1${version}$3`) + next = next.replace(bundleVersionPattern, `$1${version}$3`) + + if (next === current) { + return + } + + writeFileSync(infoPlistPath, next) + touched.push(path.relative(rootDir, infoPlistPath)) +} + for (const workspacePath of workspacePaths) { const packagePath = path.join(rootDir, workspacePath, 'package.json') if (!existsSync(packagePath)) { @@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) { } } +syncDesktopTauriConfigVersion(rootVersion) +syncDesktopCargoPackageVersion(rootVersion) +syncDesktopInfoPlistVersion(rootVersion) + if (touched.length === 0) { console.log(`Workspace versions and internal deps already synced to ${rootVersion}`) } else {