fix(desktop): improve tauri permissions and settings UX

This commit is contained in:
Mohamed Boudra
2026-02-18 18:13:20 +07:00
parent b1955cf74d
commit 4affac9ce9
28 changed files with 1433 additions and 198 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"ios": "npm run ios --workspace=@getpaseo/app", "ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "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", "cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs", "version:sync-internal": "node scripts/sync-workspace-versions.mjs",
+11
View File
@@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client";
import { import {
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail, type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications"; } from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute } from "@/utils/notification-routing";
import { import {
buildHostAgentDraftRoute, buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
polyfillCrypto(); polyfillCrypto();
@@ -57,6 +59,15 @@ function PushNotificationRouter() {
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { 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 target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => { const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>; const customEvent = event as CustomEvent<WebNotificationClickDetail>;
+4 -4
View File
@@ -1,4 +1,5 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
export const FOOTER_HEIGHT = 75; 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) // Check if running in Tauri desktop app (any OS)
function isTauri(): boolean { function isTauri(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false; return getTauri() !== null;
return "__TAURI__" in window;
} }
// Check if running in Tauri desktop app on macOS // Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean { function isTauriMac(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
if (typeof window === "undefined") 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 // Check for macOS via user agent
const ua = navigator.userAgent; const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh"); 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 _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null; let _isTauriCached: boolean | null = null;
@@ -351,6 +351,15 @@ export function SessionProvider({
const isActive = appState ? appState === "active" : true; const isActive = appState ? appState === "active" : true;
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId; const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
if (!isAwayFromAgent) { if (!isAwayFromAgent) {
console.log(
"[OSNotifications] Skipping attention notification: user already focused on agent",
{
agentId: params.agentId,
reason: params.reason,
appState,
focusedAgentId,
}
);
return; return;
} }
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri";
export interface AudioCaptureConfig { export interface AudioCaptureConfig {
sampleRate?: number; sampleRate?: number;
@@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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( throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` `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 options = configRef.current;
const constraints: MediaStreamConstraints = { const constraints: MediaStreamConstraints = {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getTauri } from "@/utils/tauri";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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}`); 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(); const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) { if (!AudioContextCtor) {
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout"; import { getIsTauriMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
} }
async function updateMacDockBadge(count?: number) { 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") { if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
return; return;
} }
+4 -7
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
interface UseFileDropZoneOptions { interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
return file.type.startsWith("image/"); return file.type.startsWith("image/");
} }
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getFileExtension(path: string): string { function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf("."); const extensionIndex = normalizedPath.lastIndexOf(".");
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
function filePathToImageAttachment(path: string): ImageAttachment { function filePathToImageAttachment(path: string): ImageAttachment {
const extension = getFileExtension(path); const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; const convertFileSrc = getTauri()?.core?.convertFileSrc;
const uri = const uri =
typeof convertFileSrc === "function" ? convertFileSrc(path) : path; typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
@@ -145,11 +142,11 @@ export function useFileDropZone({
} }
async function setupTauriDragDrop(): Promise<boolean> { async function setupTauriDragDrop(): Promise<boolean> {
if (!isTauriEnvironment()) { if (getTauri() === null) {
return false; return false;
} }
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
return false; return false;
} }
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SpeechSegmenter } from "@/voice/speech-segmenter"; import { SpeechSegmenter } from "@/voice/speech-segmenter";
import { getTauri } from "@/utils/tauri";
export interface SpeechmaticsAudioConfig { export interface SpeechmaticsAudioConfig {
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
@@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat
? window.isSecureContext ? window.isSecureContext
: true; : true;
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
try { try {
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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}`); 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(); const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) { if (!AudioContextCtor) {
+295 -111
View File
@@ -13,7 +13,7 @@ import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query"; 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 { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
@@ -35,6 +35,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; 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) => const delay = (ms: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
letterSpacing: 0.6,
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
}, },
@@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
letterSpacing: 0.4,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
}, },
input: { input: {
@@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
audioRowDescription: { permissionSectionHeader: {
color: theme.colors.foregroundMuted, flexDirection: "row",
fontSize: theme.fontSize.sm,
marginTop: 2,
},
// Footer
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
alignItems: "center", 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", alignItems: "center",
justifyContent: "center",
},
permissionRefreshButtonDisabled: {
opacity: theme.opacity[50],
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
footerText: { permissionStatusPill: {
color: theme.colors.foregroundMuted, flexDirection: "row",
fontSize: theme.fontSize.sm, 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, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
maxWidth: 220,
textAlign: "right",
}, },
resetButton: { aboutValue: {
paddingVertical: theme.spacing[2], color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
},
resetButtonText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
}, },
// Empty state // Empty state
emptyCard: { emptyCard: {
@@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({
// Theme toggle // Theme toggle
themeToggleContainer: { themeToggleContainer: {
flexDirection: "row", flexDirection: "row",
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface3,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.md,
padding: 4, borderWidth: 1,
gap: 4, borderColor: theme.colors.border,
padding: 2,
gap: 2,
}, },
themeToggleButton: { themeToggleButton: {
flex: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: theme.spacing[2], gap: theme.spacing[1],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.sm,
}, },
themeToggleButtonActive: { themeToggleButtonActive: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface2,
}, },
themeToggleText: { themeToggleText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -417,7 +443,7 @@ export default function SettingsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const { const {
daemons, daemons,
isLoading: daemonLoading, isLoading: daemonLoading,
@@ -436,7 +462,14 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false); const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null); const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
useState(false);
const [requestingDesktopPermission, setRequestingDesktopPermission] =
useState<DesktopPermissionKind | null>(null);
const isLoading = settingsLoading || daemonLoading; const isLoading = settingsLoading || daemonLoading;
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null); const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
@@ -534,6 +567,73 @@ export default function SettingsScreen() {
pendingEditReopenServerId, 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) => { const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingServerId) return; if (!editingServerId) return;
if (isSavingEdit) return; if (isSavingEdit) return;
@@ -590,34 +690,6 @@ export default function SettingsScreen() {
[updateSettings] [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 = const restartConfirmationMessage =
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; "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 */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Appearance</Text> <Text style={styles.sectionTitle}>Appearance</Text>
<View style={styles.themeToggleContainer}> <View style={styles.audioCard}>
<Pressable <View style={styles.audioRow}>
style={[ <View style={styles.audioRowContent}>
styles.themeToggleButton, <Text style={styles.audioRowTitle}>Theme</Text>
settings.theme === "light" && styles.themeToggleButtonActive, </View>
]} <View style={styles.themeToggleContainer}>
onPress={() => handleThemeChange("light")} <Pressable
> style={[
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}> settings.theme === "light" && styles.themeToggleButtonActive,
Light ]}
</Text> onPress={() => handleThemeChange("light")}
</Pressable> >
<Pressable <Sun size={14} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
style={[ <Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
styles.themeToggleButton, Light
settings.theme === "dark" && styles.themeToggleButtonActive, </Text>
]} </Pressable>
onPress={() => handleThemeChange("dark")} <Pressable
> style={[
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}> settings.theme === "dark" && styles.themeToggleButtonActive,
Dark ]}
</Text> onPress={() => handleThemeChange("dark")}
</Pressable> >
<Pressable <Moon size={14} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
style={[ <Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
styles.themeToggleButton, Dark
settings.theme === "auto" && styles.themeToggleButtonActive, </Text>
]} </Pressable>
onPress={() => handleThemeChange("auto")} <Pressable
> style={[
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}> settings.theme === "auto" && styles.themeToggleButtonActive,
System ]}
</Text> onPress={() => handleThemeChange("auto")}
</Pressable> >
<Monitor size={14} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
System
</Text>
</Pressable>
</View>
</View>
</View> </View>
</View> </View>
{/* Footer */} {showDesktopPermissionSection ? (
<View style={styles.footer}> <View style={styles.section}>
<View style={styles.footerAppInfo}> <View style={styles.permissionSectionHeader}>
<Text style={styles.footerText}>Paseo</Text> <Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
<Text style={styles.footerVersion}>Version {appVersion}</Text> Desktop permissions
</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
(isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null) &&
styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshDesktopPermissions();
}}
disabled={
isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null
}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={desktopPermissionSnapshot?.notifications ?? null}
isRequesting={requestingDesktopPermission === "notifications"}
onRequest={() => {
void handleRequestDesktopPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={desktopPermissionSnapshot?.microphone ?? null}
isRequesting={requestingDesktopPermission === "microphone"}
onRequest={() => {
void handleRequestDesktopPermission("microphone");
}}
/>
</View>
</View>
) : null}
{/* About */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Version</Text>
</View>
<Text style={styles.aboutValue}>{appVersion}</Text>
</View>
</View> </View>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to defaults</Text>
</Pressable>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
@@ -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 (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button
variant="secondary"
size="sm"
onPress={onRequest}
disabled={isRequesting}
>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? (
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
) : null}
</View>
</View>
);
}
function ConnectionRow({ function ConnectionRow({
connection, connection,
latencyMs, latencyMs,
+3 -24
View File
@@ -1,4 +1,5 @@
import { Alert, Platform } from "react-native"; import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
export interface ConfirmDialogInput { export interface ConfirmDialogInput {
title: string; title: string;
@@ -8,26 +9,6 @@ export interface ConfirmDialogInput {
destructive?: boolean; destructive?: boolean;
} }
interface TauriDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
}
interface TauriCoreApi {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
interface TauriGlobalApi {
dialog?: TauriDialogApi;
core?: TauriCoreApi;
}
interface ConfirmButtonConfig { interface ConfirmButtonConfig {
confirmLabel: string; confirmLabel: string;
cancelLabel: string; cancelLabel: string;
@@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
}); });
} }
function getTauriApi(): TauriGlobalApi | null { function getTauriApi() {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return null; return null;
} }
return getTauri();
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
return tauriApi ?? null;
} }
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions { function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
}
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value,
})
}
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
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
vi.resetModules()
vi.doMock('react-native', () => ({ 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')
})
})
@@ -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<string>
}
type MediaStreamTrackLike = {
stop?: () => void
}
type MediaStreamLike = {
getTracks?: () => MediaStreamTrackLike[]
}
type NavigatorLike = {
mediaDevices?: {
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>
}
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<string, unknown> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
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<DesktopPermissionStatus> {
if (input.kind === 'notifications') {
return await requestNotificationPermissionStatus()
}
return await requestMicrophonePermissionStatus()
}
export async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
const [notifications, microphone] = await Promise.all([
getNotificationPermissionStatus(),
getMicrophonePermissionStatus(),
])
return {
checkedAt: Date.now(),
notifications,
microphone,
}
}
+5 -15
View File
@@ -1,23 +1,13 @@
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauri } from "@/constants/layout"; import { getTauri } from "@/utils/tauri";
interface TauriWindowWithOpener {
__TAURI__?: {
opener?: {
openUrl?: (url: string) => Promise<void>;
};
};
}
export async function openExternalUrl(url: string): Promise<void> { export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && getIsTauri()) { const opener = getTauri()?.opener?.openUrl;
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; if (typeof opener === "function") {
if (typeof opener === "function") { await opener(url);
await opener(url); return;
return;
}
} }
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
dispatchEvent: unknown; dispatchEvent: unknown;
focus: unknown; focus: unknown;
location: unknown; location: unknown;
__TAURI__: unknown;
}; };
const originalGlobals: GlobalSnapshot = { const originalGlobals: GlobalSnapshot = {
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent, dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus, focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location, 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") {
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent; (globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus; (globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location; (globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
} }
describe("sendOsNotification", () => { describe("sendOsNotification", () => {
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); 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",
});
});
}); });
+105 -1
View File
@@ -1,5 +1,6 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { buildNotificationRoute } from "./notification-routing"; import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null; let permissionRequest: Promise<boolean> | null = null;
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
return null;
}
return getTauri()?.notification ?? null;
}
function getWebNotificationConstructor(): { function getWebNotificationConstructor(): {
permission: string; permission: string;
requestPermission?: () => Promise<string>; requestPermission?: () => Promise<string>;
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
return result; return result;
} }
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) {
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<boolean> {
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 { function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent; const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }) const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
@@ -127,12 +215,28 @@ export async function sendOsNotification(
return false; return false;
} }
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
console.log("[OSNotifications] Using Tauri notification module");
return await sendTauriNotification(payload, tauriNotification);
}
const NotificationConstructor = getWebNotificationConstructor(); const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) { if (!NotificationConstructor) {
console.log(
"[OSNotifications][Web] Notification constructor unavailable",
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown-origin"
);
return false; return false;
} }
const granted = await ensureNotificationPermission(); const granted = await ensureOsNotificationPermission();
if (!granted) { if (!granted) {
console.log(
"[OSNotifications][Web] Permission not granted:",
NotificationConstructor.permission
);
return false; return false;
} }
const notification = new NotificationConstructor(payload.title, { const notification = new NotificationConstructor(payload.title, {
@@ -1,4 +1,5 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client"; import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import { getTauri } from "@/utils/tauri";
type TauriWebSocketMessage = type TauriWebSocketMessage =
| { type: "Text"; data: string } | { type: "Text"; data: string }
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
return Array.from(data); return Array.from(data);
} }
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getTauriWebSocketModule(): TauriWebSocketModule | null { function getTauriWebSocketModule(): TauriWebSocketModule | null {
if (!isTauriEnvironment()) { const ws = getTauri()?.websocket;
return null;
}
const ws = (window as any).__TAURI__?.websocket;
if (ws && typeof ws.connect === "function") { if (ws && typeof ws.connect === "function") {
return ws as TauriWebSocketModule; return ws as TauriWebSocketModule;
} }
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
} }
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
if (!isTauriEnvironment()) { if (getTauri() === null) {
return null; return null;
} }
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
} }
if (!module) { if (!module) {
throw new Error( 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?"
); );
} }
+9 -11
View File
@@ -1,15 +1,10 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout"; import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
let tauriWindow: any = null; let tauriWindow: any = null;
// Runtime check for Tauri environment
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" &&
(window as any).__TAURI__ !== undefined;
}
async function getTauriWindow() { async function getTauriWindow() {
if (tauriWindow) return tauriWindow; if (tauriWindow) return tauriWindow;
@@ -19,15 +14,18 @@ async function getTauriWindow() {
} }
try { 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 // We must use that here because importing `@tauri-apps/api/*` would be bundled into
// the native (Hermes) builds and break parsing/runtime on mobile. // 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. // Prefer the public global window module.
const windowModule = tauri?.window; const publicWindow = getCurrentTauriWindow();
if (windowModule && typeof windowModule.getCurrentWindow === "function") { if (publicWindow) {
tauriWindow = windowModule.getCurrentWindow(); tauriWindow = publicWindow;
return tauriWindow; return tauriWindow;
} }
+92
View File
@@ -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<boolean>;
}
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;
}
}
+69 -2
View File
@@ -2028,6 +2028,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@@ -2164,6 +2176,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" 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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -2512,7 +2538,7 @@ dependencies = [
[[package]] [[package]]
name = "paseo" name = "paseo"
version = "0.1.0" version = "0.1.13"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -2521,6 +2547,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-websocket", "tauri-plugin-websocket",
] ]
@@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -2858,6 +2885,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -4035,6 +4071,25 @@ dependencies = [
"time", "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]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.3" version = "2.5.3"
@@ -4178,6 +4233,18 @@ dependencies = [
"toml 0.9.11+spec-1.1.0", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.25.0"
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "paseo" name = "paseo"
version = "0.1.0" version = "0.1.13"
description = "Paseo Desktop" description = "Paseo Desktop"
authors = ["moboudra"] authors = ["moboudra"]
license = "MIT" license = "MIT"
@@ -28,5 +28,7 @@ log = "0.4"
tauri = { version = "2.9.5", features = [] } tauri = { version = "2.9.5", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -7,9 +7,9 @@
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Paseo</string> <string>Paseo</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.0</string> <string>0.1.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.1.0</string> <string>0.1.13</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string> <string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict> </dict>
@@ -17,6 +17,7 @@
"core:window:allow-set-badge-count", "core:window:allow-set-badge-count",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"dialog:default", "dialog:default",
"notification:default",
"opener:default", "opener:default",
"websocket:default" "websocket:default"
] ]
+27
View File
@@ -1,5 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use tauri::Manager; use tauri::Manager;
use tauri::WebviewWindow; use tauri::WebviewWindow;
@@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_websocket::init())
.setup(|app| { .setup(|app| {
@@ -38,6 +41,30 @@ pub fn run() {
// responder-chain shortcuts across the whole app. // responder-chain shortcuts across the whole app.
let menu = Menu::default(app.handle())?; 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 <plist short> (<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") let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In")
.accelerator("CmdOrCtrl+=") .accelerator("CmdOrCtrl+=")
.build(app)?; .build(app)?;
+2 -2
View File
@@ -2,8 +2,8 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle), // `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 // so we must embed an Info.plist containing usage descriptions for WebKit media
// microphone access to trigger the OS permission prompt in dev. // permission prompts in dev.
#[cfg(all(target_os = "macos", debug_assertions))] #[cfg(all(target_os = "macos", debug_assertions))]
tauri::embed_plist::embed_info_plist!("../Info.plist"); tauri::embed_plist::embed_info_plist!("../Info.plist");
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo", "productName": "Paseo",
"version": "0.1.0", "version": "0.1.13",
"identifier": "dev.paseo.desktop", "identifier": "dev.paseo.desktop",
"build": { "build": {
"frontendDist": "../../app/dist", "frontendDist": "../../app/dist",
@@ -20,7 +20,10 @@
"fullscreen": false, "fullscreen": false,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true, "hiddenTitle": true,
"trafficLightPosition": { "x": 16, "y": 22 } "trafficLightPosition": {
"x": 16,
"y": 22
}
} }
], ],
"security": { "security": {
@@ -38,6 +41,7 @@
"icons/icon.ico" "icons/icon.ico"
], ],
"macOS": { "macOS": {
"entitlements": "Entitlements.plist",
"infoPlist": "Info.plist" "infoPlist": "Info.plist"
} }
} }
+104
View File
@@ -23,6 +23,106 @@ const dependencySections = [
const touched = [] 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 = /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/
const bundleVersionPattern = /(<key>CFBundleVersion<\/key>\s*<string>)([^<]+)(<\/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) { for (const workspacePath of workspacePaths) {
const packagePath = path.join(rootDir, workspacePath, 'package.json') const packagePath = path.join(rootDir, workspacePath, 'package.json')
if (!existsSync(packagePath)) { if (!existsSync(packagePath)) {
@@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) {
} }
} }
syncDesktopTauriConfigVersion(rootVersion)
syncDesktopCargoPackageVersion(rootVersion)
syncDesktopInfoPlistVersion(rootVersion)
if (touched.length === 0) { if (touched.length === 0) {
console.log(`Workspace versions and internal deps already synced to ${rootVersion}`) console.log(`Workspace versions and internal deps already synced to ${rootVersion}`)
} else { } else {