637 lines
19 KiB
TypeScript
637 lines
19 KiB
TypeScript
/**
|
|
* Open an external URL using Tauri opener plugin on desktop/mobile, or window.open in browser
|
|
* @param url The URL to open
|
|
*/
|
|
export const openExternalUrl = async (url: string): Promise<void> => {
|
|
console.log('[openExternalUrl] called with:', url);
|
|
|
|
// Use Electron's shell.openExternal if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.shell?.openExternal) {
|
|
await electron.shell.openExternal(url);
|
|
console.log('[openExternalUrl] Electron shell.openExternal succeeded');
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri for actual Tauri builds
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
// First, try the plugin directly (works on both desktop and mobile)
|
|
console.log('[openExternalUrl] trying Tauri opener plugin...');
|
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
|
await openUrl(url);
|
|
console.log('[openExternalUrl] Tauri plugin succeeded');
|
|
return;
|
|
} catch (pluginErr) {
|
|
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
|
|
|
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
|
try {
|
|
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('open_external_url', { url });
|
|
console.log('[openExternalUrl] Tauri command fallback succeeded');
|
|
return;
|
|
} catch (invokeErr) {
|
|
console.error('[openExternalUrl] Tauri command fallback also failed:', invokeErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('[openExternalUrl] falling back to window.open');
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
/**
|
|
* YouTube stream info returned by yt-dlp
|
|
*/
|
|
export interface YouTubeStreamInfo {
|
|
/** Direct video stream URL */
|
|
video_url: string;
|
|
/** Title of the video */
|
|
title: string;
|
|
}
|
|
|
|
/**
|
|
* Get direct YouTube stream URL using yt-dlp
|
|
* Requires yt-dlp to be installed on the system
|
|
* @param url The YouTube video URL
|
|
* @returns Stream info with direct URL and title
|
|
* @throws If yt-dlp is not installed or fails
|
|
*/
|
|
export const getYouTubeStream = async (url: string): Promise<YouTubeStreamInfo> => {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
|
|
}
|
|
|
|
// Check for Electron
|
|
const electron = (window as any).electron;
|
|
if (electron?.youtube?.getStream) {
|
|
const result = await electron.youtube.getStream(url);
|
|
// Electron returns { success: true, data: { video_url, title } } or { success: false, error }
|
|
if (result.success === false) {
|
|
throw new Error(result.error || 'Failed to get YouTube stream');
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
// Check for Tauri
|
|
if ((window as any).__TAURI__) {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
return invoke<YouTubeStreamInfo>('get_youtube_stream', { url });
|
|
}
|
|
|
|
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
|
|
};
|
|
|
|
/**
|
|
* Check if yt-dlp is available for YouTube streaming
|
|
* @returns True if yt-dlp streaming is available
|
|
*/
|
|
export const isYouTubeStreamingAvailable = (): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
// Check for Electron
|
|
const electron = (window as any).electron;
|
|
if (electron?.youtube?.getStream) {
|
|
return true;
|
|
}
|
|
|
|
// Check for Tauri
|
|
return !!(window as any).__TAURI__;
|
|
};
|
|
|
|
/**
|
|
* Tauri-specific utilities for desktop and mobile platforms
|
|
*/
|
|
|
|
/** Callback for notification tap actions */
|
|
let notificationTapCallback: ((path: string) => void) | null = null;
|
|
|
|
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
|
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
|
|
|
|
const CAPACITOR_LOCAL_NOTIFICATIONS_MODULE_ID = '@capacitor/local-notifications';
|
|
|
|
type CapacitorLocalNotificationsModule = typeof import('@capacitor/local-notifications');
|
|
|
|
/**
|
|
* Lazy-load Capacitor local notifications at runtime only.
|
|
* Vite must not pre-resolve this module because some desktop builds do not include it.
|
|
*/
|
|
const importCapacitorLocalNotifications = async (): Promise<CapacitorLocalNotificationsModule> =>
|
|
import(/* @vite-ignore */ CAPACITOR_LOCAL_NOTIFICATIONS_MODULE_ID);
|
|
|
|
/**
|
|
* Bring the Tauri window to the front and focus it
|
|
*/
|
|
export const focusWindow = async (): Promise<void> => {
|
|
// Use Electron's window focus if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.window?.focus) {
|
|
await electron.window.focus();
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Failed to focus Electron window:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri's window focus for actual Tauri builds
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
|
const window = getCurrentWindow();
|
|
await window.unminimize();
|
|
await window.setFocus();
|
|
} catch (err) {
|
|
console.warn('Failed to focus Tauri window:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set up listener for notification tap/click actions
|
|
* Call this once at app startup with a navigation callback
|
|
*/
|
|
export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => {
|
|
notificationTapCallback = onTap;
|
|
|
|
// Use Electron's native notification handler if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.notification?.onNavigate) {
|
|
electron.notification.onNavigate((data: { path?: string }) => {
|
|
if (data.path && typeof data.path === 'string' && notificationTapCallback) {
|
|
notificationTapCallback(data.path);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to set up Electron notification listener:', err);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Use Tauri plugin for actual Tauri builds (not Electron)
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const { onAction } = await import('@tauri-apps/plugin-notification');
|
|
|
|
await onAction(async (notification) => {
|
|
// Bring window to front first
|
|
await focusWindow();
|
|
|
|
// Get the path from extra data and navigate
|
|
const path = notification.notification?.extra?.path;
|
|
if (path && typeof path === 'string' && notificationTapCallback) {
|
|
notificationTapCallback(path);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.warn('Failed to set up Tauri notification tap listener:', err);
|
|
}
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
|
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
|
await focusWindow();
|
|
const path = event?.notification?.extra?.path;
|
|
if (path && typeof path === 'string' && notificationTapCallback) {
|
|
notificationTapCallback(path);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if we're running inside a Tauri application
|
|
*/
|
|
export const isTauri = (): boolean =>
|
|
'__TAURI__' in window || '__TAURI_INTERNALS__' in window;
|
|
|
|
/**
|
|
* Check if we're running inside Electron (not Tauri)
|
|
*/
|
|
export const isElectron = (): boolean =>
|
|
typeof window !== 'undefined' && 'electron' in window;
|
|
|
|
/**
|
|
* Check if we're running inside a Capacitor native app
|
|
*/
|
|
export const isCapacitorNative = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const cap = (window as any).Capacitor;
|
|
return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios');
|
|
};
|
|
|
|
/**
|
|
* Check if we're running on a mobile platform (Android/iOS)
|
|
*/
|
|
export const isTauriMobile = (): boolean => {
|
|
if (!isTauri() || isElectron()) return false;
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad');
|
|
};
|
|
|
|
/**
|
|
* Check if we're running on Android
|
|
*/
|
|
export const isAndroid = (): boolean => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
return ua.includes('android');
|
|
};
|
|
|
|
/**
|
|
* Apply safe area insets for mobile devices
|
|
* On Android, CSS env() may not work, so we apply fallback padding
|
|
*/
|
|
export const applySafeAreaInsets = (): void => {
|
|
if (!isTauriMobile()) return;
|
|
|
|
const root = document.documentElement;
|
|
|
|
// Check if env() is working by testing if it returns a value
|
|
const testValue = getComputedStyle(root).getPropertyValue('--safe-area-inset-top');
|
|
const envWorking = testValue && testValue !== '0px' && testValue !== '';
|
|
|
|
if (!envWorking && isAndroid()) {
|
|
// Apply fallback padding for Android status bar and navigation bar
|
|
// Status bar is typically 24-48dp, navigation bar is typically 48dp
|
|
// We use conservative values that work on most devices
|
|
root.style.setProperty('--safe-area-inset-top', '28px');
|
|
root.style.setProperty('--safe-area-inset-bottom', '24px');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Send a browser notification
|
|
*/
|
|
const sendBrowserNotification = (options: {
|
|
title: string;
|
|
body: string;
|
|
icon?: string;
|
|
onClick?: () => void;
|
|
}): Notification | undefined => {
|
|
const { title, body, icon, onClick } = options;
|
|
|
|
if (!('Notification' in window)) return undefined;
|
|
if (Notification.permission !== 'granted') return undefined;
|
|
|
|
const notification = new Notification(title, {
|
|
body,
|
|
icon,
|
|
silent: true,
|
|
});
|
|
|
|
if (onClick) {
|
|
notification.onclick = async () => {
|
|
// Bring Tauri window to front if in Tauri
|
|
await focusWindow();
|
|
// Also use standard window.focus for browser
|
|
window.focus();
|
|
if (!window.closed) onClick();
|
|
notification.close();
|
|
};
|
|
}
|
|
|
|
return notification;
|
|
};
|
|
|
|
/** Flag to track if notification channel has been created on Android */
|
|
let notificationChannelCreated = false;
|
|
|
|
/**
|
|
* Create notification channel for Android
|
|
* Required for Android 8.0+ to show notifications
|
|
*/
|
|
const ensureNotificationChannel = async (): Promise<void> => {
|
|
if (notificationChannelCreated || !isAndroid()) return;
|
|
|
|
try {
|
|
const { createChannel, Importance } = await import('@tauri-apps/plugin-notification');
|
|
|
|
await createChannel({
|
|
id: 'messages',
|
|
name: 'Messages',
|
|
description: 'Message notifications from Matrix',
|
|
importance: Importance.High,
|
|
vibration: true,
|
|
sound: 'default',
|
|
});
|
|
|
|
notificationChannelCreated = true;
|
|
} catch (err) {
|
|
console.warn('Failed to create notification channel:', err);
|
|
}
|
|
};
|
|
|
|
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
|
if (notificationChannelCreated || !isAndroid()) return;
|
|
|
|
try {
|
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
|
await LocalNotifications.createChannel({
|
|
id: 'messages',
|
|
name: 'Messages',
|
|
description: 'Message notifications from Matrix',
|
|
importance: 5,
|
|
sound: 'default',
|
|
visibility: 1,
|
|
vibration: true,
|
|
lights: true,
|
|
});
|
|
notificationChannelCreated = true;
|
|
} catch (err) {
|
|
console.warn('Failed to create Capacitor notification channel:', err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Request notification permission across Electron, Tauri, Capacitor, and browser
|
|
*/
|
|
export const requestSystemNotificationPermission = async (): Promise<boolean> => {
|
|
if (isElectron() || (isTauri() && !isElectron())) {
|
|
if (!('Notification' in window)) return false;
|
|
const permission = await Notification.requestPermission();
|
|
return permission === 'granted';
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
|
let perm = await LocalNotifications.checkPermissions();
|
|
if (perm.display !== 'granted') {
|
|
perm = await LocalNotifications.requestPermissions();
|
|
}
|
|
return perm.display === 'granted';
|
|
} catch (err) {
|
|
console.warn('Capacitor notification permission request failed:', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!('Notification' in window)) return false;
|
|
const permission = await Notification.requestPermission();
|
|
return permission === 'granted';
|
|
};
|
|
|
|
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
|
const perm = await LocalNotifications.checkPermissions();
|
|
return perm.display === 'granted' ? 'granted' : 'prompt';
|
|
} catch (err) {
|
|
console.warn('Failed to check Capacitor notification permission:', err);
|
|
return 'denied';
|
|
}
|
|
}
|
|
|
|
if ('Notification' in window) {
|
|
if (window.Notification.permission === 'default') {
|
|
return 'prompt';
|
|
}
|
|
return window.Notification.permission;
|
|
}
|
|
|
|
return 'denied';
|
|
};
|
|
|
|
/**
|
|
* Send a notification using Tauri's notification plugin
|
|
* Falls back to browser Notification API if not in Tauri
|
|
*/
|
|
export const sendNotification = async (options: {
|
|
title: string;
|
|
body: string;
|
|
icon?: string;
|
|
path?: string;
|
|
onClick?: () => void;
|
|
}): Promise<void> => {
|
|
const { title, body, icon, path, onClick } = options;
|
|
|
|
// Use Electron's native notification API if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.notification?.show) {
|
|
await electron.notification.show({
|
|
title,
|
|
body,
|
|
icon,
|
|
path,
|
|
});
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Electron notification failed:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri plugin for actual Tauri builds (not Electron)
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const {
|
|
sendNotification: tauriSendNotification,
|
|
isPermissionGranted,
|
|
requestPermission,
|
|
} = await import('@tauri-apps/plugin-notification');
|
|
|
|
let permissionGranted = await isPermissionGranted();
|
|
if (!permissionGranted) {
|
|
const permission = await requestPermission();
|
|
permissionGranted = permission === 'granted';
|
|
}
|
|
|
|
if (permissionGranted) {
|
|
// Ensure notification channel exists on Android
|
|
await ensureNotificationChannel();
|
|
|
|
await tauriSendNotification({
|
|
title,
|
|
body,
|
|
// Use the channel on Android
|
|
channelId: isAndroid() ? 'messages' : undefined,
|
|
// Store path in extra data for notification tap handling
|
|
extra: path ? { path } : undefined,
|
|
});
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Tauri notification failed:', err);
|
|
}
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await importCapacitorLocalNotifications();
|
|
let perm = await LocalNotifications.checkPermissions();
|
|
if (perm.display !== 'granted') {
|
|
perm = await LocalNotifications.requestPermissions();
|
|
}
|
|
|
|
if (perm.display === 'granted') {
|
|
await ensureCapacitorNotificationChannel();
|
|
|
|
const id = Math.floor(Date.now() % 2147483647);
|
|
await LocalNotifications.schedule({
|
|
notifications: [
|
|
{
|
|
id,
|
|
title,
|
|
body,
|
|
channelId: isAndroid() ? 'messages' : undefined,
|
|
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
|
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
|
extra: path ? { path } : undefined,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Capacitor notification failed:', err);
|
|
}
|
|
}
|
|
|
|
// Fallback to browser notification
|
|
sendBrowserNotification({ title, body, icon, onClick });
|
|
};
|
|
|
|
/**
|
|
* Check if we're running on Linux
|
|
*/
|
|
export const isLinux = (): boolean => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
return ua.includes('linux') && !ua.includes('android');
|
|
};
|
|
|
|
/**
|
|
* Read clipboard image on Linux using Tauri command with arboard/Wayland support
|
|
* Returns a data URL if an image is found, null otherwise
|
|
*/
|
|
export const readClipboardImage = async (): Promise<File | null> => {
|
|
// Use Electron's clipboard API if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.clipboard?.readImage) {
|
|
const dataUrl = await electron.clipboard.readImage();
|
|
if (!dataUrl) return null;
|
|
|
|
// Convert data URL to File
|
|
const response = await fetch(dataUrl);
|
|
const blob = await response.blob();
|
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to read Electron clipboard image:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Use Tauri's invoke for actual Tauri builds on Linux
|
|
if (isTauri() && !isElectron() && isLinux()) {
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
const dataUrl = await invoke<string | null>('read_clipboard_image');
|
|
|
|
if (!dataUrl) return null;
|
|
|
|
// Convert data URL to File
|
|
const response = await fetch(dataUrl);
|
|
const blob = await response.blob();
|
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
|
} catch (err) {
|
|
console.warn('Failed to read Tauri clipboard image:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Background Sync API for mobile platforms
|
|
* Starts a native Rust-based Matrix sync that runs even when the app is backgrounded
|
|
*/
|
|
export interface BackgroundSyncCredentials {
|
|
homeserverUrl: string;
|
|
userId: string;
|
|
accessToken: string;
|
|
deviceId: string;
|
|
}
|
|
|
|
/**
|
|
* Start background Matrix sync on mobile
|
|
* This runs the sync in native Rust code, allowing notifications even when the app is backgrounded
|
|
*/
|
|
export const startBackgroundSync = async (credentials: BackgroundSyncCredentials): Promise<void> => {
|
|
if (!isTauriMobile()) {
|
|
console.log('[BackgroundSync] Not on mobile, skipping');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('start_background_sync', {
|
|
homeserverUrl: credentials.homeserverUrl,
|
|
userId: credentials.userId,
|
|
accessToken: credentials.accessToken,
|
|
deviceId: credentials.deviceId,
|
|
});
|
|
console.log('[BackgroundSync] Started successfully');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to start:', err);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Stop background Matrix sync on mobile
|
|
*/
|
|
export const stopBackgroundSync = async (): Promise<void> => {
|
|
if (!isTauriMobile()) return;
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('stop_background_sync');
|
|
console.log('[BackgroundSync] Stopped');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to stop:', err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get the current background sync state
|
|
*/
|
|
export const getBackgroundSyncState = async (): Promise<string> => {
|
|
if (!isTauriMobile()) return 'NotApplicable';
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
return await invoke<string>('get_background_sync_state');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to get state:', err);
|
|
return 'Error';
|
|
}
|
|
};
|