506 lines
13 KiB
TypeScript
506 lines
13 KiB
TypeScript
import {
|
|
app,
|
|
clipboard,
|
|
desktopCapturer,
|
|
dialog,
|
|
ipcMain,
|
|
Notification,
|
|
shell
|
|
} from 'electron';
|
|
import * as fs from 'fs';
|
|
import * as fsp from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import {
|
|
getDesktopSettingsSnapshot,
|
|
updateDesktopSettings,
|
|
type DesktopSettings
|
|
} from '../desktop-settings';
|
|
import {
|
|
activateLinuxScreenShareAudioRouting,
|
|
deactivateLinuxScreenShareAudioRouting,
|
|
prepareLinuxScreenShareAudioRouting,
|
|
startLinuxScreenShareMonitorCapture,
|
|
stopLinuxScreenShareMonitorCapture
|
|
} from '../audio/linux-screen-share-routing';
|
|
import {
|
|
checkForDesktopUpdates,
|
|
configureDesktopUpdaterContext,
|
|
getDesktopUpdateState,
|
|
handleDesktopSettingsChanged,
|
|
restartToApplyUpdate,
|
|
readDesktopUpdateServerHealth,
|
|
type DesktopUpdateServerContext
|
|
} from '../update/desktop-updater';
|
|
import { consumePendingDeepLink } from '../app/deep-links';
|
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
|
import {
|
|
getMainWindow,
|
|
getWindowIconPath,
|
|
updateCloseToTraySetting
|
|
} from '../window/create-window';
|
|
import {
|
|
deleteSavedTheme,
|
|
getSavedThemesPath,
|
|
listSavedThemes,
|
|
readSavedTheme,
|
|
writeSavedTheme
|
|
} from '../theme-library';
|
|
|
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
|
const FILE_CLIPBOARD_FORMATS = [
|
|
'x-special/gnome-copied-files',
|
|
'text/uri-list',
|
|
'public.file-url',
|
|
'FileNameW'
|
|
] as const;
|
|
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
|
'.7z': 'application/x-7z-compressed',
|
|
'.aac': 'audio/aac',
|
|
'.avi': 'video/x-msvideo',
|
|
'.bmp': 'image/bmp',
|
|
'.csv': 'text/csv',
|
|
'.doc': 'application/msword',
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'.gif': 'image/gif',
|
|
'.gz': 'application/gzip',
|
|
'.heic': 'image/heic',
|
|
'.jpeg': 'image/jpeg',
|
|
'.jpg': 'image/jpeg',
|
|
'.json': 'application/json',
|
|
'.m4a': 'audio/mp4',
|
|
'.md': 'text/markdown',
|
|
'.mov': 'video/quicktime',
|
|
'.mp3': 'audio/mpeg',
|
|
'.mp4': 'video/mp4',
|
|
'.ogg': 'audio/ogg',
|
|
'.pdf': 'application/pdf',
|
|
'.png': 'image/png',
|
|
'.ppt': 'application/vnd.ms-powerpoint',
|
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'.rar': 'application/vnd.rar',
|
|
'.svg': 'image/svg+xml',
|
|
'.tar': 'application/x-tar',
|
|
'.txt': 'text/plain',
|
|
'.wav': 'audio/wav',
|
|
'.webm': 'video/webm',
|
|
'.webp': 'image/webp',
|
|
'.xls': 'application/vnd.ms-excel',
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'.xml': 'application/xml',
|
|
'.zip': 'application/zip'
|
|
};
|
|
|
|
interface ClipboardFilePayload {
|
|
data: string;
|
|
lastModified: number;
|
|
mime: string;
|
|
name: string;
|
|
path?: string;
|
|
}
|
|
|
|
interface DesktopNotificationPayload {
|
|
body: string;
|
|
requestAttention?: boolean;
|
|
title: string;
|
|
}
|
|
|
|
function resolveLinuxDisplayServer(): string {
|
|
if (process.platform !== 'linux') {
|
|
return 'N/A';
|
|
}
|
|
|
|
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
if (ozonePlatform === 'wayland') {
|
|
return 'Wayland';
|
|
}
|
|
|
|
if (ozonePlatform === 'x11') {
|
|
return 'X11';
|
|
}
|
|
|
|
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
if (ozonePlatformHint === 'wayland') {
|
|
return 'Wayland';
|
|
}
|
|
|
|
if (ozonePlatformHint === 'x11') {
|
|
return 'X11';
|
|
}
|
|
|
|
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
|
|
.toLowerCase();
|
|
|
|
if (sessionType === 'wayland') {
|
|
return 'Wayland';
|
|
}
|
|
|
|
if (sessionType === 'x11') {
|
|
return 'X11';
|
|
}
|
|
|
|
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
|
|
return 'Wayland';
|
|
}
|
|
|
|
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
|
|
return 'X11';
|
|
}
|
|
|
|
return 'Unknown (Linux)';
|
|
}
|
|
|
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
|
return FILE_CLIPBOARD_FORMATS.some(
|
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
|
);
|
|
}
|
|
|
|
function extractClipboardFilePaths(buffer: Buffer, format: string): string[] {
|
|
const textVariants = new Set<string>();
|
|
const utf8Text = buffer.toString('utf8').replace(/\0/g, '')
|
|
.trim();
|
|
|
|
if (utf8Text) {
|
|
textVariants.add(utf8Text);
|
|
}
|
|
|
|
if (format.toLowerCase() === 'filenamew') {
|
|
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n')
|
|
.trim();
|
|
|
|
if (utf16Text) {
|
|
textVariants.add(utf16Text);
|
|
}
|
|
}
|
|
|
|
const filePaths = new Set<string>();
|
|
|
|
for (const text of textVariants) {
|
|
for (const filePath of parseClipboardFilePathText(text)) {
|
|
filePaths.add(filePath);
|
|
}
|
|
}
|
|
|
|
return [...filePaths];
|
|
}
|
|
|
|
function parseClipboardFilePathText(text: string): string[] {
|
|
return text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(
|
|
(line) =>
|
|
!!line &&
|
|
!line.startsWith('#') &&
|
|
line.toLowerCase() !== 'copy' &&
|
|
line.toLowerCase() !== 'cut'
|
|
)
|
|
.flatMap((line) => {
|
|
if (line.startsWith('file://')) {
|
|
try {
|
|
return [fileURLToPath(line)];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return path.isAbsolute(line) ? [line] : [];
|
|
});
|
|
}
|
|
|
|
function inferMimeType(filePath: string): string {
|
|
return MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? DEFAULT_MIME_TYPE;
|
|
}
|
|
|
|
async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
|
const formats = clipboard.availableFormats();
|
|
const filePaths = new Set<string>();
|
|
|
|
for (const format of formats) {
|
|
if (!isSupportedClipboardFileFormat(format)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const buffer = clipboard.readBuffer(format);
|
|
|
|
for (const filePath of extractClipboardFilePaths(buffer, format)) {
|
|
filePaths.add(filePath);
|
|
}
|
|
} catch {
|
|
/* ignore unreadable clipboard formats */
|
|
}
|
|
}
|
|
|
|
const files: ClipboardFilePayload[] = [];
|
|
|
|
for (const filePath of filePaths) {
|
|
try {
|
|
const stats = await fsp.stat(filePath);
|
|
|
|
if (!stats.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const data = await fsp.readFile(filePath);
|
|
|
|
files.push({
|
|
data: data.toString('base64'),
|
|
lastModified: Math.round(stats.mtimeMs),
|
|
mime: inferMimeType(filePath),
|
|
name: path.basename(filePath),
|
|
path: filePath
|
|
});
|
|
} catch {
|
|
/* ignore files that are no longer readable */
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
export function setupSystemHandlers(): void {
|
|
ipcMain.on('get-linux-display-server', (event) => {
|
|
event.returnValue = resolveLinuxDisplayServer();
|
|
});
|
|
|
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
|
await shell.openExternal(url);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
|
|
|
ipcMain.handle('get-sources', async () => {
|
|
try {
|
|
const thumbnailSize = { width: 240, height: 150 };
|
|
const [screenSources, windowSources] = await Promise.all([
|
|
desktopCapturer.getSources({
|
|
types: ['screen'],
|
|
thumbnailSize
|
|
}),
|
|
desktopCapturer.getSources({
|
|
types: ['window'],
|
|
thumbnailSize,
|
|
fetchWindowIcons: true
|
|
})
|
|
]);
|
|
const sources = [...screenSources, ...windowSources];
|
|
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
|
|
|
return [...uniqueSources.values()].map((source) => ({
|
|
id: source.id,
|
|
name: source.name,
|
|
thumbnail: source.thumbnail.toDataURL()
|
|
}));
|
|
} catch {
|
|
// desktopCapturer.getSources fails on Wayland; return empty so the
|
|
// renderer falls through to getDisplayMedia with the system picker.
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
|
return await prepareLinuxScreenShareAudioRouting();
|
|
});
|
|
|
|
ipcMain.handle('activate-linux-screen-share-audio-routing', async () => {
|
|
return await activateLinuxScreenShareAudioRouting();
|
|
});
|
|
|
|
ipcMain.handle('deactivate-linux-screen-share-audio-routing', async () => {
|
|
return await deactivateLinuxScreenShareAudioRouting();
|
|
});
|
|
|
|
ipcMain.handle('start-linux-screen-share-monitor-capture', async (event) => {
|
|
return await startLinuxScreenShareMonitorCapture(event.sender);
|
|
});
|
|
|
|
ipcMain.handle('stop-linux-screen-share-monitor-capture', async (_event, captureId?: string) => {
|
|
return await stopLinuxScreenShareMonitorCapture(captureId);
|
|
});
|
|
|
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
|
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
|
return await writeSavedTheme(fileName, text);
|
|
});
|
|
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
|
|
return await deleteSavedTheme(fileName);
|
|
});
|
|
|
|
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
|
|
|
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
|
|
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
|
|
const body = typeof payload?.body === 'string' ? payload.body : '';
|
|
const mainWindow = getMainWindow();
|
|
const suppressSystemNotification = mainWindow?.isVisible() === true
|
|
&& !mainWindow.isMinimized()
|
|
&& mainWindow.isMaximized();
|
|
|
|
if (!title) {
|
|
return false;
|
|
}
|
|
|
|
if (!suppressSystemNotification && Notification.isSupported()) {
|
|
try {
|
|
const notification = new Notification({
|
|
title,
|
|
body,
|
|
icon: getWindowIconPath(),
|
|
silent: true
|
|
});
|
|
|
|
notification.on('click', () => {
|
|
if (!mainWindow) {
|
|
return;
|
|
}
|
|
|
|
if (mainWindow.isMinimized()) {
|
|
mainWindow.restore();
|
|
}
|
|
|
|
if (!mainWindow.isVisible()) {
|
|
mainWindow.show();
|
|
}
|
|
|
|
mainWindow.focus();
|
|
});
|
|
|
|
notification.show();
|
|
} catch {
|
|
// Ignore notification center failures and still attempt taskbar attention.
|
|
}
|
|
}
|
|
|
|
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
|
|
mainWindow.flashFrame(true);
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('request-window-attention', () => {
|
|
const mainWindow = getMainWindow();
|
|
|
|
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
|
|
return false;
|
|
}
|
|
|
|
mainWindow.flashFrame(true);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('clear-window-attention', () => {
|
|
getMainWindow()?.flashFrame(false);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
|
|
|
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
|
|
return await readDesktopUpdateServerHealth(serverUrl);
|
|
});
|
|
|
|
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
|
return await configureDesktopUpdaterContext(context);
|
|
});
|
|
|
|
ipcMain.handle('check-for-app-updates', async () => {
|
|
return await checkForDesktopUpdates();
|
|
});
|
|
|
|
ipcMain.handle('restart-to-apply-update', () => restartToApplyUpdate());
|
|
|
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
|
const snapshot = updateDesktopSettings(patch);
|
|
|
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
|
updateCloseToTraySetting(snapshot.closeToTray);
|
|
await handleDesktopSettingsChanged();
|
|
return snapshot;
|
|
});
|
|
|
|
ipcMain.handle('relaunch-app', () => {
|
|
app.relaunch();
|
|
app.exit(0);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
|
try {
|
|
await fsp.access(filePath, fs.constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('read-file', async (_event, filePath: string) => {
|
|
const data = await fsp.readFile(filePath);
|
|
|
|
return data.toString('base64');
|
|
});
|
|
|
|
ipcMain.handle('read-clipboard-files', async () => {
|
|
return await readClipboardFiles();
|
|
});
|
|
|
|
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
|
const buffer = Buffer.from(base64Data, 'base64');
|
|
|
|
await fsp.writeFile(filePath, buffer);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
|
try {
|
|
await fsp.unlink(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code === 'ENOENT') {
|
|
return true;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('save-file-as', async (_event, defaultFileName: string, base64Data: string) => {
|
|
const result = await dialog.showSaveDialog({
|
|
defaultPath: defaultFileName
|
|
});
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { saved: false,
|
|
cancelled: true };
|
|
}
|
|
|
|
const buffer = Buffer.from(base64Data, 'base64');
|
|
|
|
await fsp.writeFile(result.filePath, buffer);
|
|
|
|
return { saved: true,
|
|
cancelled: false };
|
|
});
|
|
|
|
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
|
|
await fsp.mkdir(dirPath, { recursive: true });
|
|
return true;
|
|
});
|
|
}
|