From 7d863bfcc4878a5fe37f249cb14f6f245cb40c24 Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Thu, 26 Feb 2026 15:34:18 +1100 Subject: [PATCH] feat(clipboard): implement proper Linux file reference clipboard support - Add _detectLinuxClipboardMethod() to detect display protocol and DE - Use xclip on X11 and wl-copy on Wayland with correct MIME types - GTK desktops (GNOME, XFCE, Cinnamon): x-special/gnome-copied-files - KDE desktops: text/uri-list with CRLF line ending - Unknown DEs default to GTK format as most widely supported - Add 2s spawnSync timeout to prevent hanging on broken compositors - Trim env var checks to reject whitespace-only values - Fall back to plain-text file:// URI when tools are unavailable - Add 25 unit tests covering all X11/Wayland/DE/fallback combinations --- src/utils/clipboard.js | 84 +++++++- tests/unit/utils/clipboard.test.js | 316 ++++++++++++++++++++++++++++- 2 files changed, 393 insertions(+), 7 deletions(-) diff --git a/src/utils/clipboard.js b/src/utils/clipboard.js index da6558e..46d9906 100644 --- a/src/utils/clipboard.js +++ b/src/utils/clipboard.js @@ -51,12 +51,34 @@ class Clipboard { } } else if (process.platform === 'linux') { try { - // Use file:// URI format for Linux clipboard compatibility const fileUri = url.pathToFileURL(filePath).toString(); - await this.copyText(fileUri); + const method = Clipboard._detectLinuxClipboardMethod(fileUri); + + if (!method) { + // Neither xclip nor wl-copy available; fall back to plain-text URI + return this.copyText(fileUri); + } + + const result = spawnSync(method.tool, method.args, { + input: method.payload, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 2000, + }); + + if (result.error || result.status !== 0) { + logger.debug( + `Failed to copy file reference using ${method.tool}, falling back to text:`, + result.error?.message || `exit code ${result.status}`, + ); + return this.copyText(fileUri); + } } catch (error) { - logger.debug('Failed to copy file URI, falling back to text path:', error.message); - return this.copyText(filePath); + logger.debug( + 'Failed to copy file reference on Linux, falling back to text:', + error.message, + ); + const fileUri = url.pathToFileURL(filePath).toString(); + return this.copyText(fileUri); } } else if (process.platform === 'darwin') { try { @@ -78,6 +100,60 @@ tell app "Finder" to set the clipboard to aFile`; } } + /** + * Detect the correct Linux clipboard tool, MIME type, and payload for file references. + * + * Desktop environment detection: + * - GTK-based (GNOME, XFCE, Cinnamon, MATE, Budgie): uses `x-special/gnome-copied-files` + * - KDE/Qt: uses `text/uri-list` + * - Unknown: defaults to GTK format (more widely supported) + * + * Display protocol detection: + * - Wayland ($WAYLAND_DISPLAY set): uses `wl-copy` + * - X11 ($DISPLAY set): uses `xclip` + * - Neither: returns null (no clipboard tool available) + * + * @param {string} fileUri - The file:// URI to place on the clipboard + * @returns {{ tool: string, args: string[], payload: string } | null} + * @private + */ + static _detectLinuxClipboardMethod(fileUri) { + const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toUpperCase(); + const isKDE = desktop.includes('KDE'); + + // Determine MIME type and payload based on desktop environment + let mimeType; + let payload; + if (isKDE) { + mimeType = 'text/uri-list'; + payload = `${fileUri}\r\n`; + } else { + // GTK-based desktops (GNOME, XFCE, Cinnamon, MATE, Budgie) and unknown DEs + mimeType = 'x-special/gnome-copied-files'; + payload = `copy\n${fileUri}`; + } + + // Determine clipboard tool based on display protocol + if ((process.env.WAYLAND_DISPLAY || '').trim()) { + return { + tool: 'wl-copy', + args: ['--type', mimeType], + payload, + }; + } + + if ((process.env.DISPLAY || '').trim()) { + return { + tool: 'xclip', + args: ['-selection', 'clipboard', '-t', mimeType], + payload, + }; + } + + // No display server detected + return null; + } + /** * Reveal file in file manager (cross-platform) * @param {string} filePath - The file path to reveal diff --git a/tests/unit/utils/clipboard.test.js b/tests/unit/utils/clipboard.test.js index cca8d15..a0fc452 100644 --- a/tests/unit/utils/clipboard.test.js +++ b/tests/unit/utils/clipboard.test.js @@ -142,16 +142,326 @@ describe('Clipboard', () => { }); describe('copyFileReference — Linux', () => { - beforeEach(() => setPlatform('linux')); + const savedEnv = {}; + + beforeEach(() => { + setPlatform('linux'); + // Save and clear relevant env vars + savedEnv.DISPLAY = process.env.DISPLAY; + savedEnv.WAYLAND_DISPLAY = process.env.WAYLAND_DISPLAY; + savedEnv.XDG_CURRENT_DESKTOP = process.env.XDG_CURRENT_DESKTOP; + delete process.env.DISPLAY; + delete process.env.WAYLAND_DISPLAY; + delete process.env.XDG_CURRENT_DESKTOP; + }); + + afterEach(() => { + // Restore env vars + if (savedEnv.DISPLAY !== undefined) process.env.DISPLAY = savedEnv.DISPLAY; + else delete process.env.DISPLAY; + if (savedEnv.WAYLAND_DISPLAY !== undefined) + process.env.WAYLAND_DISPLAY = savedEnv.WAYLAND_DISPLAY; + else delete process.env.WAYLAND_DISPLAY; + if (savedEnv.XDG_CURRENT_DESKTOP !== undefined) + process.env.XDG_CURRENT_DESKTOP = savedEnv.XDG_CURRENT_DESKTOP; + else delete process.env.XDG_CURRENT_DESKTOP; + }); + + it('X11 + GTK: uses xclip with x-special/gnome-copied-files', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('X11 + KDE: uses xclip with text/uri-list', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'KDE'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); - it('copies file:// URI to clipboard', async () => { + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'text/uri-list'], + expect.objectContaining({ + input: 'file:///home/user/file.txt\r\n', + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('Wayland + GTK: uses wl-copy with x-special/gnome-copied-files', async () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'wl-copy', + ['--type', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('Wayland + KDE: uses wl-copy with text/uri-list', async () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.XDG_CURRENT_DESKTOP = 'KDE'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'wl-copy', + ['--type', 'text/uri-list'], + expect.objectContaining({ + input: 'file:///home/user/file.txt\r\n', + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('no display server: falls back to plain-text URI copy', async () => { + // Neither DISPLAY nor WAYLAND_DISPLAY set + const spy = jest.spyOn(Clipboard, 'copyText').mockResolvedValueOnce(); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spy).toHaveBeenCalledWith('file:///home/user/file.txt'); + expect(spawnSync).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('tool failure: falls back to plain-text URI copy', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + spawnSync.mockReturnValueOnce({ status: 1, error: null }); + const spy = jest.spyOn(Clipboard, 'copyText').mockResolvedValueOnce(); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ input: 'copy\nfile:///home/user/file.txt' }), + ); + expect(spy).toHaveBeenCalledWith('file:///home/user/file.txt'); + spy.mockRestore(); + }); + + it('tool spawn error: falls back to plain-text URI copy', async () => { + process.env.DISPLAY = ':0'; + spawnSync.mockReturnValueOnce({ status: null, error: new Error('ENOENT') }); const spy = jest.spyOn(Clipboard, 'copyText').mockResolvedValueOnce(); await Clipboard.copyFileReference('/home/user/file.txt'); - expect(spy).toHaveBeenCalledWith(expect.stringMatching(/^file:\/\/\/home\/user\/file\.txt$/)); + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('file:///home/user/file.txt'); spy.mockRestore(); }); + + it('spawnSync throws synchronously: falls back to plain-text URI copy', async () => { + process.env.DISPLAY = ':0'; + spawnSync.mockImplementationOnce(() => { + throw new Error('spawn exploded'); + }); + const spy = jest.spyOn(Clipboard, 'copyText').mockResolvedValueOnce(); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spy).toHaveBeenCalledWith('file:///home/user/file.txt'); + spy.mockRestore(); + }); + + it('URI-encodes paths with spaces correctly', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/my documents/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/my%20documents/file.txt', + }), + ); + }); + + it('no display server: URI-encodes spaces in fallback plain-text copy', async () => { + const spy = jest.spyOn(Clipboard, 'copyText').mockResolvedValueOnce(); + + await Clipboard.copyFileReference('/home/user/my documents/file.txt'); + + expect(spy).toHaveBeenCalledWith('file:///home/user/my%20documents/file.txt'); + expect(spawnSync).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('XFCE desktop uses GTK format', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'XFCE'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + }), + ); + }); + + it('Cinnamon desktop uses GTK format', async () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'X-Cinnamon'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + }), + ); + }); + + it('unknown desktop defaults to GTK format', async () => { + process.env.DISPLAY = ':0'; + // XDG_CURRENT_DESKTOP not set + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledWith( + 'xclip', + ['-selection', 'clipboard', '-t', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + }), + ); + }); + + it('Wayland takes priority over X11 when both are set', async () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + spawnSync.mockReturnValueOnce({ status: 0, error: null }); + + await Clipboard.copyFileReference('/home/user/file.txt'); + + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(spawnSync).toHaveBeenCalledWith( + 'wl-copy', + ['--type', 'x-special/gnome-copied-files'], + expect.objectContaining({ + input: 'copy\nfile:///home/user/file.txt', + }), + ); + }); + }); + + describe('_detectLinuxClipboardMethod', () => { + const savedEnv = {}; + + beforeEach(() => { + savedEnv.DISPLAY = process.env.DISPLAY; + savedEnv.WAYLAND_DISPLAY = process.env.WAYLAND_DISPLAY; + savedEnv.XDG_CURRENT_DESKTOP = process.env.XDG_CURRENT_DESKTOP; + delete process.env.DISPLAY; + delete process.env.WAYLAND_DISPLAY; + delete process.env.XDG_CURRENT_DESKTOP; + }); + + afterEach(() => { + if (savedEnv.DISPLAY !== undefined) process.env.DISPLAY = savedEnv.DISPLAY; + else delete process.env.DISPLAY; + if (savedEnv.WAYLAND_DISPLAY !== undefined) + process.env.WAYLAND_DISPLAY = savedEnv.WAYLAND_DISPLAY; + else delete process.env.WAYLAND_DISPLAY; + if (savedEnv.XDG_CURRENT_DESKTOP !== undefined) + process.env.XDG_CURRENT_DESKTOP = savedEnv.XDG_CURRENT_DESKTOP; + else delete process.env.XDG_CURRENT_DESKTOP; + }); + + it('returns null when no display server is available', () => { + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result).toBeNull(); + }); + + it('returns xclip config for X11', () => { + process.env.DISPLAY = ':0'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.tool).toBe('xclip'); + expect(result.args).toContain('-selection'); + expect(result.args).toContain('clipboard'); + }); + + it('returns wl-copy config for Wayland', () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.tool).toBe('wl-copy'); + expect(result.args).toContain('--type'); + }); + + it('uses GTK MIME type for GNOME', () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.args).toContain('x-special/gnome-copied-files'); + expect(result.payload).toBe('copy\nfile:///test'); + }); + + it('uses KDE MIME type for KDE', () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'KDE'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.args).toContain('text/uri-list'); + expect(result.payload).toBe('file:///test\r\n'); + }); + + it('is case-insensitive for KDE detection', () => { + process.env.DISPLAY = ':0'; + process.env.XDG_CURRENT_DESKTOP = 'kde'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.args).toContain('text/uri-list'); + expect(result.payload).toBe('file:///test\r\n'); + }); + + it('prefers Wayland when both WAYLAND_DISPLAY and DISPLAY are set', () => { + process.env.WAYLAND_DISPLAY = 'wayland-0'; + process.env.DISPLAY = ':0'; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result.tool).toBe('wl-copy'); + }); + + it('returns null when env vars are whitespace-only', () => { + process.env.DISPLAY = ' '; + process.env.WAYLAND_DISPLAY = ' '; + const result = Clipboard._detectLinuxClipboardMethod('file:///test'); + expect(result).toBeNull(); + }); }); describe('revealInFinder — Windows', () => {