Files
cinny-mobile/overlay/src/app/utils/tauri.ts
litruv 157d707581
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 5m3s
Fix Android notification avatars, E2EE wake alerts, and tap navigation.
Show circular sender avatars with MessagingStyle, notify on encrypted background sync events, deep-link tray taps into the right room, and suppress Firefox open-link contextual actions.
2026-07-24 15:03:03 +10:00

978 lines
30 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';
/**
* 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 import('@capacitor/local-notifications');
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);
}
// Native NotificationManager posts (MatrixBackgroundSync.showNotification) open the
// Activity with extras — LocalNotifications never sees those taps.
try {
const {
getPendingNotificationNav,
listenForNotificationOpens,
} = await import('./backgroundSync');
const deliver = (path?: string | null, roomId?: string | null) => {
if (path && notificationTapCallback) {
notificationTapCallback(path);
return;
}
if (roomId && notificationTapCallback) {
// Encode a synthetic marker path that ClientNonUIFeatures can resolve.
notificationTapCallback(`__room__:${roomId}`);
}
};
const pending = await getPendingNotificationNav();
deliver(pending.path, pending.roomId);
await listenForNotificationOpens((event) => {
void focusWindow();
deliver(event.path, event.roomId);
});
} catch (err) {
console.warn('Failed to set up native notification open 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');
};
/** Returns true when running as a Capacitor app on Android. */
export const isCapacitorAndroid = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
};
/**
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/**
* 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> => {
await ensureCapacitorNotificationChannels();
};
/**
* 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 import('@capacitor/local-notifications');
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 import('@capacitor/local-notifications');
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';
};
/** Foreground sync status notification — must not collide with room message IDs. */
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
/** Android notification group keys for rooms outside a space. */
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
export const NOTIF_GROUP_HOME = 'paarrot_home';
/** Capacitor / native channel ids (under the Paarrot app). */
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
export const NOTIF_CHANNEL_HOME = 'messages_home';
/** Legacy channel — kept so older installs still receive. */
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
export type NotificationGroupKind = 'direct' | 'space' | 'home';
export type NotificationGroupInfo = {
groupId: string;
groupName: string;
kind: NotificationGroupKind;
roomName?: string;
};
/**
* Stable Android notification id for a Matrix room.
* Re-using the same id replaces the previous tray entry instead of stacking.
*/
export const notificationIdForRoom = (roomId: string): number => {
let hash = 0;
for (let i = 0; i < roomId.length; i += 1) {
hash = (hash << 5) - hash + roomId.charCodeAt(i);
hash |= 0;
}
let id = Math.abs(hash) % 2147483647;
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
}
return id;
};
/** Stable id for a space/group summary notification. */
export const notificationIdForGroupSummary = (groupId: string): number =>
notificationIdForRoom(`summary:${groupId}`);
const channelIdForKind = (kind: NotificationGroupKind): string => {
switch (kind) {
case 'direct':
return NOTIF_CHANNEL_DIRECTS;
case 'space':
return NOTIF_CHANNEL_SPACES;
default:
return NOTIF_CHANNEL_HOME;
}
};
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
if (!isAndroid()) return;
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const channels: Array<{
id: string;
name: string;
description: string;
}> = [
{
id: NOTIF_CHANNEL_DIRECTS,
name: 'Direct messages',
description: 'One-to-one Matrix conversations',
},
{
id: NOTIF_CHANNEL_SPACES,
name: 'Spaces',
description: 'Messages from rooms inside Spaces',
},
{
id: NOTIF_CHANNEL_HOME,
name: 'Other rooms',
description: 'Rooms that are not in a Space',
},
{
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
name: 'Messages',
description: 'Legacy message channel',
},
];
await Promise.all(
channels.map((channel) =>
LocalNotifications.createChannel({
id: channel.id,
name: channel.name,
description: channel.description,
importance: 5,
sound: 'default',
visibility: 1,
vibration: true,
lights: true,
})
)
);
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create Capacitor notification channels:', err);
}
};
const notificationBelongsToRoom = (
notification: { id?: number; extra?: Record<string, unknown> },
roomId: string
): boolean => {
if (notification.id === notificationIdForRoom(roomId)) return true;
const extra = notification.extra;
if (!extra || typeof extra !== 'object') return false;
if (extra.roomId === roomId) return true;
const path = extra.path;
if (typeof path === 'string') {
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
}
return false;
};
const clearCapacitorGroupSummaryIfEmpty = async (
LocalNotifications: {
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
},
groupId: string
): Promise<void> => {
const summaryId = notificationIdForGroupSummary(groupId);
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
if (!delivered?.notifications) return;
const siblings = delivered.notifications.filter((n) => {
if (n.id === summaryId) return false;
if (n.group === groupId) return true;
return n.extra?.groupId === groupId;
});
if (siblings.length > 0) return;
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
const summary = delivered.notifications.find((n) => n.id === summaryId);
if (summary) {
await LocalNotifications.removeDeliveredNotifications({
notifications: [summary],
}).catch(() => undefined);
}
};
/**
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
* Also drops the space/group summary when no child notifications remain.
*/
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
if (!roomId) return;
if (isCapacitorNative()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const id = notificationIdForRoom(roomId);
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
const groupId =
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
? (roomNotif as { group?: string }).group
: undefined);
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
if (deliveredBefore?.notifications?.length) {
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
if (matching.length > 0) {
await LocalNotifications.removeDeliveredNotifications({
notifications: matching,
}).catch(() => undefined);
}
}
if (groupId) {
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
}
} catch (err) {
console.warn('Failed to clear Capacitor notifications for room:', err);
}
try {
const { clearNativeRoomNotifications } = await import('./backgroundSync');
await clearNativeRoomNotifications(roomId);
} catch (err) {
console.warn('Failed to clear native room notifications:', err);
}
return;
}
if (isTauri() && !isElectron()) {
try {
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
const id = notificationIdForRoom(roomId);
if (typeof cancel === 'function') {
await cancel([id]).catch(() => undefined);
}
if (typeof removeActive === 'function') {
await removeActive([{ id }]).catch(() => undefined);
}
} catch (err) {
console.warn('Failed to clear Tauri notifications for room:', err);
}
}
};
/**
* 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;
iconBase64?: string;
bigPictureBase64?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId?: string;
group?: NotificationGroupInfo;
onClick?: () => void;
}): Promise<void> => {
const {
title,
body,
icon,
iconBase64,
bigPictureBase64,
senderName,
messageText,
conversationTitle,
path,
roomId,
group,
onClick,
} = options;
const extra = {
...(path ? { path } : {}),
...(roomId ? { roomId } : {}),
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
};
const hasExtra = Object.keys(extra).length > 0;
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
const groupId = group?.groupId;
const groupName = group?.groupName;
// 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,
// Stable per-room id replaces prior tray entry instead of stacking
id: roomId ? notificationIdForRoom(roomId) : undefined,
// Use the channel on Android
channelId: isAndroid() ? channelId : undefined,
group: groupId,
icon,
// Store path/roomId in extra data for tap handling + clear-on-read
extra: hasExtra ? extra : undefined,
});
if (isAndroid() && groupId && groupName) {
await tauriSendNotification({
title: groupName,
body: 'New messages',
id: notificationIdForGroupSummary(groupId),
channelId,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
}).catch(() => undefined);
}
}
return;
} catch (err) {
console.warn('Tauri notification failed:', err);
}
}
if (isCapacitorNative()) {
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
if (roomId && groupId && groupName && group) {
try {
const { showNativeNotification } = await import('./backgroundSync');
const shown = await showNativeNotification({
title,
body,
senderName: senderName ?? title,
messageText: messageText ?? body,
conversationTitle,
path,
roomId,
groupId,
groupName,
kind: group.kind,
largeIconBase64: iconBase64,
bigPictureBase64,
});
if (shown) return;
} catch (err) {
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
}
}
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions();
if (perm.display !== 'granted') {
perm = await LocalNotifications.requestPermissions();
}
if (perm.display === 'granted') {
await ensureCapacitorNotificationChannels();
// One notification per room: same id updates the existing tray entry.
const id = roomId
? notificationIdForRoom(roomId)
: Math.floor(Date.now() % 2147483647);
const roomNotification: Record<string, unknown> = {
id,
title,
body,
channelId: isAndroid() ? channelId : undefined,
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
extra: hasExtra ? extra : undefined,
threadIdentifier: groupId,
};
if (isAndroid() && groupId) {
roomNotification.group = groupId;
if (groupName) {
roomNotification.summaryText = groupName;
}
if (group?.roomName && group.roomName !== title) {
roomNotification.largeBody = body;
}
}
const notifications: Array<Record<string, unknown>> = [roomNotification];
// Nest rooms under a space / Directs / Home summary in the shade.
if (isAndroid() && groupId && groupName) {
notifications.push({
id: notificationIdForGroupSummary(groupId),
title: groupName,
body: 'New messages',
channelId,
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
});
}
await LocalNotifications.schedule({
notifications: notifications as any,
});
}
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';
}
};