Add file upload from clipboard
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
app,
|
||||
clipboard,
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
} 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 } from '../desktop-settings';
|
||||
import {
|
||||
activateLinuxScreenShareAudioRouting,
|
||||
@@ -16,6 +19,166 @@ import {
|
||||
stopLinuxScreenShareMonitorCapture
|
||||
} from '../audio/linux-screen-share-routing';
|
||||
|
||||
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://'))) {
|
||||
@@ -88,6 +251,10 @@ export function setupSystemHandlers(): void {
|
||||
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');
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ export interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ClipboardFilePayload {
|
||||
data: string;
|
||||
lastModified: number;
|
||||
mime: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
@@ -58,6 +66,7 @@ export interface ElectronAPI {
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
@@ -114,6 +123,7 @@ const electronAPI: ElectronAPI = {
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
|
||||
Reference in New Issue
Block a user