Files
Toju/electron/ipc/system.ts
2026-03-10 23:38:57 +01:00

330 lines
8.5 KiB
TypeScript

import {
app,
clipboard,
desktopCapturer,
dialog,
ipcMain,
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,
type DesktopUpdateServerContext
} from '../update/desktop-updater';
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;
}
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.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('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL()
}));
});
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-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
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 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;
});
}