fix(desktop): improve tauri permissions and settings UX
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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?"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+69
-2
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user