|
| 1 | +import { clipboard } from 'electron'; |
| 2 | +import path from 'path'; |
| 3 | + |
| 4 | +// 仅在 Windows 平台辅助操作剪贴板多文件格式。 |
| 5 | +type ClipboardExModule = typeof import('electron-clipboard-ex'); |
| 6 | + |
| 7 | +const DROPFILES_HEADER_SIZE = 20; |
| 8 | + |
| 9 | +let clipboardExModule: ClipboardExModule | null = null; |
| 10 | + |
| 11 | +/** |
| 12 | + * Windows 平台专用:尝试加载第三方库 electron-clipboard-ex。 |
| 13 | + * 这个库能够调用系统底层接口写入“文件复制”数据,成功率更高。 |
| 14 | + * 其他系统无需加载它,因此这里做了“按需加载”的处理。 |
| 15 | + */ |
| 16 | +const ensureClipboardEx = (): ClipboardExModule | null => { |
| 17 | + if (process.platform !== 'win32') return null; |
| 18 | + if (clipboardExModule) return clipboardExModule; |
| 19 | + try { |
| 20 | + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires |
| 21 | + clipboardExModule = require('electron-clipboard-ex'); |
| 22 | + } catch { |
| 23 | + clipboardExModule = null; |
| 24 | + } |
| 25 | + return clipboardExModule; |
| 26 | +}; |
| 27 | + |
| 28 | +/** |
| 29 | + * 把一组文件路径变成 Windows 规定的文本格式。 |
| 30 | + * 要求:每个路径之间用单个空字符分隔,最后再额外放两个空字符,表示列表结束。 |
| 31 | + * Windows 资源管理器会按这个格式解析我们复制到剪贴板的文件。 |
| 32 | + */ |
| 33 | +const buildWindowsFileListPayload = (files: string[]): Buffer => |
| 34 | + Buffer.from(`${files.join('\0')}\0\0`, 'utf16le'); |
| 35 | + |
| 36 | +/** |
| 37 | + * 构造 CF_HDROP 专用的二进制数据。 |
| 38 | + * 这是 Windows 复制文件时的底层格式,前 20 字节是固定的结构头, |
| 39 | + * 后面紧跟着具体的文件路径(由 buildWindowsFileListPayload 生成)。 |
| 40 | + * 只要把这个内容写入剪贴板,任何支持粘贴文件的程序都能理解。 |
| 41 | + */ |
| 42 | +const buildWindowsFileDropBuffer = (files: string[]): Buffer => { |
| 43 | + const payload = buildWindowsFileListPayload(files); |
| 44 | + const header = Buffer.alloc(DROPFILES_HEADER_SIZE); |
| 45 | + header.writeUInt32LE(DROPFILES_HEADER_SIZE, 0); |
| 46 | + header.writeInt32LE(0, 4); |
| 47 | + header.writeInt32LE(0, 8); |
| 48 | + header.writeUInt32LE(0, 12); |
| 49 | + header.writeUInt32LE(1, 16); |
| 50 | + |
| 51 | + const result = Buffer.alloc(header.length + payload.length); |
| 52 | + for (let i = 0; i < header.length; i += 1) { |
| 53 | + result[i] = header[i]; |
| 54 | + } |
| 55 | + for (let i = 0; i < payload.length; i += 1) { |
| 56 | + result[header.length + i] = payload[i]; |
| 57 | + } |
| 58 | + return result; |
| 59 | +}; |
| 60 | + |
| 61 | +/** |
| 62 | + * 复制/移动/创建快捷方式 等不同操作在 Windows 中对应不同的“意图”值。 |
| 63 | + * Preferred DropEffect 告诉系统:当前剪贴板数据应该以何种方式处理。 |
| 64 | + * 我们默认写入“copy”,相当于普通的复制粘贴。 |
| 65 | + */ |
| 66 | +const buildDropEffectBuffer = (effect: 'copy' | 'move' | 'link' = 'copy') => { |
| 67 | + const effectMap = { |
| 68 | + copy: 1, |
| 69 | + move: 2, |
| 70 | + link: 4, |
| 71 | + } as const; |
| 72 | + const buffer = Buffer.alloc(4); |
| 73 | + buffer.writeUInt32LE(effectMap[effect], 0); |
| 74 | + return buffer; |
| 75 | +}; |
| 76 | + |
| 77 | +/** |
| 78 | + * 直接使用 Electron 内置 API 写入多种剪贴板格式。 |
| 79 | + * 步骤: |
| 80 | + * 1. 写入二进制的 CF_HDROP(含头部与路径列表) |
| 81 | + * 2. 写入纯文本形式的 FileNameW(备选格式) |
| 82 | + * 3. 写入 Preferred DropEffect(告诉系统“这是复制”) |
| 83 | + * 全部成功后,读取一次 CF_HDROP 的长度,确认剪贴板里确实有内容。 |
| 84 | + */ |
| 85 | +const writeWindowsBuffers = (files: string[]): boolean => { |
| 86 | + try { |
| 87 | + clipboard.writeBuffer('CF_HDROP', buildWindowsFileDropBuffer(files)); |
| 88 | + clipboard.writeBuffer('FileNameW', buildWindowsFileListPayload(files)); |
| 89 | + clipboard.writeBuffer('Preferred DropEffect', buildDropEffectBuffer('copy')); |
| 90 | + return clipboard.readBuffer('CF_HDROP').length > 0; |
| 91 | + } catch { |
| 92 | + return false; |
| 93 | + } |
| 94 | +}; |
| 95 | + |
| 96 | +/** |
| 97 | + * 如果项目中安装了 electron-clipboard-ex,我们优先使用它。 |
| 98 | + * 理由:该库通过原生方式与系统交互,兼容性往往优于 Electron 的 JS 层写入。 |
| 99 | + * 调用成功后,必要时读回文件列表做一次数量校验,确保复制的文件数量正确。 |
| 100 | + */ |
| 101 | +const writeWithClipboardEx = (files: string[]): boolean => { |
| 102 | + const clipboardEx = ensureClipboardEx(); |
| 103 | + if (!clipboardEx) return false; |
| 104 | + try { |
| 105 | + clipboardEx.writeFilePaths(files); |
| 106 | + if (typeof clipboardEx.readFilePaths === 'function') { |
| 107 | + const result = clipboardEx.readFilePaths(); |
| 108 | + return Array.isArray(result) && result.length === files.length; |
| 109 | + } |
| 110 | + return true; |
| 111 | + } catch { |
| 112 | + return false; |
| 113 | + } |
| 114 | +}; |
| 115 | + |
| 116 | +/** |
| 117 | + * 对外暴露的唯一入口。 |
| 118 | + * 1. 先把所有路径换成 Windows 可识别的标准形式(path.normalize)。 |
| 119 | + * 2. 尝试使用 electron-clipboard-ex 写入,如果成功就结束。 |
| 120 | + * 3. 若第三方库不可用或失败,再退回 Electron 原生写入流程。 |
| 121 | + * 这一层屏蔽了所有细节,外部调用者只需传入字符串数组即可。 |
| 122 | + */ |
| 123 | +export const copyFilesToWindowsClipboard = (files: string[]): boolean => { |
| 124 | + const normalizedFiles = files |
| 125 | + .map((filePath) => path.normalize(filePath)) |
| 126 | + .filter(Boolean); |
| 127 | + if (!normalizedFiles.length) return false; |
| 128 | + if (writeWithClipboardEx(normalizedFiles)) { |
| 129 | + return true; |
| 130 | + } |
| 131 | + return writeWindowsBuffers(normalizedFiles); |
| 132 | +}; |
| 133 | + |
0 commit comments