Merge remote-tracking branch 'origin/main' into pr58-sync
# Conflicts: # packages/server/src/shared/messages.ts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
@@ -8,26 +9,6 @@ export interface ConfirmDialogInput {
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface TauriGlobalApi {
|
||||
dialog?: TauriDialogApi;
|
||||
core?: TauriCoreApi;
|
||||
}
|
||||
|
||||
interface ConfirmButtonConfig {
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
@@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi(): TauriGlobalApi | null {
|
||||
function getTauriApi() {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||
return tauriApi ?? null;
|
||||
return getTauri();
|
||||
}
|
||||
|
||||
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 { Platform } from "react-native";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
|
||||
interface TauriWindowWithOpener {
|
||||
__TAURI__?: {
|
||||
opener?: {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && getIsTauri()) {
|
||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
const opener = getTauri()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
|
||||
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
__TAURI__: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
|
||||
it("uses Tauri notification module when available", async () => {
|
||||
const isPermissionGranted = vi.fn(async () => false);
|
||||
const requestPermission = vi.fn(async () => "granted");
|
||||
const sendNotification = vi.fn(async () => undefined);
|
||||
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
},
|
||||
};
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1);
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri()?.notification ?? null;
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
permission: string;
|
||||
requestPermission?: () => Promise<string>;
|
||||
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
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 {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
@@ -127,12 +215,28 @@ export async function sendOsNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
console.log("[OSNotifications] Using Tauri notification module");
|
||||
return await sendTauriNotification(payload, tauriNotification);
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor();
|
||||
if (!NotificationConstructor) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Notification constructor unavailable",
|
||||
typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "unknown-origin"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const granted = await ensureNotificationPermission();
|
||||
const granted = await ensureOsNotificationPermission();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Permission not granted:",
|
||||
NotificationConstructor.permission
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
|
||||
return Array.from(data);
|
||||
}
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
const ws = (window as any).__TAURI__?.websocket;
|
||||
const ws = getTauri()?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
if (getTauri() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
|
||||
// Runtime check for Tauri environment
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" &&
|
||||
(window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
|
||||
@@ -19,15 +14,18 @@ async function getTauriWindow() {
|
||||
}
|
||||
|
||||
try {
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`.
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
|
||||
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
|
||||
// the native (Hermes) builds and break parsing/runtime on mobile.
|
||||
const tauri = (window as any).__TAURI__ as any;
|
||||
const tauri = getTauri();
|
||||
if (!tauri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the public global window module.
|
||||
const windowModule = tauri?.window;
|
||||
if (windowModule && typeof windowModule.getCurrentWindow === "function") {
|
||||
tauriWindow = windowModule.getCurrentWindow();
|
||||
const publicWindow = getCurrentTauriWindow();
|
||||
if (publicWindow) {
|
||||
tauriWindow = publicWindow;
|
||||
return tauriWindow;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user